diff --git a/docs/18-web-panel-and-data-directory.md b/docs/18-web-panel-and-data-directory.md new file mode 100644 index 0000000..2edf0ed --- /dev/null +++ b/docs/18-web-panel-and-data-directory.md @@ -0,0 +1,43 @@ +# 网页头像栏与数据目录 + +## 头像栏 + +正式渲染器读取 `metadata.json` 的 `panel` 配置。头像、头像框、昵称、 +经验条、等级、农场币和点券使用原始 `WebGLPersionInfo` prefab 的矩形、 +锚点与文字对齐方式,不再使用旧版 `userInfo.png` 的位置。 + +来源为已下载公开资源包 `mainscene_053483f584f07436ac0569e2f4f7ed01`。 +提取脚本只读取资源,不执行游戏脚本。原始包哈希及六张新增素材的哈希 +记录在 `src/farm_render/source_assets/manifest.json`。完整头像框先于文字、 +后于头像合成;Gold 的裁切透明边距按资源中的 textureRectOffset 恢复。 + +坐标以左上角为起点。Unity RectTransform 的锚点、pivot 和父矩形计算出 +各元素矩形。渲染器采用宽 1920 的参考画布,输出坐标和尺寸乘以 +`输出宽度 / 1920`;1280 像素网页截图按 2/3 对照。1920 是本渲染器的 +对照基准,没有把它报告为已经提取到的网页 CanvasScaler 参数。 + +原始头像矩形为 `[32.2744, 39.7618, 111.3870, 107.7072]`, +头像框为 `[3.0930, 8.8650, 169.7500, 169.5000]`。昵称在头像右侧, +经验条与等级在昵称下方,农场币和点券在第三行。文字按矩形尺寸适配, +经验条裁切填充图片,不通过拉伸改变纹理。 + +保留本项目的账户与经验规则。网页的 VIP、晶钻、星钻及购买按钮没有 +对应业务,不添加假余额或操作入口。渲染时暂时收起底板上原图纵坐标 +168 到 208 的空栏区域,将原始下边框上移拼接。头像和现有文字的位置、 +字号保持不变,面板可见高度约缩短 19%;原始素材完整保留,便于以后恢复。 + +## 数据目录 + +默认路径改为宿主 `DATA_PATH / farm_db`。为保持数据连续,数据库文件名 +`farm-preview.sqlite3`、日志文件名和 deployment 标识不变。 + +更新插件后下次启动时,若默认旧目录 `farm_preview` 中存在本项目 SQLite, +且 `farm_db` 不存在,会把整个旧目录重命名为 `farm_db`,同时保留日志和 +SQLite 附属文件。迁移在打开数据库前进行,成功后记录迁移日志。 + +若两个目录同时存在,启动失败并提示明确选择数据目录,禁止覆盖或默默 +创建空账户。文件被占用导致重命名失败时同样终止启动;应停止旧实例再启动。 +原有显式 `farm_preview_data_dir` 配置继续生效;该配置用于自定义路径, +不会被自动移动。若显式配置仍指向 `farm_preview`,需自行更改该路径。 + +本轮只修改开发源并进行隔离测试;没有移动正在使用的宿主数据或重启机器人。 diff --git a/integrations/zhenxun/commands.py b/integrations/zhenxun/commands.py index 221c8be..e0f8b21 100644 --- a/integrations/zhenxun/commands.py +++ b/integrations/zhenxun/commands.py @@ -15,15 +15,20 @@ from zhenxun.utils.message import MessageUtils from zhenxun.utils.platform import PlatformUtils from .runtime import COMMANDS, Runtime +from .data_paths import data_directories logger = logging.getLogger(__name__) driver = get_driver() +data_dir, legacy_data_dir = data_directories( + DATA_PATH, getattr(driver.config, 'farm_preview_data_dir', None), +) runtime = Runtime( - getattr(driver.config, "farm_preview_data_dir", DATA_PATH / "farm_preview"), + data_dir, getattr(driver.config, "farm_preview_font", None), getattr(driver.config, "farm_preview_deployment", "zhenxun-preview"), str(getattr(driver.config, "farm_preview_image_format", "AUTO")).upper(), font_dir=FONT_PATH, + legacy_data_dir=legacy_data_dir, ) driver.on_startup(runtime.start) driver.on_shutdown(runtime.close) diff --git a/integrations/zhenxun/data_paths.py b/integrations/zhenxun/data_paths.py new file mode 100644 index 0000000..8da924c --- /dev/null +++ b/integrations/zhenxun/data_paths.py @@ -0,0 +1,30 @@ +"""农场默认目录与旧预览目录的无覆盖迁移。""" + +from pathlib import Path + + +def data_directories(base, configured=None): + if configured is not None: + return Path(configured), None + return Path(base) / 'farm_db', Path(base) / 'farm_preview' + + +def prepare_directory(target, legacy=None): + target = Path(target).absolute() + if legacy is not None: + legacy = Path(legacy).absolute() + if (target.name != 'farm_db' or legacy.name != 'farm_preview' + or target.parent != legacy.parent): + raise ValueError('Farm migration requires default sibling paths') + if target.is_symlink() or legacy.is_symlink(): + raise ValueError('Farm migration does not follow directory links') + if (legacy / 'farm-preview.sqlite3').is_file(): + if target.exists(): + raise FileExistsError( + 'farm_db already exists beside farm_preview; ' + 'select farm_preview_data_dir explicitly before startup' + ) + legacy.rename(target) + return True + target.mkdir(parents=True, exist_ok=True) + return False diff --git a/integrations/zhenxun/runtime.py b/integrations/zhenxun/runtime.py index 60a47a1..76d0eec 100644 --- a/integrations/zhenxun/runtime.py +++ b/integrations/zhenxun/runtime.py @@ -12,6 +12,7 @@ from ._vendor.farm_core.storage import SCHEMA_VERSION from ._vendor.farm_core.presentation import receipt_text, snapshot_text from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService from ._vendor.farm_render.seasons import season_for +from .data_paths import prepare_directory logger = logging.getLogger(__name__) COMMANDS = { @@ -65,8 +66,9 @@ def operation_id(bot_id, platform, user_id, message_id, action): class Runtime: def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview", - image_format="AUTO", *, font_dir=None): + image_format="AUTO", *, font_dir=None, legacy_data_dir=None): self.data_dir = Path(data_dir).resolve() + self.legacy_data_dir = legacy_data_dir self.font_path = font_path self.font_dir = Path(font_dir or "resources/font") self.deployment = deployment @@ -80,7 +82,7 @@ class Runtime: async def start(self): if self.started: return - self.data_dir.mkdir(parents=True, exist_ok=True) + migrated = prepare_directory(self.data_dir, self.legacy_data_dir) self.log_handler = logging.FileHandler( self.data_dir / "farm-preview.log", encoding="utf-8", ) @@ -89,6 +91,8 @@ class Runtime: )) logging.getLogger(__package__).setLevel(logging.INFO) logging.getLogger(__package__).addHandler(self.log_handler) + if migrated: + logger.info('farm data directory migrated farm_preview -> farm_db') try: await self.store.initialize(catalogue=self.service.catalogue) except Exception: diff --git a/scripts/build_zhenxun.py b/scripts/build_zhenxun.py index 7c60c9b..8df40b5 100644 --- a/scripts/build_zhenxun.py +++ b/scripts/build_zhenxun.py @@ -44,7 +44,8 @@ def build(root, output): files = {} integration = root / "integrations/zhenxun" for name in ( - "__init__.py", "runtime.py", "commands.py", "requirements.txt", + "__init__.py", "runtime.py", "commands.py", "data_paths.py", + "requirements.txt", ): files[name] = (integration / name).read_bytes() files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n' diff --git a/scripts/export_render_assets.py b/scripts/export_render_assets.py index a978686..e7a837f 100644 --- a/scripts/export_render_assets.py +++ b/scripts/export_render_assets.py @@ -88,6 +88,7 @@ def export(root): webgl_source = root / "src/farm_render/source_assets" manifest = json.loads((webgl_source / "manifest.json").read_bytes()) metadata["pest"] = manifest["pest"] + metadata["panel"] = manifest["panel"] entries = {path.relative_to(source).as_posix(): path for path in paths} entries.update({name: webgl_source / name for name in manifest["images"]}) with ZipFile(output, "w", compression=ZIP_STORED) as bundle: diff --git a/scripts/extract_webgl_render_assets.py b/scripts/extract_webgl_render_assets.py index 19ae76a..9dffd5e 100644 --- a/scripts/extract_webgl_render_assets.py +++ b/scripts/extract_webgl_render_assets.py @@ -8,10 +8,78 @@ import re import sys +def panel_layout(environment): + prefab = next( + entry.asset for obj in environment.objects + if obj.type.name == 'AssetBundle' + for path, entry in obj.read().m_Container + if path.endswith('/webglpersioninfo.prefab') + ) + nodes = {} + + def visit(pointer, parent=None): + game_object = pointer.read() + components = [c.component for c in game_object.m_Component] + transform = next(c.read() for c in components + if c.type.name == 'RectTransform') + rect = [0, 0, 100, 100] + if parent is not None: + lo, hi = transform.m_AnchorMin, transform.m_AnchorMax + pivot, pos = transform.m_Pivot, transform.m_AnchoredPosition + w = parent[2] * (hi.x - lo.x) + transform.m_SizeDelta.x + h = parent[3] * (hi.y - lo.y) + transform.m_SizeDelta.y + rect = [ + parent[0] + parent[2] * (lo.x + (hi.x-lo.x)*pivot.x) + + pos.x - w*pivot.x, + parent[1] + parent[3] * (1-lo.y-(hi.y-lo.y)*pivot.y) + - pos.y - h*(1-pivot.y), w, h, + ] + for component in components: + if component.type.name != 'MonoBehaviour': + continue + data = component.read_typetree() + if 'm_Sprite' in data or 'm_Text' in data: + nodes[game_object.m_Name] = { + 'rect': rect, 'data': data, + } + for child in transform.m_Children: + visit(child.read().m_GameObject, rect) + + visit(prefab) + sprites = [] + sprite_ids = {} + for name in ('PersionInfoBG', 'HeadFrame', 'ExpBar', 'ExpProgress', + 'GoldIcon', 'CouponIcon'): + node = nodes[name] + sprite_id = node['data']['m_Sprite']['m_PathID'] + path = f'webgl/panel/{name}.png' + sprite_ids[sprite_id] = path + sprites.append({'name': name, 'sprite': path, 'rect': node['rect']}) + fields = [] + for node_name, field in (('NameT', 'name'), ('text', 'experience'), + ('LevelT', 'level'), ('GoldT', 'point'), + ('CouponT', 'vip_point')): + node = nodes[node_name] + color = node['data']['m_Color'] + fields.append({ + 'field': field, 'rect': node['rect'], + 'alignment': node['data']['m_FontData']['m_Alignment'], + 'color': [round(color[c]*255) for c in ('r', 'g', 'b')], + }) + return { + 'reference_canvas': [1920, 1080], + 'source_prefab': 'mainscene#WebGLPersionInfo', + 'anchor': 'top_left', 'sprites': sprites, + 'avatar': nodes['HeadIcon']['rect'], 'fields': fields, + }, sprite_ids + + def extract(bundle, destination): import UnityPy + from PIL import Image environment = UnityPy.load(str(bundle)) + panel, panel_ids = panel_layout(environment) destination.mkdir(parents=True, exist_ok=True) images = {} for obj in environment.objects: @@ -19,7 +87,9 @@ def extract(bundle, destination): continue sprite = obj.read() name = sprite.m_Name - if re.fullmatch(r"pest\d+", name): + if obj.path_id in panel_ids: + path = panel_ids[obj.path_id] + elif re.fullmatch(r"pest\d+", name): path = f"pest/{name}.png" elif name in ("Icon_PlantSeed", "Icon_PlantWither"): path = "webgl/" + ( @@ -28,9 +98,23 @@ def extract(bundle, destination): else: continue image = sprite.image + full_size = (round(sprite.m_Rect.width), round(sprite.m_Rect.height)) + if obj.path_id in panel_ids and image.size != full_size: + offset = sprite.m_RD.textureRectOffset + position = (round(offset.x), + full_size[1] - round(offset.y) - image.height) + if (min(position) < 0 or position[0]+image.width > full_size[0] + or position[1]+image.height > full_size[1]): + raise ValueError('Panel sprite exceeds its original canvas') + full = Image.new('RGBA', full_size) + full.paste(image, position) + image = full if image.size != (round(sprite.m_Rect.width), round(sprite.m_Rect.height)): - raise ValueError("Sprite extraction lost its full canvas") + raise ValueError( + f'Sprite extraction lost its full canvas: {name} ' + f'{image.size} vs {sprite.m_Rect}' + ) target = destination / path target.parent.mkdir(parents=True, exist_ok=True) image.save(target) @@ -38,8 +122,8 @@ def extract(bundle, destination): "size": list(image.size), "sha256": hashlib.sha256(target.read_bytes()).hexdigest(), } - if len(images) != 40: - raise ValueError("Expected 38 pest frames and two common crop sprites") + if len(images) != 46: + raise ValueError("Expected 38 pests, two crop and six panel sprites") manifest = { "schema": 1, "source_url": ( @@ -49,6 +133,7 @@ def extract(bundle, destination): "source_sha256": hashlib.sha256(bundle.read_bytes()).hexdigest(), "extractor": "UnityPy 1.25.4 Sprite.image; full sprite rect checked", "images": images, + "panel": panel, "pest": { "frames": [f"pest/pest{i}.png" for i in range(1, 76, 2)], "sample_rate": 24, diff --git a/src/farm_render/data/assets.zip b/src/farm_render/data/assets.zip index 9f2e977..55467b0 100644 Binary files a/src/farm_render/data/assets.zip and b/src/farm_render/data/assets.zip differ diff --git a/src/farm_render/data/metadata.json b/src/farm_render/data/metadata.json index 3bb0762..3a8458e 100644 --- a/src/farm_render/data/metadata.json +++ b/src/farm_render/data/metadata.json @@ -6195,6 +6195,48 @@ 120 ] }, + "webgl/panel/CouponIcon.png": { + "sha256": "8f564bf9da5c0fbeb65a3e320cfb47d66648405f09e2309a42825c1f5404caf1", + "size": [ + 64, + 64 + ] + }, + "webgl/panel/ExpBar.png": { + "sha256": "efb13c2dcb1ecd49689ed561d3f816312738f84887e01496385914e86dcf87d5", + "size": [ + 223, + 30 + ] + }, + "webgl/panel/ExpProgress.png": { + "sha256": "fcb1bdc9a568758460e005a1be58c04d8346eaedfcbc3fef59b29ad0dedcfdc0", + "size": [ + 225, + 23 + ] + }, + "webgl/panel/GoldIcon.png": { + "sha256": "5389afb3a3b1245e9fdc8d3a8839c8ff07a653ebf160bb8e43882a461e73ce49", + "size": [ + 64, + 64 + ] + }, + "webgl/panel/HeadFrame.png": { + "sha256": "74e8f716f58f9055d873657eb8fbaea2170ea19365fe83299b8e32f8484b0b62", + "size": [ + 230, + 230 + ] + }, + "webgl/panel/PersionInfoBG.png": { + "sha256": "62971e70743418988982667625f3e6e4e61073e74610c5279919be2e71c1bd10", + "size": [ + 492, + 236 + ] + }, "webgl/seed.png": { "sha256": "353ff338fd5ab3f7812e9d2d7f98df11d0de4e04af6de7f9d5aa0249d40b3609", "size": [ @@ -6210,6 +6252,159 @@ ] } }, + "panel": { + "anchor": "top_left", + "avatar": [ + 32.2744255065918, + 39.76182556152344, + 111.38700103759766, + 107.70719909667969 + ], + "fields": [ + { + "alignment": 3, + "color": [ + 74, + 31, + 0 + ], + "field": "name", + "rect": [ + 161.24510192871094, + 40.90002250671387, + 298.0353088378906, + 31.200000762939453 + ] + }, + { + "alignment": 4, + "color": [ + 91, + 110, + 3 + ], + "field": "experience", + "rect": [ + 177.60003662109375, + 89.78792595863342, + 223.0, + 22.024199962615967 + ] + }, + { + "alignment": 4, + "color": [ + 216, + 112, + 0 + ], + "field": "level", + "rect": [ + 400.9734191894531, + 85.36273193359375, + 56.0, + 30.0 + ] + }, + { + "alignment": 3, + "color": [ + 255, + 255, + 255 + ], + "field": "point", + "rect": [ + 178.46239471435547, + 128.23919677734375, + 123.61039733886719, + 30.0 + ] + }, + { + "alignment": 3, + "color": [ + 255, + 255, + 255 + ], + "field": "vip_point", + "rect": [ + 342.0, + 128.23919677734375, + 108.0, + 30.0 + ] + } + ], + "reference_canvas": [ + 1920, + 1080 + ], + "source_prefab": "mainscene#WebGLPersionInfo", + "sprites": [ + { + "name": "PersionInfoBG", + "rect": [ + 0.0, + 0.0, + 492.0, + 236.0 + ], + "sprite": "webgl/panel/PersionInfoBG.png" + }, + { + "name": "HeadFrame", + "rect": [ + 3.092998504638672, + 8.865001678466797, + 169.75, + 169.5 + ], + "sprite": "webgl/panel/HeadFrame.png" + }, + { + "name": "ExpBar", + "rect": [ + 177.60000610351562, + 85.89997100830078, + 223.0, + 30.0 + ], + "sprite": "webgl/panel/ExpBar.png" + }, + { + "name": "ExpProgress", + "rect": [ + 179.90000915527344, + 89.89997100830078, + 215.0, + 22.0 + ], + "sprite": "webgl/panel/ExpProgress.png" + }, + { + "name": "GoldIcon", + "rect": [ + 146.5999984741211, + 128.8000030517578, + 30.0, + 30.0 + ], + "sprite": "webgl/panel/GoldIcon.png" + }, + { + "name": "CouponIcon", + "rect": [ + 310.5, + 128.8000030517578, + 30.0, + 30.0 + ], + "sprite": "webgl/panel/CouponIcon.png" + } + ] + }, "pest": { "display_size": [ 22, diff --git a/src/farm_render/renderer.py b/src/farm_render/renderer.py index eee3b14..1930b8e 100644 --- a/src/farm_render/renderer.py +++ b/src/farm_render/renderer.py @@ -17,6 +17,7 @@ from .models import RenderOptions, RenderResult logger = logging.getLogger(__name__) MAX_AVATAR_BYTES = 2 * 1024 * 1024 MAX_AVATAR_PIXELS = 4_000_000 +PANEL_UNUSED_ROW = (168, 208) class FarmRenderer: @@ -218,45 +219,64 @@ class FarmRenderer: return avatar def _panel(self, canvas, scene, options, avatar): - s = lambda n: self._scale(n, options) - - def paste_asset(name, position): - original = self.assets.metadata["images"][name]["size"] - image = self.assets.image( - name, tuple(max(1, s(n)) for n in original), - ) - canvas.paste(image, tuple(s(n) for n in position)) - - paste_asset("background/userInfo.png", (100, 30)) - canvas.paste( - self._avatar(avatar, (max(1, s(140)), max(1, s(150)))), - (s(125), s(85)), - ) - paste_asset("background/frame/frame.png", (75, 44)) + 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) * (level + 2) // 2 - progress = min(247, max(0, current * 247 // next_total)) - if progress: - canvas.rectangle( - tuple(s(n) for n in (292, 164, 292 + progress, 189)), - (171, 194, 41), + 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))), ) - lines = ( - (account.name, (300, 92), 24, (77, 35, 4), 330), - (f"{current} / {next_total}", (365, 164), 20, - (102, 120, 19), 165), - (str(level), (570, 158), 32, (214, 111, 1), 60), - (str(account.point), (330, 211), 24, (253, 253, 253), 100), - (str(account.vip_point), (570, 211), 24, (253, 253, 253), 40), - ) - for value, position, original_size, color, width in lines: - size = max(1, s(original_size)) + 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, max(1, s(width)), + 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( - tuple(s(n) for n in position), text, - self.font_path, size, color, + (left, top), text, self.font_path, size, tuple(entry['color']), ) diff --git a/src/farm_render/source_assets/manifest.json b/src/farm_render/source_assets/manifest.json index c3a1740..acf7517 100644 --- a/src/farm_render/source_assets/manifest.json +++ b/src/farm_render/source_assets/manifest.json @@ -25,6 +25,20 @@ ], "sha256": "226c96b60648787fd457d2f968c0eee7c610444f191e6c73fdd071fa6e95bf14" }, + "webgl/panel/GoldIcon.png": { + "size": [ + 64, + 64 + ], + "sha256": "5389afb3a3b1245e9fdc8d3a8839c8ff07a653ebf160bb8e43882a461e73ce49" + }, + "webgl/panel/HeadFrame.png": { + "size": [ + 230, + 230 + ], + "sha256": "74e8f716f58f9055d873657eb8fbaea2170ea19365fe83299b8e32f8484b0b62" + }, "pest/pest37.png": { "size": [ 44, @@ -116,6 +130,13 @@ ], "sha256": "1ad3f1bd4521ef462056b88070728853fc93de85457eac1903d8dd11dc175642" }, + "webgl/panel/PersionInfoBG.png": { + "size": [ + 492, + 236 + ], + "sha256": "62971e70743418988982667625f3e6e4e61073e74610c5279919be2e71c1bd10" + }, "pest/pest69.png": { "size": [ 44, @@ -172,6 +193,13 @@ ], "sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7" }, + "webgl/panel/ExpProgress.png": { + "size": [ + 225, + 23 + ], + "sha256": "fcb1bdc9a568758460e005a1be58c04d8346eaedfcbc3fef59b29ad0dedcfdc0" + }, "pest/pest65.png": { "size": [ 44, @@ -249,6 +277,13 @@ ], "sha256": "353ff338fd5ab3f7812e9d2d7f98df11d0de4e04af6de7f9d5aa0249d40b3609" }, + "webgl/panel/CouponIcon.png": { + "size": [ + 64, + 64 + ], + "sha256": "8f564bf9da5c0fbeb65a3e320cfb47d66648405f09e2309a42825c1f5404caf1" + }, "pest/pest27.png": { "size": [ 44, @@ -263,6 +298,13 @@ ], "sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82" }, + "webgl/panel/ExpBar.png": { + "size": [ + 223, + 30 + ], + "sha256": "efb13c2dcb1ecd49689ed561d3f816312738f84887e01496385914e86dcf87d5" + }, "pest/pest61.png": { "size": [ 44, @@ -285,6 +327,159 @@ "sha256": "5bd1ed7e9db82572119403fce50ff9c7276df33ad4288869aeac3fa5896652e6" } }, + "panel": { + "reference_canvas": [ + 1920, + 1080 + ], + "source_prefab": "mainscene#WebGLPersionInfo", + "anchor": "top_left", + "sprites": [ + { + "name": "PersionInfoBG", + "sprite": "webgl/panel/PersionInfoBG.png", + "rect": [ + 0.0, + 0.0, + 492.0, + 236.0 + ] + }, + { + "name": "HeadFrame", + "sprite": "webgl/panel/HeadFrame.png", + "rect": [ + 3.092998504638672, + 8.865001678466797, + 169.75, + 169.5 + ] + }, + { + "name": "ExpBar", + "sprite": "webgl/panel/ExpBar.png", + "rect": [ + 177.60000610351562, + 85.89997100830078, + 223.0, + 30.0 + ] + }, + { + "name": "ExpProgress", + "sprite": "webgl/panel/ExpProgress.png", + "rect": [ + 179.90000915527344, + 89.89997100830078, + 215.0, + 22.0 + ] + }, + { + "name": "GoldIcon", + "sprite": "webgl/panel/GoldIcon.png", + "rect": [ + 146.5999984741211, + 128.8000030517578, + 30.0, + 30.0 + ] + }, + { + "name": "CouponIcon", + "sprite": "webgl/panel/CouponIcon.png", + "rect": [ + 310.5, + 128.8000030517578, + 30.0, + 30.0 + ] + } + ], + "avatar": [ + 32.2744255065918, + 39.76182556152344, + 111.38700103759766, + 107.70719909667969 + ], + "fields": [ + { + "field": "name", + "rect": [ + 161.24510192871094, + 40.90002250671387, + 298.0353088378906, + 31.200000762939453 + ], + "alignment": 3, + "color": [ + 74, + 31, + 0 + ] + }, + { + "field": "experience", + "rect": [ + 177.60003662109375, + 89.78792595863342, + 223.0, + 22.024199962615967 + ], + "alignment": 4, + "color": [ + 91, + 110, + 3 + ] + }, + { + "field": "level", + "rect": [ + 400.9734191894531, + 85.36273193359375, + 56.0, + 30.0 + ], + "alignment": 4, + "color": [ + 216, + 112, + 0 + ] + }, + { + "field": "point", + "rect": [ + 178.46239471435547, + 128.23919677734375, + 123.61039733886719, + 30.0 + ], + "alignment": 3, + "color": [ + 255, + 255, + 255 + ] + }, + { + "field": "vip_point", + "rect": [ + 342.0, + 128.23919677734375, + 108.0, + 30.0 + ], + "alignment": 3, + "color": [ + 255, + 255, + 255 + ] + } + ] + }, "pest": { "frames": [ "pest/pest1.png", diff --git a/src/farm_render/source_assets/webgl/panel/CouponIcon.png b/src/farm_render/source_assets/webgl/panel/CouponIcon.png new file mode 100644 index 0000000..7874d98 Binary files /dev/null and b/src/farm_render/source_assets/webgl/panel/CouponIcon.png differ diff --git a/src/farm_render/source_assets/webgl/panel/ExpBar.png b/src/farm_render/source_assets/webgl/panel/ExpBar.png new file mode 100644 index 0000000..969d9ca Binary files /dev/null and b/src/farm_render/source_assets/webgl/panel/ExpBar.png differ diff --git a/src/farm_render/source_assets/webgl/panel/ExpProgress.png b/src/farm_render/source_assets/webgl/panel/ExpProgress.png new file mode 100644 index 0000000..1b7a0e5 Binary files /dev/null and b/src/farm_render/source_assets/webgl/panel/ExpProgress.png differ diff --git a/src/farm_render/source_assets/webgl/panel/GoldIcon.png b/src/farm_render/source_assets/webgl/panel/GoldIcon.png new file mode 100644 index 0000000..532a386 Binary files /dev/null and b/src/farm_render/source_assets/webgl/panel/GoldIcon.png differ diff --git a/src/farm_render/source_assets/webgl/panel/HeadFrame.png b/src/farm_render/source_assets/webgl/panel/HeadFrame.png new file mode 100644 index 0000000..444bc1f Binary files /dev/null and b/src/farm_render/source_assets/webgl/panel/HeadFrame.png differ diff --git a/src/farm_render/source_assets/webgl/panel/PersionInfoBG.png b/src/farm_render/source_assets/webgl/panel/PersionInfoBG.png new file mode 100644 index 0000000..c26b7a6 Binary files /dev/null and b/src/farm_render/source_assets/webgl/panel/PersionInfoBG.png differ diff --git a/tests/test_data_directory.py b/tests/test_data_directory.py new file mode 100644 index 0000000..e1b0a5e --- /dev/null +++ b/tests/test_data_directory.py @@ -0,0 +1,58 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class DataDirectoryTests(unittest.TestCase): + def setUp(self): + spec = importlib.util.spec_from_file_location( + 'farm_data_paths', ROOT / 'integrations/zhenxun/data_paths.py', + ) + self.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.module) + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + + def test_default_and_explicit_directory(self): + self.assertEqual(self.module.data_directories(self.base), ( + self.base / 'farm_db', self.base / 'farm_preview', + )) + custom = self.base / 'custom' + self.assertEqual(self.module.data_directories(self.base, custom), ( + custom, None, + )) + + def test_migrate_preserves_database_log_and_sidecars(self): + old = self.base / 'farm_preview' + old.mkdir() + for name in ('farm-preview.sqlite3', 'farm-preview.sqlite3-wal', + 'farm-preview.log'): + (old / name).write_bytes(name.encode()) + target = self.base / 'farm_db' + self.assertTrue(self.module.prepare_directory(target, old)) + self.assertFalse(old.exists()) + for path in target.iterdir(): + self.assertEqual(path.read_bytes(), path.name.encode()) + self.assertFalse(self.module.prepare_directory(target, old)) + + def test_conflicting_directory_stops_without_overwrite(self): + old, target = self.base / 'farm_preview', self.base / 'farm_db' + old.mkdir() + target.mkdir() + (old / 'farm-preview.sqlite3').write_bytes(b'old') + (target / 'farm-preview.sqlite3').write_bytes(b'new') + with self.assertRaises(FileExistsError): + self.module.prepare_directory(target, old) + self.assertEqual((old / 'farm-preview.sqlite3').read_bytes(), b'old') + self.assertEqual((target / 'farm-preview.sqlite3').read_bytes(), b'new') + + def test_migration_rejects_paths_outside_default_siblings(self): + with self.assertRaises(ValueError): + self.module.prepare_directory( + self.base / 'farm_db', self.base / 'other/farm_preview', + ) diff --git a/tests/test_render.py b/tests/test_render.py index 79bdf43..a6f6d7e 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -115,32 +115,26 @@ class RenderTests(unittest.TestCase): changed = replace(data, account=replace(data.account, experience=200)) self.assertFalse(self.renderer.render(self.who, changed, 0).cache_hit) - def test_legacy_panel_assets_avatar_size_and_layer_order(self): + def test_web_panel_avatar_uses_prefab_rect_and_frame_overlays_it(self): avatar = BytesIO() Image.new("RGB", (80, 40), "red").save(avatar, "PNG") scene = self.renderer.scene(self.who, snapshot(), 0) for width in (1024, 1536, 3072): options = RenderOptions(width=width) - s = lambda n: round(n * width / 3072) - actual = Canvas.new((width, s(1872))) - expected = Canvas.new((width, s(1872))) - name = "background/userInfo.png" - size = self.renderer.assets.metadata["images"][name]["size"] - expected.paste(self.renderer.assets.image( - name, tuple(s(n) for n in size), - ), (s(100), s(30))) - expected.paste(Image.new("RGBA", (s(140), s(150)), "red"), - (s(125), s(85))) - name = "background/frame/frame.png" - size = self.renderer.assets.metadata["images"][name]["size"] - expected.paste(self.renderer.assets.image( - name, tuple(s(n) for n in size), - ), (s(75), s(44))) - with patch.object(actual, "text"), patch.object(actual, "rectangle"): + s = lambda n: round(n * width / 1920) + actual = Canvas.new((width, round(width * 1872 / 3072))) + with patch.object(actual, "paste", wraps=actual.paste) as paste: self.renderer._panel(actual, scene, options, avatar.getvalue()) - self.assertEqual(actual.image.tobytes(), expected.image.tobytes()) + calls = paste.call_args_list + self.assertEqual(calls[1].args[0].size, + (s(111.387001), s(107.707199))) + self.assertEqual(calls[1].args[1], (s(32.274426), s(39.761826))) + self.assertEqual(calls[1].args[0].getpixel((0, 0)), + (255, 0, 0, 255)) + self.assertEqual(calls[2].args[1], (s(3.093), s(8.865))) + self.assertEqual(calls[2].args[0].size, (s(169.75), s(169.5))) - def test_legacy_panel_fields_progress_and_level_boundaries(self): + def test_web_panel_fields_fit_their_prefab_rectangles(self): options = RenderOptions(width=3072) for exp, level, current, threshold in ( (0, 0, 0, 200), (199, 0, 199, 200), (200, 1, 0, 600), @@ -154,21 +148,46 @@ class RenderTests(unittest.TestCase): canvas, "rectangle", ) as rectangle: self.renderer._panel(canvas, scene, options, None) - values = [(call.args[0], call.args[1]) - for call in text.call_args_list] - self.assertEqual(values, [ - ((300, 92), "农场"), - ((365, 164), f"{current} / {threshold}"), - ((570, 158), str(level)), - ((330, 211), "12580"), ((570, 211), "36"), + self.assertEqual([c.args[1] for c in text.call_args_list], [ + '农场', f'{current} / {threshold}', str(level), '12580', '36', ]) - progress = current * 247 // threshold - if progress: - rectangle.assert_called_once_with( - (292, 164, 292 + progress, 189), (171, 194, 41), - ) + from farm_render.canvas import font + rectangles = ((161.2451, 40.9, 298.0353, 31.2), + (177.6, 89.7879, 223, 22.0242), + (400.9734, 85.3627, 56, 30), + (178.4624, 128.2392, 123.6104, 30), + (342, 128.2392, 108, 30)) + for call, rect in zip(text.call_args_list, rectangles): + position, value, path, size, _ = call.args + box = font(path, size).getbbox(value) + left, top = position[0] + box[0], position[1] + box[1] + self.assertGreaterEqual(left, round(rect[0] * 1.6) - 1) + self.assertGreaterEqual(top, round(rect[1] * 1.6) - 1) + self.assertLessEqual(left + box[2] - box[0], + round((rect[0] + rect[2]) * 1.6) + 1) + self.assertLessEqual(top + box[3] - box[1], + round((rect[1] + rect[3]) * 1.6) + 1) + + def test_web_experience_fill_clips_texture_without_resizing(self): + options = RenderOptions(width=1536) + original = self.renderer.assets.image( + 'webgl/panel/ExpProgress.png', (172, 18), + ) + for exp in (0, 100, 199): + scene = self.renderer.scene( + self.who, snapshot(experience=exp), 0, + ) + canvas = Canvas.new((1536, 936)) + with patch.object(canvas, 'paste') as paste: + self.renderer._panel(canvas, scene, options, None) + calls = paste.call_args_list + if exp == 0: + self.assertEqual(len(calls), 6) else: - rectangle.assert_not_called() + self.assertEqual(len(calls), 7) + filled = exp * 172 // 200 + self.assertEqual(calls[4].args[0].tobytes(), + original.crop((0, 0, filled, 18)).tobytes()) def test_lru_budget_and_shared_image_safety(self): cache = ByteLRU(10) diff --git a/tests/test_zhenxun_runtime.py b/tests/test_zhenxun_runtime.py index bb5da73..2a7fb95 100644 --- a/tests/test_zhenxun_runtime.py +++ b/tests/test_zhenxun_runtime.py @@ -189,10 +189,33 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(before, await self.call("inventory")) + async def test_default_directory_migration_keeps_account_and_inventory(self): + old = self.root / 'migration/farm_preview' + target = old.with_name('farm_db') + previous = self.module.Runtime(old, choose_font()) + await previous.start() + try: + await previous.execute('register', '迁移农场', **self.context) + before = await previous.execute('inventory', '', **self.context) + finally: + await previous.close() + current = self.module.Runtime( + target, choose_font(), legacy_data_dir=old, + ) + await current.start() + try: + self.assertFalse(old.exists()) + self.assertTrue((target / 'farm-preview.sqlite3').is_file()) + self.assertEqual(await current.execute( + 'inventory', '', **self.context, + ), before) + finally: + await current.close() + async def test_mock_host_load_registers_hooks_and_original_matchers(self): hooks = {"start":[], "close":[], "commands":{}} driver = SimpleNamespace( - config=SimpleNamespace(farm_preview_data_dir=self.root / "host"), + config=SimpleNamespace(), on_startup=hooks["start"].append, on_shutdown=hooks["close"].append, ) @@ -233,6 +256,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): )} with patch.dict(sys.modules, modules): imported = importlib.import_module(self.package + ".commands") + self.assertEqual(imported.runtime.data_dir, self.root / 'farm_db') self.assertEqual(set(hooks["commands"]), set(self.module.COMMANDS)) self.assertEqual(len(hooks["start"]), 1) await hooks["start"][0]()