Files
main_plugin_farm/src/farm_render/renderer.py
T
2026-10-26 11:34:00 +08:00

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']),
)