mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-11-01 07:19:59 +08:00
add # 新增项目整体大纲
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
"""纯快照农场合成:目标尺寸、独立素材、可见状态成图缓存。"""
|
||||
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import logging
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||
MAX_AVATAR_PIXELS = 4_000_000
|
||||
|
||||
|
||||
class FarmRenderer:
|
||||
def __init__(
|
||||
self, font_path, asset_cache_bytes=32 * 1024 * 1024,
|
||||
result_cache_bytes=8 * 1024 * 1024,
|
||||
):
|
||||
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)
|
||||
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._panel(canvas, scene, options, avatar)
|
||||
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 _base(self, scene, options):
|
||||
slots = frozenset(p.slot for p in scene.plots)
|
||||
key = ("base", options.theme, options.width, slots)
|
||||
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)
|
||||
soil_size = tuple(max(1, s(n)) for n in (
|
||||
self.assets.metadata["soil_size"]
|
||||
))
|
||||
tiles = {
|
||||
True: self.assets.image("soil/普通土地.png", soil_size),
|
||||
False: 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[slot in slots], 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 _plants(self, canvas, scene, options):
|
||||
s = lambda n: self._scale(n, options)
|
||||
soil_w, soil_h = self.assets.metadata["soil_size"]
|
||||
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", (max(1, s(60)),) * 2, "#597b45")
|
||||
ImageDraw.Draw(plant).text(
|
||||
(s(10), 0), "?", fill="white",
|
||||
font=font(self.font_path, max(10, s(48))),
|
||||
)
|
||||
else:
|
||||
size = plot.size or metadata["size"]
|
||||
plant = self.assets.image(
|
||||
plot.sprite, tuple(max(1, s(n)) for n in size),
|
||||
)
|
||||
offset_x, offset_y = plot.offset
|
||||
xy = (
|
||||
s(pos["x"] + soil_w / 2 + offset_x) - plant.width // 2,
|
||||
s(pos["y"] + soil_h / 2 + offset_y) - plant.height // 2,
|
||||
)
|
||||
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 _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")
|
||||
image.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
avatar = Image.new("RGBA", size, "#cce3c1")
|
||||
avatar.alpha_composite(image.convert("RGBA"), (
|
||||
(size[0] - image.width) // 2,
|
||||
(size[1] - image.height) // 2,
|
||||
))
|
||||
return avatar
|
||||
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):
|
||||
s = lambda n: self._scale(n, options)
|
||||
canvas.rectangle(
|
||||
tuple(s(n) for n in (90, 75, 1160, 425)),
|
||||
"#fff9e8", s(24),
|
||||
)
|
||||
canvas.paste(self._avatar(avatar, (s(170), s(170))), (s(125), s(125)))
|
||||
size = max(12, s(55))
|
||||
name = canvas.fit_text(
|
||||
scene.account.name, self.font_path, size, s(790),
|
||||
)
|
||||
canvas.text((s(330), s(105)), name, self.font_path, size, "#493525")
|
||||
account = scene.account
|
||||
lines = (
|
||||
f"等级 {account.level} 经验 {account.experience}",
|
||||
f"农场币 {account.point} 点券 {account.vip_point}",
|
||||
f"已解锁 {len(scene.plots)}/30 块地",
|
||||
)
|
||||
for index, line in enumerate(lines):
|
||||
size = max(10, s(43))
|
||||
text = canvas.fit_text(line, self.font_path, size, s(790))
|
||||
canvas.text(
|
||||
(s(330), s(195 + index * 65)), text,
|
||||
self.font_path, size, "#674d34",
|
||||
)
|
||||
Reference in New Issue
Block a user