iter48-卡2:卡②(立绘显示档批): 九件素材原稿分辨率入包(紧裁零降采样,STANDEE_SRC_H九键+产物计数自证)+standee_motion显示档PORTRAIT_DISPLAY_H九键钉值(_place_portrait按档算倍率,缺键push_error按1×)+portrait_probe扩display_size/display_scale+闸件standee_asset_check换口径(源高==原稿表值/显示宽≤150酋长157例外逐字)+样张站桩段按显示档取数(A4解冻重校88×92,V10并排30/30)+V13–V18全绿(50checks)+三面对照图

This commit is contained in:
DKChan committed 2026-11-02 01:58:23 +08:00
1 parent 1e35ab947b
commit 4c3f0f6082
9 files changed
+255 -119

No files matched your search

+2 -1
View File
@@ -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)
+5 -1
View File
@@ -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 帧数档挂点) ----
+28 -7
View File
@@ -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 不注册=该态保持现纹理)
+2 -2
View File
@@ -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,
@@ -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)
+1 -1
View File
@@ -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]
+49 -43
View File
@@ -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 <game/assets/art/character>]
# 九张 48px 站桩槽位档(八敌 pixel-1+主角 normal;
# 底中锚/NEAREST/统一比例保体量/画布高恒 48)
python tools/pixelize_candidates.py <目录> --standee-display [--standee-out <game/assets/art/character>]
# 九张站桩原稿分辨率稿(八敌 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 = []
+30 -27
View File
@@ -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))
+45 -37
View File
@@ -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()
main()