mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-31 23:09:58 +08:00
321 lines
13 KiB
Python
321 lines
13 KiB
Python
"""纯快照农场合成:目标尺寸、独立素材、可见状态成图缓存。"""
|
|
|
|
from dataclasses import replace
|
|
import hashlib
|
|
from io import BytesIO
|
|
import logging
|
|
from math import sqrt
|
|
from pathlib import Path
|
|
from time import perf_counter
|
|
|
|
from PIL import Image, ImageDraw, UnidentifiedImageError
|
|
|
|
from .assets import Assets, ByteLRU
|
|
from .canvas import Canvas, font
|
|
from .layout import scene_from_snapshot
|
|
from .models import RenderOptions, RenderResult
|
|
from .placement import ground_contact, place_sprite
|
|
|
|
logger = logging.getLogger(__name__)
|
|
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
|
MAX_AVATAR_PIXELS = 4_000_000
|
|
PANEL_UNUSED_ROW = (168, 208)
|
|
|
|
|
|
class FarmRenderer:
|
|
def __init__(
|
|
self, font_path, asset_cache_bytes=32 * 1024 * 1024,
|
|
result_cache_bytes=8 * 1024 * 1024,
|
|
bundle_path=None,
|
|
):
|
|
self.font_path = Path(font_path).resolve()
|
|
if not self.font_path.is_file():
|
|
raise ValueError("Configure an existing Chinese font path")
|
|
font(self.font_path, 16)
|
|
self.assets = Assets(asset_cache_bytes, bundle_path=bundle_path)
|
|
self.results = ByteLRU(result_cache_bytes)
|
|
|
|
def scene(self, identity, snapshot, now):
|
|
return scene_from_snapshot(
|
|
identity, snapshot, now, self.assets.metadata,
|
|
)
|
|
|
|
def request_key(
|
|
self, identity, snapshot, now, options=None, avatar=None,
|
|
supported=("JPEG",),
|
|
):
|
|
options = options or RenderOptions()
|
|
if avatar is not None and not isinstance(avatar, bytes):
|
|
raise ValueError("Avatar must be bytes or None")
|
|
digest = hashlib.sha256(avatar).hexdigest() if (
|
|
avatar and len(avatar) <= MAX_AVATAR_BYTES
|
|
) else "default"
|
|
scene = self.scene(identity, snapshot, now)
|
|
return scene, options, options.codec(supported), digest
|
|
|
|
def render(
|
|
self, identity, snapshot, now, options=None, avatar=None,
|
|
supported=("JPEG",),
|
|
):
|
|
key = self.request_key(
|
|
identity, snapshot, now, options, avatar, supported,
|
|
)
|
|
cached = self.results.get(key)
|
|
if cached is not None:
|
|
logger.info("farm render cache=hit bytes=%s", len(cached.content))
|
|
return replace(cached, cache_hit=True, compose_ms=0, encode_ms=0)
|
|
scene, options, codec, _ = key
|
|
started = perf_counter()
|
|
canvas = self._base(scene, options)
|
|
self._plants(canvas, scene, options)
|
|
self._pests(canvas, scene, options)
|
|
self._decorations(canvas, options, "foreground")
|
|
self._panel(canvas, scene, options, avatar)
|
|
if options.show_plot_ids:
|
|
self._plot_ids(canvas, scene, options)
|
|
composed = perf_counter()
|
|
content = canvas.encode(codec, options.quality, options.method)
|
|
encoded = perf_counter()
|
|
result = RenderResult(
|
|
content, {"JPEG":"image/jpeg", "PNG":"image/png",
|
|
"WEBP":"image/webp"}[codec],
|
|
*canvas.image.size, (composed - started) * 1000,
|
|
(encoded - composed) * 1000,
|
|
)
|
|
self.results.put(key, result, len(content) + 4096)
|
|
logger.info(
|
|
"farm render cache=miss compose_ms=%.2f encode_ms=%.2f "
|
|
"width=%s height=%s bytes=%s codec=%s",
|
|
result.compose_ms, result.encode_ms, result.width,
|
|
result.height, len(content), codec,
|
|
)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _scale(value, options):
|
|
return round(value * options.width / 3072)
|
|
|
|
def _plot_ids(self, canvas, scene, options):
|
|
s = lambda n: self._scale(n, options)
|
|
owned = {plot.slot for plot in scene.plots}
|
|
soil_w, soil_h = self.assets.metadata['soil_size']
|
|
size = max(1, s(40))
|
|
for slot, pos in enumerate(self.assets.metadata['positions'], 1):
|
|
x = s(pos['x'] + soil_w/2)
|
|
y = s(pos['y'] + soil_h/2)
|
|
color = '#fff2ca' if slot in owned else '#d5e8bc'
|
|
canvas.rectangle(
|
|
(x-s(38), y-s(30), x+s(38), y+s(30)),
|
|
'#493b22', radius=s(30),
|
|
)
|
|
canvas.rectangle(
|
|
(x-s(36), y-s(28), x+s(36), y+s(28)),
|
|
color, radius=s(28),
|
|
)
|
|
label = str(slot)
|
|
width = canvas.measure_text(label, self.font_path, size)
|
|
box = font(self.font_path, size).getbbox(label)
|
|
canvas.text(
|
|
(round(x-width/2), round(y-(box[1]+box[3])/2)),
|
|
label, self.font_path, size, '#392917',
|
|
)
|
|
canvas.rectangle(
|
|
(s(76), s(1676), s(2996), s(1836)),
|
|
'#fff2ca', radius=s(24),
|
|
)
|
|
for y, text, text_size in (
|
|
(1694, '地块编号 · 浅色:已开垦 | 绿色:待开垦', 46),
|
|
(1760, '前两排交错编号,之后逐排推进。作物信息:农场详述', 40),
|
|
):
|
|
canvas.text(
|
|
(s(116), s(y)), text, self.font_path,
|
|
max(1, s(text_size)), '#392917',
|
|
)
|
|
|
|
def _base(self, scene, options):
|
|
slots = {p.slot: p.land_level for p in scene.plots}
|
|
key = ("base", options.theme, options.width,
|
|
tuple(sorted(slots.items())))
|
|
cached = self.assets.cache.get(key)
|
|
if cached is not None:
|
|
return Canvas(cached.copy())
|
|
s = lambda n: self._scale(n, options)
|
|
size = (options.width, s(1872))
|
|
background = self.assets.image(
|
|
"background/decorateIcon/"
|
|
f"decorate_{options.theme}_1.jpg", size,
|
|
)
|
|
canvas = Canvas(background)
|
|
self._decorations(canvas, options, "background")
|
|
soil_size = tuple(max(1, s(n)) for n in (
|
|
self.assets.metadata["soil_size"]
|
|
))
|
|
names = ('普通土地', '红土地', '黑土地', '金土地')
|
|
tiles = {level: self.assets.image(f'soil/{names[level]}.png', soil_size)
|
|
for level in set(slots.values())}
|
|
grass = self.assets.image('soil/草土地.png', soil_size)
|
|
expansion = False
|
|
for slot, pos in enumerate(self.assets.metadata["positions"], 1):
|
|
xy = (s(pos["x"]), s(pos["y"]))
|
|
canvas.paste(tiles[slots[slot]] if slot in slots else grass, xy)
|
|
if slot not in slots and not expansion:
|
|
icon = self.assets.image(
|
|
"background/expansion.png", (max(1, s(90)),) * 2,
|
|
)
|
|
canvas.paste(icon, (xy[0] + s(70), xy[1] + s(10)))
|
|
expansion = True
|
|
self.assets.cache.put(key, canvas.image, size[0] * size[1] * 4)
|
|
return canvas.copy()
|
|
|
|
def _decorations(self, canvas, options, layer):
|
|
s = lambda n: self._scale(n, options)
|
|
for item in self.assets.metadata["decorations"][options.theme]:
|
|
if item["layer"] != layer:
|
|
continue
|
|
image = self.assets.image(
|
|
item["sprite"], tuple(max(1, s(n)) for n in item["size"]),
|
|
)
|
|
canvas.paste(image, tuple(s(n) for n in item["position"]))
|
|
|
|
def _plants(self, canvas, scene, options):
|
|
s = lambda n: self._scale(n, options)
|
|
soil_w, soil_h = self.assets.metadata["soil_size"]
|
|
rules = self.assets.metadata["placement"]
|
|
(a, b), (c, d) = rules['scene_transform']
|
|
scene_scale = sqrt(abs(a*d-b*c))
|
|
first_ripe = True
|
|
for plot in scene.plots:
|
|
if not plot.sprite:
|
|
continue
|
|
pos = self.assets.metadata["positions"][plot.slot - 1]
|
|
metadata = self.assets.metadata["images"].get(plot.sprite)
|
|
if metadata is None:
|
|
logger.warning("farm render missing packaged crop sprite")
|
|
plant = Image.new("RGBA", (60, 60), "#597b45")
|
|
ImageDraw.Draw(plant).text(
|
|
(10, 0), "?", fill="white",
|
|
font=font(self.font_path, 48),
|
|
)
|
|
else:
|
|
plant = self.assets.image(plot.sprite)
|
|
factor = rules['growth_scale']
|
|
if plot.stage == 0 or plot.state == "withered":
|
|
factor = rules['common_scale']
|
|
scale = scene_scale*factor*options.width/3072
|
|
plant = plant.resize(
|
|
tuple(max(1, round(n*scale)) for n in plant.size),
|
|
Image.Resampling.LANCZOS,
|
|
)
|
|
contact = ground_contact(
|
|
plant, centered=plot.stage == 0 and plot.state != 'withered',
|
|
threshold=rules['contact_alpha_threshold'],
|
|
band_fraction=rules['contact_band_fraction'],
|
|
)
|
|
xy = (round((pos['x']+soil_w/2)*options.width/3072-contact[0]),
|
|
round((pos['y']+soil_h/2)*options.width/3072-contact[1]))
|
|
canvas.paste(plant, xy)
|
|
if plot.state == "ripe" and first_ripe:
|
|
icon = self.assets.image(
|
|
"background/ripe.png", (max(1, s(100)),) * 2,
|
|
)
|
|
canvas.paste(icon, (s(pos["x"] + 70), s(pos["y"] - 80)))
|
|
first_ripe = False
|
|
|
|
def _pests(self, canvas, scene, options):
|
|
soil_w, soil_h = self.assets.metadata["soil_size"]
|
|
transform = self.assets.metadata['placement']['scene_transform']
|
|
config = self.assets.metadata["pest"]
|
|
for plot in scene.plots:
|
|
if plot.state not in ("growing", "ripe"):
|
|
continue
|
|
pos = self.assets.metadata["positions"][plot.slot - 1]
|
|
for frame, (x, y) in zip(plot.pest_frames, config["positions"]):
|
|
image = self.assets.image(frame, config['display_size'])
|
|
image, xy = place_sprite(
|
|
image, (pos['x']+soil_w/2, pos['y']+soil_h/2),
|
|
(x, y), (.5, .5), 1, transform, options.width/3072,
|
|
)
|
|
canvas.paste(image, xy)
|
|
|
|
def _avatar(self, content, size):
|
|
if content and len(content) <= MAX_AVATAR_BYTES:
|
|
try:
|
|
with Image.open(BytesIO(content)) as image:
|
|
if image.width * image.height > MAX_AVATAR_PIXELS:
|
|
raise ValueError("Avatar dimensions exceed limit")
|
|
return image.convert("RGBA").resize(
|
|
size, Image.Resampling.LANCZOS,
|
|
)
|
|
except (UnidentifiedImageError, OSError, ValueError,
|
|
Image.DecompressionBombError):
|
|
logger.warning("farm render avatar rejected; using default")
|
|
avatar = Image.new("RGBA", size, "#dce8cf")
|
|
draw = ImageDraw.Draw(avatar)
|
|
w, h = size
|
|
draw.ellipse((w*.3, h*.12, w*.7, h*.52), fill="#6f8a54")
|
|
draw.ellipse((w*.15, h*.55, w*.85, h*1.2), fill="#6f8a54")
|
|
return avatar
|
|
|
|
def _panel(self, canvas, scene, options, avatar):
|
|
layout = self.assets.metadata['panel']
|
|
s = lambda n: round(n * options.width / layout['reference_canvas'][0])
|
|
account = scene.account
|
|
level = account.level
|
|
current = account.experience - 200 * level * (level + 1) // 2
|
|
next_total = 200 * (level + 1)
|
|
for entry in layout['sprites']:
|
|
x, y, w, h = entry['rect']
|
|
image = self.assets.image(
|
|
entry['sprite'], (max(1, s(w)), max(1, s(h))),
|
|
)
|
|
if entry['name'] == 'PersionInfoBG':
|
|
start, end = (s(n) for n in PANEL_UNUSED_ROW)
|
|
compact = Image.new('RGBA', (
|
|
image.width, image.height - (end - start),
|
|
))
|
|
compact.paste(image.crop((0, 0, image.width, start)))
|
|
compact.paste(image.crop((
|
|
0, end, image.width, image.height,
|
|
)), (0, start))
|
|
image = compact
|
|
if entry['name'] == 'ExpProgress':
|
|
filled = min(image.width, max(
|
|
0, current * image.width // next_total,
|
|
))
|
|
if not filled:
|
|
continue
|
|
image = image.crop((0, 0, filled, image.height))
|
|
canvas.paste(image, (s(x), s(y)))
|
|
if entry['name'] == 'PersionInfoBG':
|
|
ax, ay, aw, ah = layout['avatar']
|
|
canvas.paste(self._avatar(
|
|
avatar, (max(1, s(aw)), max(1, s(ah))),
|
|
), (s(ax), s(ay)))
|
|
values = {
|
|
'name': account.name, 'experience': f'{current} / {next_total}',
|
|
'level': str(level), 'point': str(account.point),
|
|
'vip_point': str(account.vip_point),
|
|
}
|
|
for entry in layout['fields']:
|
|
x, y, w, h = entry['rect']
|
|
value = values[entry['field']]
|
|
width, height = max(1, s(w)), max(1, s(h))
|
|
size = height
|
|
while size > 1:
|
|
box = font(self.font_path, size).getbbox(value)
|
|
if (box[2]-box[0] <= width
|
|
and box[3]-box[1] <= height):
|
|
break
|
|
size -= 1
|
|
text = canvas.fit_text(
|
|
value, self.font_path, size, width,
|
|
)
|
|
box = font(self.font_path, size).getbbox(text)
|
|
left = s(x) - box[0]
|
|
if entry['alignment'] == 4:
|
|
left += (width - (box[2]-box[0])) // 2
|
|
top = s(y) + (height - (box[3]-box[1])) // 2 - box[1]
|
|
canvas.text(
|
|
(left, top), text, self.font_path, size, tuple(entry['color']),
|
|
)
|