From 4c3f0f608272c93d6765d0670c2dc6bfacb9e9e6 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Mon, 2 Nov 2026 01:58:23 +0800 Subject: [PATCH] =?UTF-8?q?iter48-=E5=8D=A12=EF=BC=9A=E5=8D=A1=E2=91=A1(?= =?UTF-8?q?=E7=AB=8B=E7=BB=98=E6=98=BE=E7=A4=BA=E6=A1=A3=E6=89=B9):=20?= =?UTF-8?q?=E4=B9=9D=E4=BB=B6=E7=B4=A0=E6=9D=90=E5=8E=9F=E7=A8=BF=E5=88=86?= =?UTF-8?q?=E8=BE=A8=E7=8E=87=E5=85=A5=E5=8C=85(=E7=B4=A7=E8=A3=81?= =?UTF-8?q?=E9=9B=B6=E9=99=8D=E9=87=87=E6=A0=B7,STANDEE=5FSRC=5FH=E4=B9=9D?= =?UTF-8?q?=E9=94=AE+=E4=BA=A7=E7=89=A9=E8=AE=A1=E6=95=B0=E8=87=AA?= =?UTF-8?q?=E8=AF=81)+standee=5Fmotion=E6=98=BE=E7=A4=BA=E6=A1=A3PORTRAIT?= =?UTF-8?q?=5FDISPLAY=5FH=E4=B9=9D=E9=94=AE=E9=92=89=E5=80=BC(=5Fplace=5Fp?= =?UTF-8?q?ortrait=E6=8C=89=E6=A1=A3=E7=AE=97=E5=80=8D=E7=8E=87,=E7=BC=BA?= =?UTF-8?q?=E9=94=AEpush=5Ferror=E6=8C=891=C3=97)+portrait=5Fprobe?= =?UTF-8?q?=E6=89=A9display=5Fsize/display=5Fscale+=E9=97=B8=E4=BB=B6stand?= =?UTF-8?q?ee=5Fasset=5Fcheck=E6=8D=A2=E5=8F=A3=E5=BE=84(=E6=BA=90?= =?UTF-8?q?=E9=AB=98=3D=3D=E5=8E=9F=E7=A8=BF=E8=A1=A8=E5=80=BC/=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=AE=BD=E2=89=A4150=E9=85=8B=E9=95=BF157=E4=BE=8B?= =?UTF-8?q?=E5=A4=96=E9=80=90=E5=AD=97)+=E6=A0=B7=E5=BC=A0=E7=AB=99?= =?UTF-8?q?=E6=A1=A9=E6=AE=B5=E6=8C=89=E6=98=BE=E7=A4=BA=E6=A1=A3=E5=8F=96?= =?UTF-8?q?=E6=95=B0(A4=E8=A7=A3=E5=86=BB=E9=87=8D=E6=A0=A188=C3=9792,V10?= =?UTF-8?q?=E5=B9=B6=E6=8E=9230/30)+V13=E2=80=93V18=E5=85=A8=E7=BB=BF(50ch?= =?UTF-8?q?ecks)+=E4=B8=89=E9=9D=A2=E5=AF=B9=E7=85=A7=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/presentation/views/level_map_screen.gd | 3 +- game/presentation/views/standee_body.gd | 6 +- game/presentation/views/standee_motion.gd | 35 +++++-- game/probe/ui_probe.gd | 4 +- .../standee_present_values_test.gd | 93 +++++++++++++++++++ tools/path_check.py | 2 +- tools/pixelize_candidates.py | 92 +++++++++--------- tools/render_sample_sheet_v3.py | 57 ++++++------ tools/standee_asset_check.py | 82 ++++++++-------- 9 files changed, 255 insertions(+), 119 deletions(-) diff --git a/game/presentation/views/level_map_screen.gd b/game/presentation/views/level_map_screen.gd index 6cfe6681..7e7fb434 100644 --- a/game/presentation/views/level_map_screen.gd +++ b/game/presentation/views/level_map_screen.gd @@ -173,7 +173,8 @@ func _build_status_bar(box: VBoxContainer) -> void: strip.add_child(abandon_btn) -## 头像域:定高 48px=1×、宽随源图比自适应(源 46×48 → 46×48;纵横异倍消除) +## 头像域:定高 48px 框、宽随源图比自适应(源 = 原稿分辨率;该面为独立消费面—— +## 显示档批不改框尺寸,画面随素材分辨率自动变化) func _build_face_rect() -> TextureRect: var face := TextureRect.new() var face_tex := UiKit.load_tex(UiKit.TEX_HERO_PORTRAIT) diff --git a/game/presentation/views/standee_body.gd b/game/presentation/views/standee_body.gd index f8c2e6e1..07ac8753 100644 --- a/game/presentation/views/standee_body.gd +++ b/game/presentation/views/standee_body.gd @@ -281,7 +281,11 @@ func _portrait_glyph() -> String: # 立绘落位探针出口(站桩纹理非空断言面;缺位回退链保持的对照面) func portrait_probe() -> Dictionary: - return {"has_tex": mo._portrait_sprite != null and mo._portrait_sprite.texture != null} + var sp: Sprite2D = mo._portrait_sprite + if sp == null or sp.texture == null: + return {"has_tex": false, "display_size": Vector2.ZERO, "display_scale": 0.0} + var src := Vector2(sp.texture.get_width(), sp.texture.get_height()) + return {"has_tex": true, "display_size": src * sp.scale, "display_scale": sp.scale.x} # ---- 主角三态切换(攻击演出→attack/受击→hit/结算回 idle;§8.4 帧数档挂点) ---- diff --git a/game/presentation/views/standee_motion.gd b/game/presentation/views/standee_motion.gd index e162ac4e..84998245 100644 --- a/game/presentation/views/standee_motion.gd +++ b/game/presentation/views/standee_motion.gd @@ -12,6 +12,15 @@ const _HS_VALUE := {"idle": HeroState.IDLE, "reset": HeroState.RESET, "attack": var _owner: Node2D = null var _portrait_sprite: Sprite2D = null # 资产挂点(null = 未落位,字形在位) var _portrait_tex: Texture2D = null # 落位纹理引用(set_portrait_texture 装载;字形回退判据) +# 站桩立绘显示档(三档绝对高——art-bible §8.2 显示档定值单点;九键逐键钉值,禁兜底字面量): +# 小型 96 = 地精/巨蜘蛛/灰矮人;中型 144 = 主角/钩爪恐魔/熊地精;大型 188 = 酋长/卓尔/石君王。 +# 显示倍率 = 档高 / 源高(宽随源比例);源高 = 原稿表值(Python 侧 STANDEE_SRC_H—— +# 跨语言逐键对账由 standee_present_values_test V17 承担)。 +const PORTRAIT_DISPLAY_H := { + "goblin": 96.0, "giant_spider": 96.0, "duergar": 96.0, + "hook_horror": 144.0, "bugbear": 144.0, "hero": 144.0, + "ogre_chief": 188.0, "drider": 188.0, "duergar_king": 188.0, +} # 主角三态切换:state 名 → 纹理(battle_stage_view 组合点注册);缺态=保持现纹理 # (缺位回退链延伸——三态素材缺任一不炸不空)。敌站桩不注册不消费。 var _hero_tex := {} @@ -35,8 +44,9 @@ func _init(owner: Node2D) -> void: # 资产挂点:tex 落位后字形隐退、纹理立绘顶位——零代码换装契约;tex=null 回退字形。 -# flip = 立绘朝向翻转(素材基准朝左时传 true 呈现朝右)。盒高 96px = 48px 立绘源整数 2× -# (圣经 §8.2 禁非整数缩放)——源换代时随源重算。(box = 宿主冻结 var,由宿主传入) +# flip = 立绘朝向翻转(素材基准朝左时传 true 呈现朝右)。显示高 = PORTRAIT_DISPLAY_H +# 逐键绝对高(art-bible §8.2 三档),宽随源比例(高定宽随)。 +# (box = 宿主冻结 var,由宿主传入) func set_portrait_texture(tex: Texture2D, flip: bool, box: Vector2) -> void: if tex == null: if _portrait_sprite != null: @@ -56,14 +66,25 @@ func set_portrait_texture(tex: Texture2D, flip: bool, box: Vector2) -> void: func _place_portrait(box: Vector2) -> void: - # 立绘盒 = (0,44)-(box.x, box.y-36);48 源 ×2 整数档居中 + # 立绘盒 = (0,44)-(box.x, box.y-36);显示档逐键取 PORTRAIT_DISPLAY_H 绝对高, + # 宽随源比例(高定宽随);高于盒者沿既有居中溢出机制((box_h-h)*0.5 为负即上溢) if _portrait_sprite == null or _portrait_tex == null: return + var eid: String = _owner.enemy_id() + var key := "hero" if eid.is_empty() else eid var box_h := box.y - 44.0 - 36.0 - var w := _portrait_tex.get_width() * 2.0 - var h := _portrait_tex.get_height() * 2.0 - _portrait_sprite.scale = Vector2(2.0, 2.0) # 整数 2×(C5) - _portrait_sprite.position = Vector2((box.x - w) * 0.5, 44.0 + (box_h - h) * 0.5) + var disp_h: float = PORTRAIT_DISPLAY_H.get(key, -1.0) + if disp_h < 0.0: + # 缺键不崩、不静默兜档——push_error 报警后按 1× 落位(V18 键集自检在测试期报警) + push_error("[站桩] 显示档表缺键: %s(逐键登记纪律)" % key) + _portrait_sprite.scale = Vector2.ONE + _portrait_sprite.position = Vector2((box.x - _portrait_tex.get_width()) * 0.5, + 44.0 + (box_h - _portrait_tex.get_height()) * 0.5) + return + var s := disp_h / float(_portrait_tex.get_height()) + _portrait_sprite.scale = Vector2(s, s) + _portrait_sprite.position = Vector2((box.x - _portrait_tex.get_width() * s) * 0.5, + 44.0 + (box_h - disp_h) * 0.5) # 组合点注册(battle_stage_view _init——UiKit 常量 load_tex,缺位 null 不注册=该态保持现纹理) diff --git a/game/probe/ui_probe.gd b/game/probe/ui_probe.gd index 65d5b429..640d3a81 100644 --- a/game/probe/ui_probe.gd +++ b/game/probe/ui_probe.gd @@ -147,11 +147,11 @@ func _ready() -> void: problems.append("普攻应零表示(意图指示应隐藏)") # 站桩纹理非空断言(八敌真图落位;缺位回退链保持的对照面) if not bool(standee.portrait_probe().get("has_tex", false)): - problems.append("敌立绘纹理未落位(素材管线 48px 档——迭代 6 T2;缺位回退链不应在此态触发)") + problems.append("敌立绘纹理未落位(原稿分辨率入包——显示档批;缺位回退链不应在此态触发)") if main.ui.battle.stage.hero_standee == null or not main.ui.battle.stage.hero_standee.has_world_hp_bar: problems.append("B 区主角站桩缺失(世界血条面缺失)") elif not bool(main.ui.battle.stage.hero_standee.portrait_probe().get("has_tex", false)): - problems.append("主角立绘纹理未落位(48px 档——迭代 6 T2/T3)") + problems.append("主角立绘纹理未落位(显示档 hero=144——显示档批)") # ---- BUG-0001 回归(tooltip 入树后 set_effects 槽位不错位——结算软锁修复沉淀)---- var slots_row = main.ui.battle.stage.hero_standee.slot_row # BUG-0001 回归随组件迁入站桩(tooltip/槽位错位同源) var probe_eff: Array = [{"effect_id": "attack_up", "display_name": "增伤", "layers": 1, diff --git a/game/tests/unit/presentation/standee_present_values_test.gd b/game/tests/unit/presentation/standee_present_values_test.gd index 80a9a1c2..22260714 100644 --- a/game/tests/unit/presentation/standee_present_values_test.gd +++ b/game/tests/unit/presentation/standee_present_values_test.gd @@ -11,6 +11,9 @@ # V10 名字行提档 FS_HINT 14(探针 name_font_size 实读 + 两绘制点源码锚 + FS_CAPTION 计数锚==1); # V11 退役常量不在册 + 掩尘 BRICK 逐位 + 顶缘线/口沿高光两绘制点改指源码锚; # V12 PLAZA_EDGE 逐位 #4C4038 + 回退描边改指源码锚。 +# V13–V18 立绘显示档(2026-11-01 迭代 48 卡②)——显示档 = 三档绝对高逐键钉值(96/144/188)+ +# 入包件 = 原稿紧裁零降采样(源高 == 原稿表值)+纵横倍率恒等+主角三态显示 144+ +# 跨语言显示档表逐键对账+键集恰九键(§8.2 显示档定值单点)。 # headless 无渲染面:像素采样以常量单点 + 源码锚 + 探针几何承载(观测面注记,验收对照表 V5 行)。 # 运行:godot --headless --path game --script res://tests/unit/presentation/standee_present_values_test.gd(expect 0) extends SceneTree @@ -110,4 +113,94 @@ func _init() -> void: else "护甲三枚入包(24×24)〔样张批 V9〕", tex9 != null and tex9.get_width() == 24 and tex9.get_height() == 24) + # ---- V13–V18 立绘显示档(迭代 48 卡②) ---- + _check_display_tier() + quit(Kit.finish("standee_present_values")) + + +# ---- V13–V18 立绘显示档(迭代 48 卡②——显示档 = 三档绝对高,入包件 = 原稿紧裁零降采样) ---- + +# 规格钉值(显示档定值单点 = art-bible §8.2;源高 = 原稿表值——旧降采样稿必红; +# 显示宽 = round(源宽×档高/源高),食人魔酋长 157 为已接受超宽项): +# 键 → [源高, 显示档高, 显示宽] +const _DISPLAY_SPEC := { + "goblin": [90, 96, 95], "giant_spider": [81, 96, 121], "duergar": [126, 96, 71], + "hook_horror": [94, 144, 113], "bugbear": [123, 144, 119], "hero": [92, 144, 138], + "ogre_chief": [189, 188, 157], "drider": [189, 188, 139], "duergar_king": [189, 188, 132], +} + + +func _enemy_tex_path(key: String) -> String: + return "res://assets/art/character/character_enemy_%s.png" % key + + +func _check_display_tier() -> void: + # V13/V15 九键逐一:真实入口 StandeeBody → set_data(enemy_id) → set_portrait_texture(真纹理) → portrait_probe + for key: String in _DISPLAY_SPEC: + var spec: Array = _DISPLAY_SPEC[key] + var body: Node2D = StandeeBodyScript.new("hero" if key == "hero" else "enemy") + root.add_child(body) + var tex: Texture2D = UiKit.load_tex(UiKit.TEX_HERO_PORTRAIT if key == "hero" else _enemy_tex_path(key)) + if key == "hero": + body.set_portrait_texture(tex, true) + else: + body.set_data({"display_name": key, "enemy_id": key, "hp": 3, "max_hp": 3, + "order": 1, "armor_type": "light"}) + body.set_portrait_texture(tex, false) + var pp: Dictionary = body.portrait_probe() + Kit.check("显示档 %s:源高 %d(原稿表值——旧降采样稿必红)+显示高 %d 逐键钉值〔立绘显示档 V13〕" + % [key, int(spec[0]), int(spec[1])], + tex != null and int(tex.get_height()) == int(spec[0]) + and is_equal_approx(float(pp.display_size.y), float(spec[1])) + and is_equal_approx(float(pp.display_scale) * float(tex.get_height()), float(spec[1]))) + Kit.check("显示档 %s:纵横倍率恒等(双轴同倍率)+显示宽随源比例(round 档 %d)〔立绘显示档 V15〕" + % [key, int(spec[2])], + is_equal_approx(float(pp.display_size.x), float(tex.get_width()) * float(pp.display_scale)) + and is_equal_approx(float(pp.display_size.y), float(tex.get_height()) * float(pp.display_scale)) + and absf(float(pp.display_size.x) - float(spec[2])) < 0.51) + body.queue_free() + # V14 静态源锚:显示档表读表单点 + 零硬编码 2× 倍率 + var motion_src := _read_source("res://presentation/views/standee_motion.gd") + Kit.check("显示档表单点在位(_place_portrait 读 PORTRAIT_DISPLAY_H)且零 Vector2(2.0, 2.0) 字面倍率〔立绘显示档 V14〕", + motion_src.contains("PORTRAIT_DISPLAY_H") and not motion_src.contains("Vector2(2.0, 2.0)")) + # V16 主角三态:组合根装载(hero_tex 注册链)→ 逐态 apply_hero_state 复读探针 + var stage_view: Node = load("res://presentation/views/battle_stage_view.gd").new(null, load("res://data/tables/battle_layout.tres")) + root.add_child(stage_view) + var hs: Node2D = stage_view.hero_standee + var pp0: Dictionary = hs.portrait_probe() + Kit.check("主角 normal 态:源高 ≥92 且显示高 144(组合根真实装载链)〔立绘显示档 V16〕", + bool(pp0.has_tex) and is_equal_approx(float(pp0.display_size.y), 144.0)) + for hstate: String in ["attack", "hit"]: + hs.set_hero_state(hstate) + var pph: Dictionary = hs.portrait_probe() + Kit.check("主角 %s 态:源高 ≥92 且显示高 144(三态同档)〔立绘显示档 V16〕" % hstate, + bool(pph.has_tex) and is_equal_approx(float(pph.display_size.y), 144.0)) + stage_view.queue_free() + # V17 跨语言对账:Python 显示档表(STANDEE_DISPLAY_H 八敌 + STANDEE_SLOT_H 主角)逐键 == GDScript 表 + # (读不到工具源 = L2 记录退回双端字面钉值+独立审计对账——禁当红亦禁当绿) + var py_src := "" + for py_path: String in ["../tools/pixelize_candidates.py", "tools/pixelize_candidates.py"]: + var pf := FileAccess.open(py_path, FileAccess.READ) + if pf != null: + py_src = pf.get_as_text() + break + if py_src.is_empty(): + print("[L2] V17 工具源不可读(../tools/ 路径不通)——退回双端字面钉值+独立审计对账(记录不判红)") + else: + var py_ok := true + for key: String in _DISPLAY_SPEC: + if key == "hero": + py_ok = py_ok and py_src.contains("STANDEE_SLOT_H = %d" % int(_DISPLAY_SPEC[key][1])) + else: + py_ok = py_ok and py_src.contains("\"%s\": %d" % [key, int(_DISPLAY_SPEC[key][1])]) + Kit.check("Python 显示档表与 GDScript PORTRAIT_DISPLAY_H 九键逐键相等(源文本对账)〔立绘显示档 V17〕", py_ok) + # V18 键集恰九键(八敌 ∪ hero,无多余键——防新敌种漏登记被 push_error 静默) + var motion_script: Script = load("res://presentation/views/standee_motion.gd") + var mcmap: Dictionary = motion_script.get_script_constant_map() + var disp_tbl: Dictionary = mcmap.get("PORTRAIT_DISPLAY_H", {}) + var keys_ok: bool = disp_tbl.size() == 9 + for k: String in ["bugbear", "drider", "duergar", "duergar_king", "giant_spider", + "goblin", "hook_horror", "ogre_chief", "hero"]: + keys_ok = keys_ok and disp_tbl.has(k) + Kit.check("显示档表键集 = 八敌 ∪ hero 恰九键(无多余键)〔立绘显示档 V18〕", keys_ok) diff --git a/tools/path_check.py b/tools/path_check.py index 2f5dd793..60f979b5 100644 --- a/tools/path_check.py +++ b/tools/path_check.py @@ -44,7 +44,7 @@ ACTIVE_FILES = [ "fx/shovel_swing_f2.png", "board/seal_mark.png", ] -# 立绘八敌按数据表键生成(键集=STANDEE_TIER_H,见 standee_asset_check) +# 立绘八敌按数据表键生成(键集=STANDEE_DISPLAY_H,见 standee_asset_check) ENEMY_KEYS = ("bugbear", "drider", "duergar", "duergar_king", "giant_spider", "goblin", "hook_horror", "ogre_chief") ACTIVE_FILES += ["character/character_enemy_%s.png" % k for k in ENEMY_KEYS] diff --git a/tools/pixelize_candidates.py b/tools/pixelize_candidates.py index 5d397d47..c3124b50 100644 --- a/tools/pixelize_candidates.py +++ b/tools/pixelize_candidates.py @@ -12,9 +12,9 @@ v3 设计要点(对应四类问题:脏点/模糊/信息丢失/白边): 用法: python tools/pixelize_candidates.py <候选批次目录> # v3 批量像素化(现行行为) python tools/pixelize_candidates.py <目录> --fixer # +rust fixer 收网格+封闭灰袋清除 - python tools/pixelize_candidates.py <目录> --standee48 [--standee-out ] - # 九张 48px 站桩槽位档(八敌 pixel-1+主角 normal; - # 底中锚/NEAREST/统一比例保体量/画布高恒 48) + python tools/pixelize_candidates.py <目录> --standee-display [--standee-out ] + # 九张站桩原稿分辨率稿(八敌 pixel-1+主角手修 2x 稿; + # 内容紧裁原样存盘,零降采样——显示缩放归游戏侧按档) --fixer: rust pixelfixer(Retro-Diffusion, MIT, D:/code/pixel-art-fixer)对原图先做网格检测+重建(收网格),v3 去底后再跑封闭灰袋清除 (三闸=近底色∧中性灰∧组件不触图缘——防误伤盔甲与影);--fixer-exe 可换引擎路径。 @@ -25,25 +25,29 @@ from PIL import Image, ImageFilter FIXER_EXE_DEFAULT = r"D:\code\pixel-art-fixer\rust\target\release\pixelfixer.exe" FIXER_EXE = FIXER_EXE_DEFAULT # --fixer-exe 可覆写(__main__ 解析) -STANDEE_SLOT_H = 48 # 主角站桩源档高(×2 整数放大=盒高 96——圣经 §8.2) -STANDEE_SLOT_W_MAX = 75 # 盒宽 150 的一半(超宽才按宽收,现八敌+主角均不触) +STANDEE_SLOT_H = 144 # 主角显示档高(中型档——art-bible §8.2 三档绝对高) -# 八敌站桩源档高 = 生物体型,不是强度档位 -# 口径:体量按「这只生物本来多大」定;强度改由别的通道承载(Boss 的紫赤色相独占、 -# 精英的垂直延伸、HUD 意图徽标)。因此下表与 §5.5 的普通/精英/Boss 档位**不对齐**—— -# 例如钩爪恐魔是普通怪却比精英灰矮人大,灰矮人是精英却比普通/精英熊地精小。 -# 数值经实测校准(视觉体量 = 不透明像素面积,不看高度——蜘蛛宽扁,按高度排会失真): -# 地精 828 < 灰矮人 1014 < 巨蜘蛛 1390 < 钩爪恐魔 1538 -# < 熊地精 2145 < 食人魔酋长 2886 < 蛛化卓尔精灵 3513 < 灰矮人石君王 4385 -STANDEE_TIER_H = { - "goblin": 36, "giant_spider": 38, - "duergar": 46, "hook_horror": 58, - "bugbear": 62, "ogre_chief": 74, - "drider": 90, "duergar_king": 94, +# 八敌站桩显示档 = 三档绝对高(art-bible §8.2 显示档定值单点)——小型 96 / 中型 144 / 大型 188 +# 归档 = 生物体型语义(character-design §5.2):小 < 中 < 大,禁按强度重排。 +# 显示倍率 = 档高 / 原稿高,宽随源比例(高定宽随,NEAREST);入包件 = 原稿紧裁原样, +# 禁任何降采样(2026-10-14 降档裁决作废——现行入包稿与原稿逐像素同)。 +STANDEE_DISPLAY_H = { + "goblin": 96, "giant_spider": 96, "duergar": 96, + "hook_horror": 144, "bugbear": 144, + "ogre_chief": 188, "drider": 188, "duergar_king": 188, +} + +# 原稿高表(入包件逐件源高 == 此值——禁降采样纪律的机检锚;主角 = 手修 2x 稿紧裁后高) +STANDEE_SRC_H = { + "goblin": 90, "giant_spider": 81, "duergar": 126, + "hook_horror": 94, "bugbear": 123, + "ogre_chief": 189, "drider": 189, "duergar_king": 189, + "hero": 92, } # 中间产物(pixel-1)的生成分辨率——只决定中间图有多细,**不决定最终体量**。 -# 最终体量归 STANDEE_TIER_H。这里取得比最终档高越大,后续降采样越干净。 +# 站桩入包件 = 原稿紧裁原样(STANDEE_SRC_H),显示体量归 STANDEE_DISPLAY_H。这里取得 +# 比显示档越大,入包件细节越足。 # 注意:ogre_chief 取 192 是分辨率选择(要给它更高的中间精度),不是把它归为 Boss 档。 SIZES = {"goblin": 96, "giant_spider": 96, "hook_horror": 96, "bugbear": 128, "duergar": 128, @@ -456,60 +460,62 @@ def dom_block_resize(im, nw, nh): return out -def build_standee48(batch_root, out_dir, extra_hero_states=("attack", "hit")): - """出八敌 + 主角的站桩源档。源 = 八敌 pixel-1 + 主角 normal_1x(追加 attack/hit,默认启用)。 +def build_standee_display(batch_root, out_dir, extra_hero_states=("attack", "hit")): + """出八敌 + 主角的站桩入包稿(原稿分辨率,零降采样)。源 = 八敌 pixel-1 + 主角手修 2x 稿。 口径: - 1. 八敌各自按 STANDEE_TIER_H 定高——那是生物体型,不是强度档位。 - (实测可读门槛约 32px,48px 舒适;若全敌共用「48/最大值」一个系数, - 普通怪被压到 21–24px 低于门槛、钩爪恐魔糊成一团——禁用统一比例。) - 2. 主角仍按 STANDEE_SLOT_H(48)定高。 - 3. 画布高度 = 该角色自己的档高;内容裁透明边后底中锚贴底缘。 - 4. 降采样走 dom_block_resize(块内众数投票,零插值)。 + 1. 内容紧裁透明边后原样存盘——画布即内容,禁任何缩放与降采样(art-bible §8.2: + 显示按三档绝对高在游戏侧缩放,入包件保留原稿分辨率)。 + 2. 逐件自证:紧裁后高度 == STANDEE_SRC_H 表值;九件计数 != 9 即停 + (缺件即停——禁回退旧降采样稿顶替)。 """ os.makedirs(out_dir, exist_ok=True) enemy_srcs = {} - for key in sorted(SIZES.keys()): + for key in sorted(STANDEE_SRC_H.keys()): + if key == "hero": + continue p = os.path.join(batch_root, key, "pixel-1.png") if os.path.isfile(p): enemy_srcs[key] = Image.open(p).convert("RGBA") - hero_p = os.path.join(batch_root, "hero-ref", "edit", "normal_1x.png") + hero_p = os.path.join(batch_root, "hero-ref", "edit", "normal_2x.png") jobs = [(k, im, "character_enemy_%s.png" % k) for k, im in enemy_srcs.items()] if os.path.isfile(hero_p): jobs.append(("hero_normal", Image.open(hero_p).convert("RGBA"), "character_hero_portrait.png")) for st in extra_hero_states: - sp = os.path.join(batch_root, "hero-ref", "edit", "%s_1x.png" % st) + sp = os.path.join(batch_root, "hero-ref", "edit", "%s_2x.png" % st) if os.path.isfile(sp): jobs.append(("hero_%s" % st, Image.open(sp).convert("RGBA"), "character_hero_%s.png" % st)) made = [] + n_pieces = 0 # 九件 = 八敌 + 主角立绘(attack/hit 为立绘伴随态,不入计数) for key, im, out_name in jobs: l, t, r, b = _bbox_alpha(im) im = im.crop((l, t, r, b)) - is_hero = key == "hero_normal" or key.startswith("hero_") - slot_h = STANDEE_SLOT_H if is_hero else STANDEE_TIER_H.get(key, STANDEE_SLOT_H) - f = slot_h / float(im.height) - nw, nh = max(1, int(round(im.width * f))), max(1, int(round(im.height * f))) - if nw > STANDEE_SLOT_W_MAX: # 超宽兜底(现无触)——按宽收再贴底 - k2 = STANDEE_SLOT_W_MAX / float(nw) - nw, nh = STANDEE_SLOT_W_MAX, max(1, int(round(nh * k2))) - body = dom_block_resize(im, nw, min(nh, slot_h)) - canvas = Image.new("RGBA", (nw, slot_h), (0, 0, 0, 0)) - canvas.alpha_composite(body, (0, slot_h - body.height)) # 底中锚:脚贴画布底缘 + src_key = "hero" if key.startswith("hero") else key + if im.height != STANDEE_SRC_H.get(src_key): + print("FAIL 站桩原稿高失配 %s: 实得 %d, 表值 %s(禁降采样——查源稿)" + % (key, im.height, STANDEE_SRC_H.get(src_key))) + sys.exit(2) outp = os.path.join(out_dir, out_name) - canvas.save(outp) - print("standee %-14s %sx%s -> %s" % (key, nw, slot_h, outp)) + im.save(outp) + print("standee %-14s %dx%d -> %s" % (key, im.width, im.height, outp)) made.append(outp) + if out_name.startswith("character_enemy_") or out_name == "character_hero_portrait.png": + n_pieces += 1 + if n_pieces != len(STANDEE_SRC_H): + print("FAIL 站桩产物计数 %d != %d(缺件即停——禁回退旧降采样稿顶替)" + % (n_pieces, len(STANDEE_SRC_H))) + sys.exit(2) return made def main(argv): root = argv[0] if argv and not argv[0].startswith("--") else "artwork/remote-gen-2026-09-26" args = argv[1:] if argv and not argv[0].startswith("--") else argv - if "--standee48" in args: + if "--standee-display" in args: out = args[args.index("--standee-out") + 1] if "--standee-out" in args \ else os.path.join("game", "assets", "art", "character") - build_standee48(root, out) + build_standee_display(root, out) return use_fixer = "--fixer" in args thumbs = [] diff --git a/tools/render_sample_sheet_v3.py b/tools/render_sample_sheet_v3.py index ae560eb0..f540deb8 100644 --- a/tools/render_sample_sheet_v3.py +++ b/tools/render_sample_sheet_v3.py @@ -51,6 +51,7 @@ import hashlib from collections import deque from layout_config import load as load_layout +from pixelize_candidates import STANDEE_SLOT_H # 站桩显示档单点(主角=中型档——art-bible §8.2) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) MAIN_ROOT = "D:/code/g-plan2" @@ -772,7 +773,7 @@ def _armor_tex(armor): def draw_standee(canvas, ox, oy, kind, name, enemy_id, order=1, armor="light", hp=1.0, intent="", statuses=(), dead=False): - """站桩 150×176(standee_body.gd 几何):血条+状态行头顶/立绘盒 96px/底座/敌名行。""" + """站桩 150×176(standee_body.gd 几何):血条+状态行头顶/立绘盒 96px 槽位(显示档居中溢出)/底座/敌名行。""" d = ImageDraw.Draw(canvas) W, H = 150, 176 # 头顶:顺位徽标(敌,FS_HINT 14)+血条(填充=危险红 #A8321F 不因敌我分色;条底=HP_BAR_TRACK) @@ -788,11 +789,13 @@ def draw_standee(canvas, ox, oy, kind, name, enemy_id, order=1, armor="light", for i, ic in enumerate(statuses): icon = STATUS_ICONS[ic].resize((16, 16), Image.NEAREST) canvas.alpha_composite(icon, (sx + i * 17, oy + 18)) - # 立绘盒 96px(48 源 ×2 整数——C5) + # 立绘盒 96px 槽位(hero 显示档 = STANDEE_SLOT_H 绝对高、宽随源比例——§8.2,居中溢出同 _place_portrait) pbox_y = oy + 44 if kind == "hero": - por = PORTRAIT_SRC.resize((96, 96), Image.NEAREST) - canvas.alpha_composite(por, (ox + (W - 96) // 2, pbox_y)) + disp_h = STANDEE_SLOT_H + disp_w = int(round(PORTRAIT_SRC.width * disp_h / float(PORTRAIT_SRC.height))) + por = PORTRAIT_SRC.resize((disp_w, disp_h), Image.NEAREST) + canvas.alpha_composite(por, (ox + (W - disp_w) // 2, pbox_y + (96 - disp_h) // 2)) else: glyph = {"goblin": "▂", "bugbear": "▆", "ogre_chief": "█"}.get(enemy_id, "敌") col = (150, 60, 48) if not dead else (110, 104, 98) @@ -1131,11 +1134,11 @@ def run_checks(board, placement, records, band, gmap, canvas_img=None): edge_hits = edge_content(canvas_img) check("A5", "画布四缘 2px 净空(零溢出)", edge_hits == 0, "缘带内容像素 %d" % edge_hits) # A4 混排相融:小人/立绘/状态图标=在库原件;语义色==UiKit 常量 - # A4 尺寸基线按现行资产实态固化——立绘 46×48/棋盘小人源 32×32 + # A4 尺寸基线按现行资产实态固化——立绘 88×92(原稿分辨率)/棋盘小人源 32×32 # 硬等断言落锁(资产升档换代须显式解冻重校,断言失败=预期闸)。 - check("A4", "混排相融(在库原件可载+UiKit 语义色对表+尺寸基线冻结:立绘 46×48/小人源 32×32)", - PORTRAIT_SRC.size == (46, 48) and HERO_SRC.size == (32, 32), - "小人源 %s(裁透明留白后 %s)/立绘 %s[冻结 46×48]/状态图标原件;INK/封印黑/奖励金 == ui_kit.gd" + check("A4", "混排相融(在库原件可载+UiKit 语义色对表+尺寸基线冻结:立绘 88×92/小人源 32×32)", + PORTRAIT_SRC.size == (88, 92) and HERO_SRC.size == (32, 32), + "小人源 %s(裁透明留白后 %s)/立绘 %s[冻结 88×92]/状态图标原件;INK/封印黑/奖励金 == ui_kit.gd" % (str(HERO_SRC.size), str(HERO.size), str(PORTRAIT_SRC.size))) return all(c[2] for c in CHECKS) @@ -1497,42 +1500,42 @@ def render_outline_overlap(): img = Image.new("RGBA", (W, H), (24, 20, 17, 255)) d = ImageDraw.Draw(img) meta = {"hero_boxes": [], "overlap": None, "armor": {}} - # ① 战斗舞台语境:主角立绘 96px + 深墨描边 - por = PORTRAIT_SRC.resize((96, 96), Image.NEAREST) + # ① 战斗舞台语境:主角立绘显示档(STANDEE_SLOT_H 绝对高、宽随源比例)+深墨描边 + por = PORTRAIT_SRC.resize((int(round(PORTRAIT_SRC.width * STANDEE_SLOT_H / float(PORTRAIT_SRC.height))), STANDEE_SLOT_H), Image.NEAREST) img.alpha_composite(_outline_underlay(por, (40, 19, 13)), (18, 14)) img.alpha_composite(por, (18, 14)) - meta["hero_boxes"].append((18, 14, 18 + 96, 14 + 96)) - d.text((18, 116), "主角·战斗舞台语境\n描边=深墨 (40,19,13)", font=F_LBL2, fill=(225, 215, 195)) + meta["hero_boxes"].append((18, 14, 18 + por.size[0], 14 + por.size[1])) + d.text((18, 166), "主角·战斗舞台语境 描边=深墨 (40,19,13)", font=F_LBL2, fill=(225, 215, 195)) # ② 棋盘格语境:小人 48px 压已开格底 - d.rectangle([150, 14, 214, 78], fill=(24, 20, 16)) + d.rectangle([210, 14, 274, 78], fill=(24, 20, 16)) fl = FLOORS[0].resize((64, 64), Image.NEAREST) - img.paste(fl.convert("RGB"), (150, 14)) + img.paste(fl.convert("RGB"), (210, 14)) h48 = HERO.resize((48, int(48 * HERO.size[1] / HERO.size[0])), Image.NEAREST) - img.alpha_composite(h48, (150 + (64 - h48.size[0]) // 2, 14 + 64 - h48.size[1] - 8)) - meta["hero_boxes"].append((150 + (64 - h48.size[0]) // 2, 14 + 64 - h48.size[1] - 8, - 150 + (64 - h48.size[0]) // 2 + h48.size[0], 14 + 64 - 8)) - d.text((150, 116), "棋盘格语境\n(隔离已开格底)", font=F_LBL2, fill=(225, 215, 195)) + img.alpha_composite(h48, (210 + (64 - h48.size[0]) // 2, 14 + 64 - h48.size[1] - 8)) + meta["hero_boxes"].append((210 + (64 - h48.size[0]) // 2, 14 + 64 - h48.size[1] - 8, + 210 + (64 - h48.size[0]) // 2 + h48.size[0], 14 + 64 - 8)) + d.text((210, 116), "棋盘格语境\n(隔离已开格底)", font=F_LBL2, fill=(225, 215, 195)) # ③ 结算明细带语境:小人 24px 压明细带底 - d.rectangle([238, 14, 302, 78], fill=(46, 39, 32)) + d.rectangle([298, 14, 362, 78], fill=(46, 39, 32)) h24 = HERO.resize((24, int(24 * HERO.size[1] / HERO.size[0])), Image.NEAREST) - img.alpha_composite(h24, (238 + 8, 14 + 52 - h24.size[1])) - meta["hero_boxes"].append((238 + 8, 14 + 52 - h24.size[1], 238 + 8 + h24.size[0], 14 + 52)) - d.text((238, 116), "结算明细带语境\n(同剪影 24px)", font=F_LBL2, fill=(225, 215, 195)) + img.alpha_composite(h24, (298 + 8, 14 + 52 - h24.size[1])) + meta["hero_boxes"].append((298 + 8, 14 + 52 - h24.size[1], 298 + 8 + h24.size[0], 14 + 52)) + d.text((298, 116), "结算明细带语境\n(同剪影 24px)", font=F_LBL2, fill=(225, 215, 195)) # ④ 敌方描边对照:字形多向偏移暖白垫底+本体深朱 for ddx, ddy in [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, -1), (-1, 1), (1, 1)]: - d.text((350 + 32 + ddx, 14 + 32 + ddy), "█", font=F_STANDEE, fill=(240, 226, 200), anchor="mm") - d.text((350 + 32, 14 + 32), "█", font=F_STANDEE, fill=(150, 60, 48), anchor="mm") - d.text((350, 116), "敌方描边=暖白族\n(非黑非深褐)", font=F_LBL2, fill=(225, 215, 195)) + d.text((410 + 32 + ddx, 14 + 32 + ddy), "█", font=F_STANDEE, fill=(240, 226, 200), anchor="mm") + d.text((410 + 32, 14 + 32), "█", font=F_STANDEE, fill=(150, 60, 48), anchor="mm") + d.text((410, 116), "敌方描边=暖白族\n(非黑非深褐)", font=F_LBL2, fill=(225, 215, 195)) # ⑤ 护甲三枚展示(源 24×24 挂 HP_BAR_BACK 素底——工单 §5.3 挂载底色口径) for i, key in enumerate(["light", "ward", "heavy"]): - ax = 470 + i * 60 + ax = 500 + i * 60 d.rectangle([ax, 14, ax + 40, 54], fill=(40, 34, 32)) tex = _armor_tex(key) if tex is not None: img.paste(tex, (ax + 8, 22), tex) meta["armor"][key] = (ax + 8, 22, ax + 8 + 24, 22 + 24) d.text((ax + 4, 58), {"light": "轻甲", "ward": "魔甲", "heavy": "重甲"}[key], font=F_LBL2, fill=(225, 215, 195)) - d.text((470, 116), "护甲三枚 24×24\n挂载底=名字行素底", font=F_LBL2, fill=(225, 215, 195)) + d.text((500, 116), "护甲三枚 24×24\n挂载底=名字行素底", font=F_LBL2, fill=(225, 215, 195)) # ⑥ 遮挡对照组:敌像(最底)→旗→小人+蓝框→数字(最顶)——P5 断言:顶层三件仍可辨 cx, cy0 = 700, 10 d.rectangle([cx, cy0, cx + 68, cy0 + 68], fill=(24, 20, 16)) diff --git a/tools/standee_asset_check.py b/tools/standee_asset_check.py index 0be3626d..710313f4 100644 --- a/tools/standee_asset_check.py +++ b/tools/standee_asset_check.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""站桩素材机检(B1 硬闸升级)。 +"""站桩素材机检(B1 硬闸,显示档口径)。 -升级依据=art-bible §8.3③ 挂点规格(审计 §07-2/3): - - 逐键校验档高(逐键断言 STANDEE_TIER_H),宽度按「源宽×2 ≤ 站桩盒宽 150」 - 物理约束校验; +判据(art-bible §8.2 站桩立绘显示档 = 三档绝对高): + - 逐键校验源高(逐件断言 STANDEE_SRC_H——入包件 = 原稿紧裁原样,禁降采样); + - 显示宽按「round(源宽 × 档高 / 源高) ≤ 站桩盒宽 150」校验 + (食人魔酋长 157 超宽 8px 为已接受项,登记例外表逐字校验); - 像素域色数 ≤32(**排除全透明像素**,见下方口径注); - 无非整数倍缩放痕迹(alpha 二值 + 无中间 α); - 描边亮度达标(亮描边暖白族,character-design §10硬规则「禁黑描边」)。 @@ -13,8 +14,9 @@ naive `len(set(所有像素))` 会把全透明 (0,0,0,0) 计成一个色 ⇒ 报33 假红灯。 正确口径 = 只统计 alpha>0 像素的唯一 RGB。机检必须如此,否则天天误报。 -档高表= tools/pixelize_candidates.py 的 STANDEE_TIER_H(定值单点纪律 §8:本表 -不在检查器里复写一份,改由 import 读,避免两处数字漂移)。 +表单点 = tools/pixelize_candidates.py 的 STANDEE_DISPLAY_H / STANDEE_SRC_H / +STANDEE_SLOT_H(定值单点纪律 §8:本件不在检查器里复写一份,改由 import 读; +import 失败即崩——禁兜底字面量造第三处复写)。 用法: python tools/standee_asset_check.py [角色素材目录] @@ -29,16 +31,7 @@ import sys from PIL import Image sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -try: - from pixelize_candidates import STANDEE_TIER_H, STANDEE_SLOT_H -except ImportError: # pragma: no cover - STANDEE_TIER_H = { - "goblin": 36, "giant_spider": 38, - "duergar": 46, "hook_horror": 58, - "bugbear": 62, "ogre_chief": 74, - "drider": 90, "duergar_king": 94, - } - STANDEE_SLOT_H = 48 +from pixelize_candidates import STANDEE_DISPLAY_H, STANDEE_SRC_H, STANDEE_SLOT_H ENEMY_KEYS = ("bugbear", "drider", "duergar", "duergar_king", "giant_spider", "goblin", "hook_horror", "ogre_chief") @@ -48,9 +41,10 @@ REQUIRED = ["character_enemy_%s.png" % k for k in ENEMY_KEYS] + \ # ── 契约常量(全部有出处,禁复写第二份) ────────────────────────────── COLOR_MAX = 32 # 圣经 §4.1/fixer 32 色冻结;机检口径=alpha>0 唯一 RGB -DISPLAY_SCALE = 2 # standee_body.gd:237 _portrait_sprite.scale = (2,2) BOX_W = 150 # standee_body.gd:29 var box := Vector2(150, 176) -SRC_W_MAX = BOX_W // DISPLAY_SCALE # =75,源宽上限(×2 后须塞进盒宽) +# 显示宽例外(已接受项——art-bible §8.2 槽位条):食人魔酋长显示宽 157 超桩盒 150, +# 逐字校验 = 实算宽必须恰等于登记值,源稿一变即红。 +DISPLAY_W_EXCEPT = {"character_enemy_ogre_chief.png": 157} ALPHA_BIN = {0, 255} # 二值 alpha 域 # 描边亮度阈值(实测标定): # 敌人暖白边占比实测 54.1%–84.3%,主角 portrait/hit 实测 0.4%(无暖白描边,见下)。 @@ -72,12 +66,19 @@ def _sat(c): return 0.0 if mx == 0 else (mx - mn) / float(mx) -def expected_tier_h(fn): - """按文件名返回应有档高。""" +def src_key_of(fn): + """按文件名返回 STANDEE_SRC_H 键(敌=键名;主角三态同键 hero)。""" if fn.startswith("character_enemy_") and fn.endswith(".png"): - key = fn[len("character_enemy_"):-len(".png")] - return STANDEE_TIER_H.get(key) - return STANDEE_SLOT_H # 主角三态同档 + return fn[len("character_enemy_"):-len(".png")] + return "hero" # 主角三态同源稿高 + + +def display_h_of(fn): + """按文件名返回显示档高(敌=逐键显示档;主角=中型档 144)。""" + key = src_key_of(fn) + if key == "hero": + return STANDEE_SLOT_H + return STANDEE_DISPLAY_H.get(key) def check(path, fn): @@ -87,15 +88,19 @@ def check(path, fn): w, h = im.size px = im.load() - # 断言①逐键档高 - tier = expected_tier_h(fn) - if tier is not None and h != tier: - problems.append("档高应 %d(§8.2 逐键定档),实得 %d" % (tier, h)) + # 断言① 逐件源高 = 原稿表值(禁降采样——旧降采样稿必红) + src_h = STANDEE_SRC_H.get(src_key_of(fn)) + if src_h is not None and h != src_h: + problems.append("源高应 %d(原稿表值——禁降采样),实得 %d" % (src_h, h)) - # 断言② 宽度物理约束:源宽 ×2 须塞进站桩盒宽 150 - if w > SRC_W_MAX: - problems.append("源宽 %d ×2=%d 超站桩盒宽 %d(§8.2 高定宽随但不得溢出盒)" - % (w, w * DISPLAY_SCALE, BOX_W)) + # 断言② 显示宽物理约束:round(源宽 × 档高 / 源高) ≤ 盒宽 150(酋长 157 例外逐字校验) + disp_h = display_h_of(fn) + disp_w = -1 + if disp_h is not None and src_h: + disp_w = int(round(w * disp_h / float(src_h))) + if disp_w > BOX_W and DISPLAY_W_EXCEPT.get(fn) != disp_w: + problems.append("显示宽 %d 超站桩盒宽 %d(§8.2 高定宽随;例外表未登记)" + % (disp_w, BOX_W)) if w < 1: problems.append("源宽 %d 非法" % w) @@ -119,7 +124,7 @@ def check(path, fn): if nb: edges.append(c) - # 断言③ 底中锚(脚贴画布底缘) + # 断言③ 底中锚(脚贴画布底缘——紧裁件底行即脚底) if bottom_opaque < 1: problems.append("底行无不透明像素(底中锚失守——脚未贴画布底缘)") @@ -148,7 +153,9 @@ def check(path, fn): info = { "size": [w, h], - "tier_h": tier, + "src_h": src_h, + "display_h": disp_h, + "display_w": disp_w, "colors": len(colors), "bottom_opaque": bottom_opaque, "edge_px": len(edges), @@ -188,9 +195,10 @@ def main(): for pr in r["problems"]: print("FAIL %-36s %s" % (r["file"], pr)) else: - print("PASS %-36s %dx%d 档高%d 色数%d 暖白边%.1f%%%s" - % (r["file"], i["size"][0], i["size"][1], i["tier_h"], - i["colors"], i["outline_warm_pct"], + print("PASS %-36s %dx%d 源高%d 显示%dx%d 色数%d 暖白边%.1f%%%s" + % (r["file"], i["size"][0], i["size"][1], i["src_h"], + i["display_w"], i["display_h"], i["colors"], + i["outline_warm_pct"], "(豁免)" if i["outline_exempt"] else "")) print("机检完成:%d 件必需,%d 件未过" % (len(REQUIRED), n_fail)) if not as_json: @@ -200,4 +208,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main()