diff --git a/design/gdd/systems/表现与支撑/HUD框架.md b/design/gdd/systems/表现与支撑/HUD框架.md index d7964760..41211bb4 100644 --- a/design/gdd/systems/表现与支撑/HUD框架.md +++ b/design/gdd/systems/表现与支撑/HUD框架.md @@ -252,3 +252,4 @@ HUD框架站在玩家与全部玩法系统之间:挖掘期它是输入的翻 | v0.7 | 2026-09-20 | **表现层三批落地 + 义务清单补行(代码同日三批:样式收敛 / 站桩改造 / 结算演出链)**:呈现义务来源清单补「关卡节点地图」行(节点链进度常显 + 选路覆盖层只读呈现——此前漏登记);新增结算演出覆盖层(跨区弹道/光带载体,兑现演出因果可视);减动效承诺落地(UiKit.motion 单点包装全部呈现层 tween 时长,开启时长减半、可挖域呼吸停格;切片期入口 = TunePanel 开关,正式设置层归设置批)。代码侧站桩载体(StandeeView)与卡片退役随 v0.6 裁决执行 | | v0.8 | 2026-09-23 | **停点前移批(冒险入口流程倒置修复,用户 2026-09-23 拍板;上位裁决 `docs/architecture/adr-0006-board-node2d-rendering.md` 与 `docs/architecture/board-node2d-migration-plan.md` §1,代码实装另批并行)**:① 战斗屏布局四区 → 三区——删 A 区关卡进度条行(LevelStripView 退役删除,节点链进度呈现移出战斗屏、只留关卡画面);C/D 区纵向 520 → 548;装板校验变量与实算同步(棋盘区高 504、可用高度 488);② 呈现义务来源清单「关卡节点地图」行改承载口径——节点链进度由关卡画面选路覆盖层节点态承载(暗金已过 / 金当前 / 候选下一跳通道亮金 / Boss 旗朱描边),不另设进度条组件;③ 「四区」措辞全线改「三区」(负责表、节题、结构冻结、验收、下游战斗表现交付行),双舞台常驻语义不动。**批内代拍板三项已追认(2026-09-24 用户确认)**:① B/C/D 字母保持不重编(字母是跨文档/代码注释的承载标识);② A 区 28px 纵向空间划归 C/D 底行(520 → 548;1920 档换算仍归表现层升级批);③ 关卡画面进度呈现由选路覆盖层节点态直接承载、不另设进度条组件 | | v0.9 | 2026-09-23 | 渲染技术注记(棋盘 Node2D 迁移批 Phase A,代码实装同批;上位裁决 ADR-0006):棋盘本体 Node2D 化——BoardView 内部由 GridContainer+逐格控件改为 Control 壳 + SubViewport + Node2D 世界,类名/信号/公共方法契约不变、探针零改造;三区布局与性能预算(同屏 draw calls < 200)不变;程序化占位视觉等价平移,素材终稿按 art-bible §8.2 格面 32×32 暂定档槽位 1:1 换入 | +| v0.10 | 2026-09-26 | 渲染技术注记(战斗舞台 Node2D 迁移批 Phase B,代码实装同批;上位裁决 ADR-0006):B 区战斗舞台 Node2D 化——BattleStageView 内部由 PanelContainer+Control 站桩改为 Control 壳 + SubViewportContainer + SubViewport + Node2D 世界(StageWorld/StandeeBody),类名/setter 面/公共方法契约不变、调用点零改造;状态图标行与悬停 tooltip 保留 Control 载体挂壳层(伴随件);C6 字体注入挂点复用(set_world_font);三区布局与性能预算(同屏 draw calls < 200)不变;占位表现等价平移(时序常量逐字对照——等价证明见 production/qa/battle-stage-node2d-等价证明-2026-09-26.md) | diff --git a/design/ux/battle.md b/design/ux/battle.md index 45a7715e..710948bd 100644 --- a/design/ux/battle.md +++ b/design/ux/battle.md @@ -437,6 +437,7 @@ | 2026-09-20 | **敌人卡形态裁决收口(2026-09-20 用户看对照样张拍板,06 待办「敌人卡形态」解决移除——台账 99)**:全站桩形态定稿——① B 区主角与敌人均为无框立绘+底座(站桩式),**英雄卡/敌人卡组件整体退役,卡片式不再作为候选**;主角血条悬浮头顶、状态效果小图标一排挂血条正下方,敌人血条+顺位徽标悬浮头顶、状态小图标同款挂血条下;攻击值等规划数据仍归 C 区数据框(C 区不动);② 布局表 B 区行、Component Inventory 英雄卡/敌人卡两行随裁决改写(敌方 side 池共享层数各立绘同值、意图动画占位等既有语义平移到站桩载体,不随形态改变);③ ASCII 线框图 B 区重绘(旧"意图:爪击/环点"示意早已随行为环退役,本次一并清掉);④ OQ4 改写为裁决已收口注记,对照样张存档 `production/mockups/`;呈现义务细节重述(受击/退场演出挂点等)随表现层升级批执行 | | 2026-09-23 | **停点前移批(冒险入口流程倒置修复,用户 2026-09-23 拍板;上位裁决 `docs/architecture/adr-0006-board-node2d-rendering.md` 与 `docs/architecture/board-node2d-migration-plan.md` §1,代码实装另批并行)**:① 布局四区 → 三区——删布局表 A 区关卡进度条行与 Component Inventory LevelStripView 行(LevelStripView 退役删除,节点链进度只留关卡画面,战斗屏零进度承载);C/D 底行 520 → 548;ASCII 线框图重绘为 B/C/D 三区;② 新流程口径落文——关卡画面(选路覆盖层常驻形态)为关卡内常驻枢纽:Navigation Position 图、到达语境、Entry 表、信息层级第 5 层、Data Requirements 节点图行改「关卡开始即出图选路、战后返回关卡画面再选路」;③ AC 四区措辞改三区。**批内代拍板三项已追认(2026-09-24 用户确认)**:① B/C/D 字母保持不重编;② 28px 纵向空间划归 C/D 底行(520 → 548);③ 关卡画面进度呈现由选路覆盖层节点态承载(暗金已过 / 金当前 / 亮金候选通道 / 旗朱 Boss)、不另设进度条组件 | | 2026-09-23 | 实现注记(棋盘 Node2D 迁移批 Phase A,代码实装同批;上位裁决 ADR-0006):棋盘渲染 Node2D 化(BoardView = Control 壳 + SubViewportContainer + SubViewport + Node2D 世界,信号/click_cell/_cells 契约保持,新增 push_input 端到端输入探针);格尺寸公式锚点不变(set_cell_area = floor((min(宽,高) − 预留 − (N−1)×格间距) / N) 单点可调),布局与交互语义零改动 | +| 2026-09-26 | 实现注记(战斗舞台 Node2D 迁移批 Phase B,代码实装同批;上位裁决 ADR-0006):B 区战斗画面 Node2D 化(BattleStageView = Control 壳 + SubViewportContainer + SubViewport + Node2D 世界,站桩本体 StandeeBody 世界精灵;set_enemies/clear_enemies/on_step_row_fx/gray_all_enemies 等 setter 面签名保持,弹道落点锚点=壳层口径 world_to_shell 单点);状态图标行与悬停 tooltip 保留 Control 载体挂壳层;三区布局尺寸零改动(B 区外框 252px 逐位) | | 2026-09-26 | **战斗屏信息归属批(用户 2026-09-26 三轮纠偏后拍板;代码实装另批——hero_info_panel_view.gd / hud_presenter.gd 改造 + 探针断言迁移,登记待办)**:立两条信息归属原则并全屏贯行——①循环口径原则(战斗屏只展小循环内被消费、影响当下决策的数据;中/大循环资源不得入侵)②单一展示面原则(同一信息全屏唯一展示面;单位本体已承载的信息数据框不重复;资源数字贴唯一消费口)。随拍板落地:① B 区站桩 = HP 与状态效果唯一展示面——C 区 HP 条/HP 文字/立绘占位/状态效果槽×4 退役(重复展示;证据链 = hud_presenter.gd 同一 s.hero.hp/s.hero_effects 双投喂 B/C 两区);② RAP 读数(点阵+数字)迁 D 区 AP 条、置弃图刷新按钮旁(RAP 唯一消费口 = 弃图刷新——就近原则);③ C 区状态槽悬停 tooltip 职责并入 B 区站桩状态小图标;④ GP(中循环)/钥石(大循环)迁出战斗屏,去向挂 OQ14;⑤ C 区改「攻击数据框 + 魔法道具槽(预留)+ 本关强化列表(预留)」——预留区来自魔法道具与关卡内属性提升的既有立项;⑥ 空态/清零反馈/悬停交互/AC/§13 义务表载体同步改写。**批内遗留两项待拍板(OQ13/14)**:护甲 AC 是否补入 C 区数据框;GP/钥石去向 | | 2026-09-26 | **信息归属二轮拍板(用户 2026-09-26,OQ13/14 收口)**:① 我方护甲 AC 补入 C 区攻击数据框(唯一展示面——布局表/组件清单/线框图同步);② GP/钥石去向定案:GP = 关卡画面常驻总量 + 战斗结算画面总量+增量,钥石 = 结算画面 + 营地(具体呈现归对应 UX spec 批次);③ 敌方 AC 展示面挂起——用户口径「敌方 AC 无任何展示」与现行代码冲突(standee_view.gd 底库名字行渲染「名字·护甲·AC%d」,系敌人技能池批克制对策可读性义务),证据链已呈报待终裁,本 spec 暂不改敌人站桩行 | | 2026-09-26 | **敌方 AC 展示终裁收口(用户 2026-09-26 终裁)**:敌方 AC 数值全屏退役(任何区不直接展示)、护甲类型改图标方式展示(形状+色相双通道,终稿图标资产归美术批)、敌人站桩底座下名字行退回纯名字+护甲类型图标;旧「克制对策可读性义务」降级承载——类型保留、数值退役。终裁理由(用户口径):HP 信息已足够,攻防数值不直接写上;护甲类型图标保留克制对策可读性。同步改写:信息归属树敌人站桩行、Component Inventory 敌人站桩立绘行、OQ13。**代码实装说明**:`standee_view.gd` 改造(站桩名字行去 AC 数值 + 护甲类型图标化)由并行代码批执行,本批只做文档同步 | diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md index 52d613fa..bfbf859b 100644 --- a/docs/architecture/architecture.md +++ b/docs/architecture/architecture.md @@ -3,7 +3,7 @@ ## Document Status - Version: 1.3 -- Last Updated: 2026-09-23(棋盘 Node2D 渲染迁移 Phase A:D1 层表 BoardView 形态注记——引 ADR-0006) +- Last Updated: 2026-09-26(战斗舞台 Node2D 迁移 Phase B:D1 层表 BattleStageView 形态注记——引 ADR-0006;Phase A 注记 2026-09-23) - Engine: Godot 4.7.2-stable(GDScript 单语言;2026-09-07 锁定) - Review Mode: solo(`production/review-mode.txt`,2026-09-12 用户拍板——TD/LP 门禁按 solo 跳过,用户授权架构产出自主推进、迭代期回调) - GDDs Covered: game-concept · game-pillars · systems-index · d2-data-config · a3-action-economy · a4-terrain-variation · b3-status-effect · a1-board-generation · a2-minesweeper-rules · b2-enemy-system · b4-reward-item-economy · b1-settlement(12 份;A5/C1–C4/B5/D3–D6 未设计,按 systems-index 依赖分层预留) @@ -120,7 +120,7 @@ | 模块 | Owns | Exposes | Consumes | 引擎 API | |---|---|---|---|---| -| **D1 HUD**(切片 lite 已拆 9 单元:BoardView / HUDView / HudPresenter / BattleLogView / ItemWindowOverlay / NodeOverlay / LevelStripView / TunePanel / UiKit + 组合根 main.gd) | 全部 Control/CanvasLayer(例外:BoardView 棋盘本体 = Control 壳 + SubViewportContainer + SubViewport + Node2D 世界〔BoardWorld/CellUnit〕,探针契约面不变——ADR-0006 棋盘 Node2D 迁移批 Phase A 2026-09-23)、输入→意图翻译(**唯一提交路径 = settle_batch,不直调 A3**)、呈现义务台账(A2×6 / B2×4 / B3×4 / B4×5 / B1×3) | 视图单元接口(信号外发、setter 只读注入) | A2 compute_move_path / ActionOutcome;A3 ap/rap;B1 步骤流行协议(数据源);ConfigDB display_name | **MEDIUM**:Control / CanvasLayer / draw_* / InputEvent / RichTextLabel——对照 breaking-changes.md 4.5–4.7 条目(CanvasItem 线条无抗锯齿羽化、设备 ID 常量、RichTextLabel 图片参数两轮变化) | +| **D1 HUD**(切片 lite 已拆 9 单元:BoardView / HUDView / HudPresenter / BattleLogView / ItemWindowOverlay / NodeOverlay / LevelStripView / TunePanel / UiKit + 组合根 main.gd) | 全部 Control/CanvasLayer(例外:BoardView 棋盘本体与 BattleStageView 站桩本体 = Control 壳 + SubViewportContainer + SubViewport + Node2D 世界〔BoardWorld/CellUnit;StageWorld/StandeeBody——B 区 slot_row 伴随件留壳层〕,探针契约面不变——ADR-0006 棋盘 Node2D 迁移批 Phase A 2026-09-23 / 战斗舞台 Phase B 2026-09-26)、输入→意图翻译(**唯一提交路径 = settle_batch,不直调 A3**)、呈现义务台账(A2×6 / B2×4 / B3×4 / B4×5 / B1×3) | 视图单元接口(信号外发、setter 只读注入) | A2 compute_move_path / ActionOutcome;A3 ap/rap;B1 步骤流行协议(数据源);ConfigDB display_name | **MEDIUM**:Control / CanvasLayer / draw_* / InputEvent / RichTextLabel——对照 breaking-changes.md 4.5–4.7 条目(CanvasItem 线条无抗锯齿羽化、设备 ID 常量、RichTextLabel 图片参数两轮变化) | | **battle_presentation**(B5,垂直切片后) | 结算动画 / 伤害数字 / 战斗舞台 | (未设计——前置"战斗表现美术测试"锁双舞台陷阱) | B1 步骤流(回放脚本源)、D1 | **MEDIUM**:AnimationPlayer(String→StringName 变更)/ Tween | | **audio**(D4,Alpha) | 音频挂点 | (挂点 = B1 明细行类型流;负反馈聚合——单结算至多一次) | B1 流水 | AudioStreamPlayer(4.7 area_mask 默认 1→0 注意) | | **tutorial**(D5,Alpha) | 教学条目 | (口径来源:A2/A3/B3/B1 各教学条目) | 各上游 | 无 | diff --git a/docs/architecture/board-node2d-migration-plan.md b/docs/architecture/board-node2d-migration-plan.md index 90352709..e86921eb 100644 --- a/docs/architecture/board-node2d-migration-plan.md +++ b/docs/architecture/board-node2d-migration-plan.md @@ -55,11 +55,11 @@ BattleScreen(Control 栅格;目标形态 = B/C/D 三区——进度条退场 **不做**(明确出批):B 区舞台任何改动;素材终稿接入;格边长数值调整(归样张批);键盘寻格/无障碍(沿 ADR-0003 已接受口径)。 -## 3. Phase B 预留(后续批,本批只保证不堵死) +## 3. Phase B(2026-09-26 已落地——战斗舞台 Node2D 迁移批,台账 170-173) -- BattleStageView 同构改造:PanelContainer → Control 壳(Node2D 舞台 + Control 文本半区),站桩 StandeeView 从 Control 迁 Node2D 精灵——意图三档动画/受击/死亡演出的载体。 -- 本批交付的两个可复用挂点:SubViewport 内 theme/字体显式注入挂点(CJK 字体义务,ADR-0003 Decision 4 关联)、CellUnit 叠层槽位模式(B 区站桩底座同构)。 -- Phase B 开工硬前置 = 样张批冻结剪影与动画方案(清单 §1.2 联动条款),与本批无相互阻塞。 +- BattleStageView 同构改造**已实施**:PanelContainer → Control 壳(类名/252px/树内位置不变)+ Node2D 舞台半区(SubViewportContainer/SubViewport/StageWorld)+ Control 文本半区(round_label/stage_center/log_view 三件保留);站桩 StandeeView(Control)退役删除,本体 = StandeeBody(Node2D 世界精灵,行为面 17 方法契约保持);slot_row+悬停 tooltip 保留 Control 载体挂壳层(伴随件容器 = enemy_box 语义改写)。 +- Phase A 交付的两挂点已被 Phase B 复用:set_world_font 字体显式注入(C6 判据=注入非空+has_char+宽度>0)与坐标空间分界纪律(世界=局部排布,壳层换算单点 world_to_shell)。 +- **样张硬前置改判(用户 2026-09-26 裁决①)**:样张批冻结只卡换皮/演出批(循环 2 前置),不卡结构迁移——本节原「Phase B 开工硬前置 = 样张批冻结」条款据此作废;ADR-0006「只卡换皮不卡开工」原则的落文位。 ## 4. 探针与测量面(实证清单) diff --git a/game/presentation/views/battle_stage_view.gd b/game/presentation/views/battle_stage_view.gd index de06b01d..4bf6e07f 100644 --- a/game/presentation/views/battle_stage_view.gd +++ b/game/presentation/views/battle_stage_view.gd @@ -4,42 +4,90 @@ # 底部结算明细带(布局决策 D1 常驻双舞台分屏))。 # Date: 2026-09-12(2026-09-20 站桩改造批:HeroCardView/EnemyCardView 退役,StandeeView 换装; # setter 面契约保持——set_enemies/clear_enemies/on_step_row_fx/gray_all_enemies 签名不变) +# 战斗舞台 Node2D 迁移批 Phase B 2026-09-26(循环 1 T2 壳 + T3 站桩迁入;ADR-0006 / +# board-node2d-migration-plan §3):根壳 PanelContainer → Control(类名/尺寸 252px/树内位置 +# 不变),内部两半区——「Node2D 舞台半区 = SubViewportContainer + SubViewport + StageWorld +# (站桩本体 StandeeBody 世界精灵)」+「Control 文本半区 = round_label / stage_center / +# log_view 三件原样保留(C1 永久 Control)」。SubViewport 逻辑分辨率 = 壳层像素 1:1(stretch, +# 禁任何缩放系数——C2/C5);字体显式注入挂点 set_world_font(C6,Phase A BoardView 同款)。 +# T3 批内代拍板默认(待追认):①enemy_box 语义改写「站桩壳层伴随件容器」(HBoxContainer → +# Control,子件 = 各站桩的 StatusSlotRow 伴随件,手动定位随世界同步——探针 :97 计数面不变); +# ②hero_standee/enemy 站桩 = StandeeBody(Node2D 世界本体,行为面契约逐项保持 C3),其 +# slot_row 成员 = 壳层伴随件引用;③定位同步 = 世界→壳层换算单点 world_to_shell,重建/重排 +# 挂点调用(零每帧成本,C4)。旧 StandeeView(Control)随迁退役删除。 # 高内聚边界:容器+组合视图——站桩槽位管理、轮次指示、结算行演出分发改由 setter 面承接; # 结算流水格式化归 BattleLogView,核心状态→文案装配归 hud_presenter.gd。 class_name BattleStageView -extends PanelContainer +extends Control -var hero_standee: StandeeView +const _STAGE_WORLD := preload("res://presentation/views/stage_world.gd") +const _STANDEE_BODY := preload("res://presentation/views/standee_body.gd") +const _CONTENT_MARGINS := Rect2(6, 4, 6, 4) # 原 PanelContainer stylebox 内容边距(子面几何零漂移) +const _STANDEE_W := 150.0 # 站桩盒宽(旧 StandeeView custom_minimum_size) +const _STANDEE_GAP := 8.0 # 敌站桩间距(旧 enemy_box HBox separation) + +var hero_standee: Node2D # StandeeBody——主角站桩本体(世界内;slot_row = 壳层伴随件引用) var round_label: Label var stage_center: Label -var enemy_box: HBoxContainer +var enemy_box: Control # 站桩壳层伴随件容器(T3 语义改写:HBoxContainer → Control) var log_view: BattleLogView -var _cards: Array = [] +var _cards: Array = [] # StandeeBody(世界本体,敌) +var _row: HBoxContainer # 文本半区行(stage_center 载体 + 世界布局基准) +var _companion_layer: Control # 壳层伴随件层(主角 slot_row 宿主;敌伴随件宿主 = enemy_box) +var _svp_container: SubViewportContainer +var _viewport: SubViewport +var world: Node2D # StageWorld(preload 脚本实例——Node2D 世界根/字体挂点) func _init(db_ref) -> void: custom_minimum_size = Vector2(0, 252) + # 面板底(PanelContainer 面板样式等价平移——Control 壳不自绘 stylebox,Panel 子面承载) + var bg := Panel.new() var sb := UiKit.panel_flat(UiKit.PANEL_STAGE, UiKit.PANEL_BORDER, 0, 1, Vector4(6, 4, 6, 4)) - add_theme_stylebox_override("panel", sb) + bg.add_theme_stylebox_override("panel", sb) + bg.set_anchors_preset(Control.PRESET_FULL_RECT) + bg.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(bg) + + # ── Node2D 舞台半区(ADR-0006 Phase B 同构;stretch 1:1 无缩放系数——C2/C5)── + _svp_container = SubViewportContainer.new() + _svp_container.stretch = true # 容器尺寸 = 视口逻辑分辨率,恒 1:1 + _svp_container.mouse_filter = Control.MOUSE_FILTER_IGNORE # 世界零输入(tooltip 归壳层伴随件) + _svp_container.set_anchors_preset(Control.PRESET_FULL_RECT) + _svp_container.offset_left = _CONTENT_MARGINS.position.x + _svp_container.offset_top = _CONTENT_MARGINS.position.y + _svp_container.offset_right = -_CONTENT_MARGINS.size.x + _svp_container.offset_bottom = -_CONTENT_MARGINS.size.y + add_child(_svp_container) + _viewport = SubViewport.new() + _viewport.transparent_bg = true # 露出面板底色(与旧空隙同观感) + _svp_container.add_child(_viewport) + world = _STAGE_WORLD.new() + _viewport.add_child(world) + + # ── Control 文本半区(round_label / stage_center / log_view 原样保留——C1 永久 Control)── var vb := VBoxContainer.new() vb.add_theme_constant_override("separation", 4) + vb.set_anchors_preset(Control.PRESET_FULL_RECT) + vb.offset_left = _CONTENT_MARGINS.position.x + vb.offset_top = _CONTENT_MARGINS.position.y + vb.offset_right = -_CONTENT_MARGINS.size.x + vb.offset_bottom = -_CONTENT_MARGINS.size.y add_child(vb) round_label = UiKit.label("第 — 轮 · —", 15, true) round_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER vb.add_child(round_label) - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 10) - row.size_flags_vertical = Control.SIZE_EXPAND_FILL - vb.add_child(row) - - hero_standee = StandeeView.new("hero") - hero_standee.set_portrait_texture(UiKit.load_tex(UiKit.TEX_HERO_PORTRAIT), true) # B 区立绘 48×48 朝右(2026-09-26 用户点名 hero_idle_01 换装+水平向右;素材缺位 null 回退字形「人」) - row.add_child(hero_standee) + _row = HBoxContainer.new() + _row.add_theme_constant_override("separation", 10) + _row.size_flags_vertical = Control.SIZE_EXPAND_FILL + vb.add_child(_row) + _row.resized.connect(_on_row_resized) # 明细带展开/收起改行高——世界布局随动 var center := CenterContainer.new() center.size_flags_horizontal = Control.SIZE_EXPAND_FILL + center.mouse_filter = Control.MOUSE_FILTER_IGNORE # 行内只剩文本——鼠标全放行给壳层伴随件 var center_col := VBoxContainer.new() center_col.alignment = BoxContainer.ALIGNMENT_CENTER center.add_child(center_col) @@ -47,29 +95,133 @@ func _init(db_ref) -> void: stage_center.add_theme_color_override("font_color", UiKit.STONE_FAINT) stage_center.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER center_col.add_child(stage_center) - row.add_child(center) + _row.add_child(center) - enemy_box = HBoxContainer.new() - enemy_box.add_theme_constant_override("separation", 8) - enemy_box.alignment = BoxContainer.ALIGNMENT_END - row.add_child(enemy_box) + enemy_box = Control.new() # 站桩壳层伴随件容器(T3 语义改写) + enemy_box.mouse_filter = Control.MOUSE_FILTER_IGNORE + _row.add_child(enemy_box) log_view = BattleLogView.new(db_ref) vb.add_child(log_view) + # ── 壳层伴随件层(最顶——世界站桩之上的 Control 触达面)── + _companion_layer = Control.new() + _companion_layer.set_anchors_preset(Control.PRESET_FULL_RECT) + _companion_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_companion_layer) + + # 主角站桩本体(世界)+ 状态行伴随件(壳层) + hero_standee = _STANDEE_BODY.new("hero") + hero_standee.set_portrait_texture(UiKit.load_tex(UiKit.TEX_HERO_PORTRAIT), true) # B 区立绘 48×48 朝右(2026-09-26 用户点名 hero_idle_01 换装+水平向右;素材缺位 null 回退字形「人」) + world.add_child(hero_standee) + hero_standee.slot_row = _make_slot_companion(_companion_layer) + + +func _make_slot_companion(parent: Control) -> StatusSlotRow: + # 状态效果唯一展示面(2026-09-26 C 区精简批迁入站桩)——compact 档伴随件,tooltip 全明细 + var comp := StatusSlotRow.new() + comp.compact = true + comp.alignment = BoxContainer.ALIGNMENT_CENTER + comp.mouse_filter = Control.MOUSE_FILTER_IGNORE # 行容器放行鼠标事件给槽位(悬停 tooltip) + comp.visible = false # 空态 = 图标行隐藏不占位(增益状态 EC12) + parent.add_child(comp) + return comp + + +# ── 布局同步(世界→壳层单点——C4;站桩无逐帧位移,重建/重排挂点调用,零每帧成本)── + +func world_to_shell(p: Vector2) -> Vector2: + return _svp_container.global_position + p + + +func _on_row_resized() -> void: + _sync_layout.call_deferred() + + +func _notification(what: int) -> void: + if what == NOTIFICATION_RESIZED: + # stretch=true 时视口尺寸由容器接管;树外(冒烟直建)才需手设保 1:1(Phase A 同款) + if not _svp_container.is_inside_tree(): + _viewport.size = _svp_container.size + _sync_layout.call_deferred() + + +func _ready() -> void: + _sync_layout.call_deferred() # 首轮布局落定后对位(row 几何此时才有效) + + +func _sync_layout() -> void: + if not is_inside_tree() or hero_standee == null: + return + var origin: Vector2 = _svp_container.global_position + var row_rect: Rect2 = _row.get_global_rect() + var local := row_rect.position - origin # 行原点 → 世界坐标(1:1,C2) + var box_h := row_rect.size.y # 站桩盒高 = 行高(旧 HBox FILL 拉伸等价) + hero_standee.box = Vector2(_STANDEE_W, box_h) + hero_standee.position = local + # 敌站桩右对齐(旧 HBox ALIGNMENT_END + separation 8 等价) + var n := _cards.size() + var total := n * _STANDEE_W + maxi(0, n - 1) * _STANDEE_GAP + var x := row_rect.size.x - total + for i in n: + var body: Node2D = _cards[i] + if not is_instance_valid(body): + continue + body.box = Vector2(_STANDEE_W, box_h) + body.position = local + Vector2(x + float(i) * (_STANDEE_W + _STANDEE_GAP), 0.0) + _sync_companions() + + +func _sync_companions() -> void: + # 状态行伴随件 = 世界站桩顶部(血条正下方偏移 (8,14)——旧 top VBox 几何)的壳层投影 + var layer_pos: Vector2 = _companion_layer.global_position + if hero_standee != null and hero_standee.slot_row != null: + var comp: StatusSlotRow = hero_standee.slot_row + comp.reset_size() + comp.position = world_to_shell(hero_standee.position + Vector2(8.0 + (_STANDEE_W - 16.0 - comp.size.x) * 0.5, 14.0)) - layer_pos + for i in _cards.size(): + var body: Node2D = _cards[i] + if not is_instance_valid(body) or body.slot_row == null: + continue + var comp2: StatusSlotRow = body.slot_row + comp2.reset_size() + comp2.position = world_to_shell(body.position + Vector2(8.0 + (_STANDEE_W - 16.0 - comp2.size.x) * 0.5, 14.0)) - layer_pos + + +# SubViewport 主题/字体显式注入挂点(C6——Phase A BoardView.set_world_font 同款;站桩本体随动) +func set_world_font(f: Font) -> void: + world.world_font = f + hero_standee.font = f + for c in _cards: + if is_instance_valid(c): + c.font = f + c.queue_redraw() + + +# 敌方站桩本体出口(探针等价改写用——T5;旧 enemy_box 子件 = 站桩 Control 改伴随件后的本体通路) +func enemy_body_at(i: int) -> Node2D: + if i < 0 or i >= _cards.size(): + return null + return _cards[i] + # 敌方站桩投影更新:数量不变 = 就地更新(死亡灰化/受击闪烁等站桩上动画不被逐行刷新重置——禁瞬消纪律); # 数量变化 = 新战斗域,重建站桩槽(轮内不增删,无回流) func set_enemies(cards_data: Array) -> void: if _cards.size() != cards_data.size(): + for body in _cards: + if is_instance_valid(body): + body.queue_free() for c in enemy_box.get_children(): c.queue_free() _cards.clear() for d in cards_data: - var standee := StandeeView.new("enemy") - standee.set_data(d) - enemy_box.add_child(standee) - _cards.append(standee) + var body: Node2D = _STANDEE_BODY.new("enemy") + world.add_child(body) + body.slot_row = _make_slot_companion(enemy_box) + body.set_data(d) + _cards.append(body) + _sync_layout.call_deferred() return for i in cards_data.size(): if is_instance_valid(_cards[i]): @@ -77,6 +229,9 @@ func set_enemies(cards_data: Array) -> void: func clear_enemies() -> void: + for body in _cards: + if is_instance_valid(body): + body.queue_free() for c in enemy_box.get_children(): c.queue_free() _cards.clear() @@ -94,7 +249,7 @@ func on_step_row_fx(row: Dictionary) -> void: var target := String(row.get("target", "")) if target == "hero" or target == "": return - var card: StandeeView = _card_by_name(target) + var card: Node2D = _card_by_name(target) if card == null: return card.flash_hit() @@ -102,25 +257,25 @@ func on_step_row_fx(row: Dictionary) -> void: card.mark_dead() -func _card_by_name(display_name: String) -> StandeeView: +func _card_by_name(display_name: String) -> Node2D: for c in _cards: if not is_instance_valid(c): continue - if String(c._name_label.text).contains(display_name): # 名字行图标化批 2026-09-26:名字 Label 独立节点(_name_line 已改 HBox 容器) + if c.display_name().contains(display_name): return c return null -# 跨区弹道目标坐标(结算演出链批——组合根传给 SettlementFxView 的全局锚点) +# 跨区弹道目标坐标(结算演出链批——组合根传给 SettlementFxView 的全局锚点;壳层口径 C4) func enemy_standee_center(display_name: String): var card := _card_by_name(display_name) if card == null or not is_instance_valid(card): return null - return card.get_global_rect().get_center() + return world_to_shell(card.position + card.box * 0.5) func hero_standee_center() -> Vector2: - return hero_standee.get_global_rect().get_center() + return world_to_shell(hero_standee.position + hero_standee.box * 0.5) # battle_over:全场敌方站桩灰化兜底(短路击杀等未走 killed 行的边角) diff --git a/game/presentation/views/stage_world.gd b/game/presentation/views/stage_world.gd new file mode 100644 index 00000000..173af733 --- /dev/null +++ b/game/presentation/views/stage_world.gd @@ -0,0 +1,21 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 战斗舞台 Node2D 迁移批 Phase B 2026-09-26(循环 1 T2/T3):StageWorld——B 区战斗舞台 Node2D +# 世界根(SubViewport 内站桩本体容器)。ADR-0006 分工:Node2D = D 区棋盘 + B 区战斗舞台; +# 与 BoardWorld(Phase A)同构。职责单点:主题/字体显式注入槽位(SubViewport 不自动继承主场景 +# Control 主题——ADR-0003 Decision 4;set_world_font 同款挂点,null = ThemeDB.fallback_font 与 +# 旧 Label 缺省主题同源)+ 站桩本体装载面(T3 迁入)。无 class_name(类名缓存面纪律—— +# BattleStageView 经 preload 使用)。 +# 坐标空间钉死(台账 160 纪律延伸):世界内 = 局部排布坐标(SubViewport 逻辑分辨率 = +# 壳层像素 1:1,无缩放系数——C2/C5);壳层/fx 层 = 全局含容器偏移,换算单点在 +# BattleStageView.world_to_shell()。 +extends Node2D + +var world_font: Font = null # 字体注入槽位(null = ThemeDB.fallback_font——BattleStageView.set_world_font 注入) + +# C6 headless 可验判据(ADR-0003 Decision 4):注入字体非空 + 中文字形 has_char + 采样文本宽度 > 0。 +# 实际光栅观感归批末目检清单。 +func font_probe() -> Dictionary: + var f: Font = world_font if world_font != null else ThemeDB.fallback_font + var has_char: bool = f.has_char("人".unicode_at(0)) + var width: float = f.get_string_size("兑现演出区", HORIZONTAL_ALIGNMENT_LEFT, -1, 14).x + return {"font": f, "has_char": has_char, "width": width} diff --git a/game/presentation/views/stage_world.gd.uid b/game/presentation/views/stage_world.gd.uid new file mode 100644 index 00000000..23bfac8b --- /dev/null +++ b/game/presentation/views/stage_world.gd.uid @@ -0,0 +1 @@ +uid://dqo6k0y6ncmov diff --git a/game/presentation/views/standee_body.gd b/game/presentation/views/standee_body.gd new file mode 100644 index 00000000..13720af2 --- /dev/null +++ b/game/presentation/views/standee_body.gd @@ -0,0 +1,348 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 实现 GDD:design/ux/battle.md(B 区全站桩形态——2026-09-20 用户看对照样张拍板,台账 99: +# 主角与敌人均为无框立绘+底座,英雄卡/敌人卡卡片式整体退役禁再提)。 +# 战斗舞台 Node2D 迁移批 Phase B 2026-09-26(循环 1 T3):站桩本体自 Control(旧 StandeeView, +# 退役删除)平移为 Node2D 世界精灵(ADR-0006:B 区战斗舞台归 Node2D)。行为面契约逐项保持: +# set_data/set_hero/_set_hp/set_idle/set_effects/set_effects_cleared_feedback/set_order/set_pending/ +# _play_intent_motion/set_portrait_texture(tex,flip)/mark_dead/is_dead/flash_hit/_revive/ +# _update_name_line/_refresh_portrait_glyph(调用点 hud_presenter/main/探针零改造目标——C3)。 +# 等价平移基线 = 旧 Control 占位表现(C9):立绘贴图(flip/NEAREST/整数 2× 档)+ 底座 + 血条 +# (直角递减 tween)+ 顺位徽标 + 意图徽标(弹入+立绘前倾一次性动画)+ 名字行(纯名+护甲类型 +# 图标——2026-09-26 终裁 AC 不展示)+ 受击闪白 + 死亡灰化摇摆塌陷(禁瞬消)+ 复活 + 字形占位 +# 回退(人/▂/▆/█/✕/敌)。批内代拍板默认(待追认):①状态图标行(slot_row)与悬停 tooltip 保留 +# Control 载体挂壳层(tooltip 机制零返工)——本体的 slot_row 成员 = 壳层伴随件引用; +# ②状态行隐藏时序 = 随 mark_dead 同拍隐藏;③清空反馈微文案挂伴随件父层(壳层)。 +# 文字全部经 draw_string + 世界字体(world.world_font 或 ThemeDB.fallback_font——C6 挂点链)。 +# 坐标空间:本节点 position/box = SubViewport 世界内局部排布;壳层换算归 +# BattleStageView.world_to_shell 单点(C4)。 +# 无 class_name(类名缓存面纪律——BattleStageView 经 preload 使用)。 +extends Node2D + +const CHARGE_SKILL_IDS := ["heavy"] +# 护甲类型图标占位——形状+色相双通道(art-bible):轻甲=◇ 灰石中性 / 魔法护甲=◆ 小人蓝 / +# 重甲=▣ 深金(避开 Boss 专属紫赤与妨害紫)。(自旧 StandeeView 平移) +const ARMOR_ICONS := {"light": "◇", "ward": "◆", "heavy": "▣"} + +var box := Vector2(150, 176) # 站桩盒尺寸(旧 Control custom_minimum_size;实际高随壳层 row 布局注入) +var font: Font = null # 世界字体槽位(null = ThemeDB.fallback_font——world.world_font 注入) +var slot_row: StatusSlotRow # 状态效果唯一展示面 = 壳层伴随件(挂 BattleStageView 壳层,本体进世界) + +var _kind := "enemy" +var _portrait_sprite: Sprite2D = null # 资产挂点(null = 未落位,字形在位) +var _portrait_tex: Texture2D = null # 落位纹理引用(set_portrait_texture 装载;字形回退判据) +var _portrait_flip := false +var _order := 1 +var _intent_glyph := "" +var _intent_col := Color.WHITE +var _intent_visible := false +var _intent_scale := 1.0: # 意图弹入动画参数(旧 tween scale 等价) + set(v): + _intent_scale = v + queue_redraw() +var _glyph_rot := 0.0: # 立绘前倾角(旧只转字形 Label——贴图态无此动画,等价平移) + set(v): + _glyph_rot = v + queue_redraw() +var _display_name := "" +var _enemy_id := "" +var _armor := "light" +var _dead := false +var _hp_max := 1.0 +var hp_value := 1.0: # 血条值 0..1(直角递减——tween_property 目标,逐行刷新重启不累积) + set(v): + hp_value = clampf(v, 0.0, 1.0) + queue_redraw() +var _hp_tween: Tween +var _hp_flash_tw: Tween + + +func _init(kind: String = "enemy") -> void: + _kind = kind + + +func _efont() -> Font: + return font if font != null else ThemeDB.fallback_font + + +func display_name() -> String: + return _display_name + + +# 血条存在性探针出口(T5 断言等价改写——旧 _hp_bar ProgressBar 退役,世界直角血条常在) +var has_world_hp_bar := true + + +# 意图徽标探针出口(T5——旧 _intent_badge.visible/text 改判) +func intent_probe() -> Dictionary: + return {"visible": _intent_visible, "text": _intent_glyph} + + +# 名字行探针出口(T5——旧 _name_label/_armor_icon.text 改判) +func name_line_probe() -> Dictionary: + return {"name": _display_name, "armor_icon": String(ARMOR_ICONS.get(_armor, "◇"))} + + +# ---- setter 面(投影契约与旧 StandeeView 一致——presenter/组合根零改排) ---- + +# data = 战斗结算 presentation_snapshot enemies[] 投影:{order, display_name, hp, max_hp, enemy_id, +# pending_skill/pending_type/pending_name, effects[](敌方 side 增益——各立绘同值呈现)} +func set_data(d: Dictionary) -> void: + _display_name = String(d.get("display_name", "敌人")) + _enemy_id = String(d.get("enemy_id", "")) + _armor = String(d.get("armor_type", "light")) + _hp_max = float(maxi(1, int(d.get("max_hp", 1)))) + _set_hp(int(d.get("hp", 0))) + set_order(int(d.get("order", 1))) + set_pending(String(d.get("pending_type", "")), String(d.get("pending_skill", ""))) + set_effects(d.get("effects", [])) + _refresh_portrait_glyph() + if _dead and int(d.get("hp", 0)) > 0: + _revive() + queue_redraw() + + +func set_hero(hp: int, max_hp: int) -> void: + _hp_max = float(maxi(1, max_hp)) + _set_hp(hp) + + +# 血条短 tween 递减(直角递减占位——密度/缓动终稿归战斗表现批);逐行刷新时重启 tween 不累积 +func _set_hp(hp: int) -> void: + var target := float(clampi(hp, 0, int(_hp_max))) / _hp_max + if _hp_tween != null and _hp_tween.is_valid(): + _hp_tween.kill() + _hp_tween = create_tween() + _hp_tween.tween_property(self, "hp_value", target, UiKit.motion(0.15)) + + +func set_idle() -> void: + _set_hp(0) + set_effects([]) + + +# 主角/敌方共用——状态效果唯一展示面(壳层伴随件转发;空态 = 图标行隐藏不占位) +func set_effects(effects: Array) -> void: + if slot_row == null: + return + slot_row.set_effects(effects) + slot_row.visible = not effects.is_empty() + + +# battle_over:状态图标行全清可见反馈(微文案挂伴随件父层 = 壳层) +func set_effects_cleared_feedback() -> void: + if slot_row == null: + return + slot_row.visible = true + slot_row.set_cleared_feedback() + var note := UiKit.label("状态随战斗结束消散", 11, false) + note.add_theme_color_override("font_color", UiKit.STONE_FAINT) + note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + note.mouse_filter = Control.MOUSE_FILTER_IGNORE + var parent := slot_row.get_parent() + if parent != null: + parent.add_child(note) + note.position = slot_row.position + Vector2((slot_row.size.x - 134.0) * 0.5, slot_row.size.y) + var tw := create_tween() + tw.tween_interval(1.2) + tw.tween_property(note, "modulate:a", 0.0, UiKit.motion(0.8)) + tw.tween_callback(note.queue_free) + + +func set_order(i: int) -> void: + # 顺位徽标 = 行主序生成序(固定顺位——敌人系统 顺位标记义务) + _order = i + queue_redraw() + + +# 摇定动作渲染(三档可见性):pending_type = 战斗结算 快照投影(basic/damage/buff/debuff; +# "" = 未摇定/死亡残位)。不显示技能名——类型级图标 + 一次性动作动画。 +func set_pending(ptype: String, pid: String) -> void: + if _dead: + _intent_visible = false + queue_redraw() + return + if ptype == "" or ptype == "basic": + _intent_visible = false # 普通攻击零表示(完全不可预测——§2.4) + queue_redraw() + return + var glyph := "⚔" # 攻击系(damage) + var col: Color = UiKit.FLAG_RED + if ptype == "buff": + glyph = "✦" # 增益系 + col = UiKit.HERO_BLUE + elif ptype == "debuff": + glyph = "☠" # 妨害系(新效应词汇批 2026-09-19) + col = UiKit.DEBUFF_PURPLE + if pid in CHARGE_SKILL_IDS: + col = UiKit.GOLD_DEEP # 重击类蓄力档(2026-09-18 用户拍板独立蓄力提示) + _intent_glyph = glyph + _intent_col = col + _intent_visible = true + _play_intent_motion() + + +# 三档动作动画占位:摇定时一次性短动画(意图图标弹入 + 立绘前倾回正),随后静帧保持 +func _play_intent_motion() -> void: + var tw := create_tween() + tw.tween_property(self, "_intent_scale", 1.6, UiKit.motion(0.10)) + tw.tween_property(self, "_intent_scale", 1.0, UiKit.motion(0.16)) + tw.parallel().tween_property(self, "_glyph_rot", 4.0, UiKit.motion(0.10)) + tw.tween_property(self, "_glyph_rot", 0.0, UiKit.motion(0.14)) + + +# 资产挂点(战斗表现样张批):tex 落位后字形隐退、纹理立绘顶位——零代码换装契约;tex=null 回退字形。 +# flip = 立绘朝向翻转(素材基准朝左时传 true 呈现朝右)。盒高 96px = 48px 立绘源整数 2× +# (圣经 §8.2 禁非整数缩放)——源换代时随源重算。 +func set_portrait_texture(tex: Texture2D, flip: bool = false) -> void: + _portrait_flip = flip + if tex == null: + if _portrait_sprite != null: + _portrait_sprite.visible = false + queue_redraw() + return + _portrait_tex = tex + if _portrait_sprite == null: + _portrait_sprite = Sprite2D.new() + _portrait_sprite.centered = false + _portrait_sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST # 像素帧放大防糊(棋盘小人贴图批 2026-09-25) + add_child(_portrait_sprite) + _portrait_sprite.texture = tex + _portrait_sprite.flip_h = flip + _portrait_sprite.visible = true + _place_portrait() + + +func _place_portrait() -> void: + # 立绘盒 = (0,44)-(box.x, box.y-36)(旧 FULL_RECT offsets 等价);48 源 ×2 整数档居中 + if _portrait_sprite == null or _portrait_tex == null: + return + 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) + + +func mark_dead() -> void: + if _dead: + return + _dead = true + modulate = Color(0.62, 0.62, 0.62, 0.9) + var tw := create_tween() + tw.tween_property(self, "rotation_degrees", 2.5, UiKit.motion(0.12)) + tw.tween_property(self, "rotation_degrees", -1.5, UiKit.motion(0.1)) + tw.tween_property(self, "rotation_degrees", 0.0, UiKit.motion(0.08)) + _refresh_portrait_glyph() + _intent_visible = false # 死亡残位意图退位(旧同款) + _set_hp(0) + if slot_row != null: + slot_row.visible = false # 状态行随 mark_dead 同拍隐藏(批内代拍板②待追认) + + +func is_dead() -> bool: + return _dead + + +# 受击闪白(结算演出占位——§2.2 受击反馈) +func flash_hit() -> void: + if _hp_flash_tw != null and _hp_flash_tw.is_valid(): + _hp_flash_tw.kill() + _hp_flash_tw = create_tween() + _hp_flash_tw.tween_property(self, "modulate", Color(1.6, 1.4, 1.2), UiKit.motion(0.08)) + _hp_flash_tw.tween_property(self, "modulate", Color(1, 1, 1) if not _dead else Color(0.62, 0.62, 0.62, 0.9), UiKit.motion(0.2)) + + +func _revive() -> void: + _dead = false + modulate = Color(1, 1, 1) + rotation_degrees = 0.0 + _refresh_portrait_glyph() + if slot_row != null: + slot_row.visible = true + queue_redraw() + + +func _refresh_portrait_glyph() -> void: + queue_redraw() + + +func _portrait_glyph() -> String: + if _kind == "hero": + return "人" + if _dead: + return "✕" + match _enemy_id: + "goblin": + return "▂" # 小体量墩形 + "bugbear": + return "▆" # 垂直延伸体量 + "ogre_chief": + return "█" # 最大体量 + _: + return "敌" + + +func _portrait_color() -> Color: + return UiKit.HERO_BLUE if _kind == "hero" else UiKit.RED_DEEP + + +# 立绘盒矩形(世界局部):(0,44)-(box.x, box.y-36)——贴图与字形共用 +func _portrait_rect() -> Rect2: + return Rect2(0, 44, box.x, box.y - 80.0) + + +func _draw() -> void: + var f := _efont() + # 底座(旧 panel_flat(STONE.darkened(0.35), STONE.darkened(0.1), 6, 1) 等价——BOTTOM_WIDE 26/-26, -18/-8) + var plat := UiKit.panel_flat(UiKit.STONE.darkened(0.35), UiKit.STONE.darkened(0.1), 6, 1) + plat.draw(get_canvas_item(), Rect2(26, box.y - 18.0, box.x - 52.0, 10.0)) + + # 血条(直角递减):敌附顺位徽标(GOLD_DEEP ①②③)——旧 top VBox 几何等价(左 8 / 右 -8) + var bar_x := 8.0 + if _kind == "enemy": + var order_txt := String("①②③"[clampi(_order - 1, 0, 2)]) + f.draw_string(get_canvas_item(), Vector2(bar_x, 12), order_txt, + HORIZONTAL_ALIGNMENT_LEFT, -1, 13, UiKit.GOLD_DEEP) + bar_x += 20.0 + var bar_w := box.x - 8.0 - bar_x + draw_rect(Rect2(bar_x, 2, bar_w, 10), Color(0.10, 0.10, 0.11)) # 条底(引擎默认主题深底近似平移) + var fill_w := bar_w * hp_value + if fill_w > 0.0: + draw_rect(Rect2(bar_x, 2, fill_w, 10), Color(0.41, 0.61, 0.91)) # 直角填充(默认主题蓝近似——直角递减) + + # 立绘:贴图在位 = Sprite2D(set_portrait_texture 落位);缺位 = 字形回退 + if _portrait_sprite == null or _portrait_sprite.texture == null or not _portrait_sprite.visible: + var prect := _portrait_rect() + var glyph := _portrait_glyph() + var center := prect.get_center() + draw_set_transform(center, deg_to_rad(_glyph_rot), Vector2.ONE) + var ts := f.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, 42) + f.draw_string(get_canvas_item(), center - Vector2(ts.x * 0.5, -ts.y * 0.35), glyph, + HORIZONTAL_ALIGNMENT_LEFT, -1, 42, _portrait_color()) + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + + # 意图徽标(敌 only——立绘上半部居中悬浮,CENTER_TOP 74..98 等价):弹入 scale 动画 + if _intent_visible: + var icenter := Vector2(box.x * 0.5, 86.0) + draw_set_transform(icenter, 0.0, Vector2(_intent_scale, _intent_scale)) + var its := f.get_string_size(_intent_glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, 15) + f.draw_string(get_canvas_item(), icenter - Vector2(its.x * 0.5, -its.y * 0.35), _intent_glyph, + HORIZONTAL_ALIGNMENT_LEFT, -1, 15, _intent_col) + draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + + # 底座下 护甲类型图标+名字 常驻行(敌 only——2026-09-26 用户终裁:AC 数值不展示,类型图标化) + if _kind == "enemy": + var armor_txt := String(ARMOR_ICONS.get(_armor, "◇")) + var col: Color = UiKit.STONE # 轻甲 = 灰石(中性轻薄) + match _armor: + "ward": + col = UiKit.HERO_BLUE # 魔法护甲 = 小人蓝 + "heavy": + col = UiKit.GOLD_DEEP # 重甲 = 深金 + var name_w := f.get_string_size(_display_name, HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x + var armor_w := f.get_string_size(armor_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 12).x + var total_w := armor_w + 3.0 + name_w + var baseline := Vector2((box.x - total_w) * 0.5, box.y - 1.0) + f.draw_string(get_canvas_item(), baseline, armor_txt, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, col) + f.draw_string(get_canvas_item(), baseline + Vector2(armor_w + 3.0, 0), _display_name, + HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiKit.INK_TITLE) diff --git a/game/presentation/views/standee_body.gd.uid b/game/presentation/views/standee_body.gd.uid new file mode 100644 index 00000000..6693b923 --- /dev/null +++ b/game/presentation/views/standee_body.gd.uid @@ -0,0 +1 @@ +uid://dliuugrkyq2ra diff --git a/game/presentation/views/standee_view.gd b/game/presentation/views/standee_view.gd deleted file mode 100644 index 670d6aa8..00000000 --- a/game/presentation/views/standee_view.gd +++ /dev/null @@ -1,324 +0,0 @@ -# VERTICAL SLICE - NOT FOR PRODUCTION -# 实现 GDD:design/ux/battle.md(B 区全站桩形态——2026-09-20 用户看对照样张拍板,台账 99: -# 主角与敌人均为无框立绘+底座,英雄卡/敌人卡卡片式整体退役禁再提)。 -# 形态契约:无框立绘(程序占位字形;set_portrait_texture = 资产挂点,战斗表现样张批落位零代码换装) -# + 底座;血条悬浮头顶;状态效果唯一展示面挂血条正下方(2026-09-26 C 区精简批:compact 档 -# StatusSlotRow 组件整体迁入——定稿 PNG 图标+层数 ×N 角标+悬停 tooltip〔来源+语义+剩余〕, -# tooltip 义务自 C 区状态槽并入;空态 = 图标行隐藏)。 -# 敌方专属:顺位徽标悬浮头顶;意图三档动作动画占位(表现层升级批拍板:不显示技能名——普攻零表示 / -# 攻击系 ⚔ / 增益系 ✦ / 妨害系 ☠ / 蓄力 = ⚔ 金档;开轮摇定播一次性短动画后静帧保持, -# 挖掘期不循环——art-bible §2.1 能量纪律,终稿动画密度归战斗表现批);底座下 护甲类型图标+名字 -# 常驻行(2026-09-26 用户终裁:AC 数值不展示、护甲类型图标化——敌人技能池批克制对策可读性 -# 义务降级承载:类型保留可读、数值移除;HP 信息已足够)。 -# 死亡呈现(敌人系统 资产下限):灰化 + 摇摆塌陷残位,禁瞬消;受击闪白沿用。 -# Date: 2026-09-20(站桩改造批——hero_card_view/enemy_card_view 退役删除) -class_name StandeeView -extends Control - -const CHARGE_SKILL_IDS := ["heavy"] -# 护甲类型图标占位——终稿图标资产归美术批(2026-09-26 用户终裁:AC 数值不展示,类型图标化)。 -# 形状+色相双通道(art-bible):轻甲=◇ 空心菱(轻薄)·灰石中性 / 魔法护甲=◆ 实心晶菱·小人蓝 -# (避开 Boss 专属紫赤 #8F2F6A 与妨害紫——两者各有专属语义)/ 重甲=▣ 实心方板(厚重板甲)·深金。 -const ARMOR_ICONS := {"light": "◇", "ward": "◆", "heavy": "▣"} - -var _kind := "enemy" -var _portrait: Label -var _portrait_tex: TextureRect = null # 资产挂点(null = 未落位,字形在位) -var _platform: Panel -var _hp_bar: ProgressBar -var _order_badge: Label = null -var _intent_badge: Label = null -var _top_row: VBoxContainer -var slot_row: StatusSlotRow # 状态效果唯一展示面(2026-09-26 C 区精简批——compact 档组件整体迁入; - # 悬停 tooltip 来源+语义+剩余 义务随之从 C 区状态槽并入,主角/敌方共用) -var _name_line: HBoxContainer = null -var _armor_icon: Label = null -var _name_label: Label = null -var _display_name := "" -var _enemy_id := "" -var _armor := "light" -var _dead := false -var _hp_tween: Tween - - -func _init(kind: String = "enemy") -> void: - _kind = kind - custom_minimum_size = Vector2(150, 176) - mouse_filter = Control.MOUSE_FILTER_IGNORE - - # 头顶悬浮层:血条(敌附顺位徽标)+ 状态小图标行 - var top := VBoxContainer.new() - _top_row = top - top.set_anchors_preset(Control.PRESET_TOP_WIDE) - top.offset_left = 8.0 - top.offset_right = -8.0 - top.add_theme_constant_override("separation", 2) - add_child(top) - var hp_row := HBoxContainer.new() - hp_row.add_theme_constant_override("separation", 4) - top.add_child(hp_row) - if kind == "enemy": - _order_badge = UiKit.label("①", 13, true) - _order_badge.add_theme_color_override("font_color", UiKit.GOLD_DEEP) - hp_row.add_child(_order_badge) - _hp_bar = ProgressBar.new() - _hp_bar.custom_minimum_size = Vector2(0, 10) - _hp_bar.show_percentage = false - _hp_bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _hp_bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER - hp_row.add_child(_hp_bar) - slot_row = StatusSlotRow.new() - slot_row.compact = true # 站桩迷你档——图标 16px+层数角标,tooltip 全明细 - slot_row.alignment = BoxContainer.ALIGNMENT_CENTER - slot_row.size_flags_horizontal = Control.SIZE_SHRINK_CENTER - slot_row.mouse_filter = Control.MOUSE_FILTER_IGNORE # 行容器放行鼠标事件给槽位(悬停 tooltip) - top.add_child(slot_row) - - # 立绘(程序占位字形) - _portrait = UiKit.label("人" if kind == "hero" else "敌", 42, true) - _portrait.add_theme_color_override("font_color", UiKit.HERO_BLUE if kind == "hero" else UiKit.RED_DEEP) - _portrait.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _portrait.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _portrait.set_anchors_preset(Control.PRESET_FULL_RECT) - _portrait.offset_top = 44.0 - _portrait.offset_bottom = -26.0 - _portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_portrait) - - # 意图指示(敌 only——立绘上半部居中悬浮) - if kind == "enemy": - _intent_badge = UiKit.label("", 15, true) - _intent_badge.set_anchors_preset(Control.PRESET_CENTER_TOP) - _intent_badge.offset_left = -20.0 - _intent_badge.offset_right = 20.0 - _intent_badge.offset_top = 74.0 - _intent_badge.offset_bottom = 98.0 - _intent_badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _intent_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE - _intent_badge.visible = false - add_child(_intent_badge) - - # 底座 - _platform = Panel.new() - _platform.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) - _platform.offset_left = 26.0 - _platform.offset_right = -26.0 - _platform.offset_top = -18.0 - _platform.offset_bottom = -8.0 - _platform.mouse_filter = Control.MOUSE_FILTER_IGNORE - _platform.add_theme_stylebox_override("panel", - UiKit.panel_flat(UiKit.STONE.darkened(0.35), UiKit.STONE.darkened(0.1), 6, 1)) - add_child(_platform) - - # 底座下 护甲类型图标+名字 常驻行(敌 only——2026-09-26 用户终裁:AC 数值不展示,类型图标化) - if kind == "enemy": - _name_line = HBoxContainer.new() - _name_line.alignment = BoxContainer.ALIGNMENT_CENTER - _name_line.add_theme_constant_override("separation", 3) - _name_line.mouse_filter = Control.MOUSE_FILTER_IGNORE - _name_line.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) - _name_line.offset_top = -8.0 - add_child(_name_line) - _armor_icon = UiKit.label("", 12, true) - _armor_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE - _name_line.add_child(_armor_icon) - _name_label = UiKit.label("", 12, false) - _name_label.add_theme_color_override("font_color", UiKit.INK_TITLE) - _name_label.clip_text = true - _name_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - _name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE - _name_line.add_child(_name_label) - - -# ---- setter 面(投影契约与旧敌卡一致——presenter 装配面零改排) ---- - -# data = 战斗结算 presentation_snapshot enemies[] 投影:{order, display_name, hp, max_hp, enemy_id, -# pending_skill/pending_type/pending_name, effects[](敌方 side 增益——各立绘同值呈现)} -func set_data(d: Dictionary) -> void: - _display_name = String(d.get("display_name", "敌人")) - _enemy_id = String(d.get("enemy_id", "")) - _armor = String(d.get("armor_type", "light")) - _hp_bar.max_value = maxi(1, int(d.get("max_hp", 1))) - _set_hp(int(d.get("hp", 0))) - set_order(int(d.get("order", 1))) - set_pending(String(d.get("pending_type", "")), String(d.get("pending_skill", ""))) - set_effects(d.get("effects", [])) - _update_name_line() - _refresh_portrait_glyph() - if _dead and int(d.get("hp", 0)) > 0: - _revive() - - -func set_hero(hp: int, max_hp: int) -> void: - _hp_bar.max_value = maxi(1, max_hp) - _set_hp(hp) - - -# 血条短 tween 递减(§2.2 直角递减占位——密度/缓动终稿归战斗表现批);逐行刷新时重启 tween 不累积 -func _set_hp(hp: int) -> void: - var target := float(clampi(hp, 0, int(_hp_bar.max_value))) - if _hp_tween != null and _hp_tween.is_valid(): - _hp_tween.kill() - _hp_tween = create_tween() - _hp_tween.tween_property(_hp_bar, "value", target, UiKit.motion(0.15)) - - -func set_idle() -> void: - _set_hp(0) - set_effects([]) - - -# 主角/敌方共用——状态效果唯一展示面(2026-09-26 C 区精简批:compact 档 StatusSlotRow 整体迁入, -# 原 B 区迷你字形行退役;悬停 tooltip(来源+语义+剩余结算次数)义务自 C 区状态槽并入本组件; -# 空态 = 图标行隐藏不占位(增益状态 EC12)) -func set_effects(effects: Array) -> void: - slot_row.set_effects(effects) - slot_row.visible = not effects.is_empty() - - -# battle_over:状态图标行全清可见反馈(增益状态×4④ 载体迁移——逐槽化光 + 微文案, -# 自 C 区状态槽迁入;主角侧调用,敌方不适用) -func set_effects_cleared_feedback() -> void: - slot_row.visible = true - slot_row.set_cleared_feedback() - var note := UiKit.label("状态随战斗结束消散", 11, false) - note.add_theme_color_override("font_color", UiKit.STONE_FAINT) - note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - note.mouse_filter = Control.MOUSE_FILTER_IGNORE - _top_row.add_child(note) - var tw := create_tween() - tw.tween_interval(1.2) - tw.tween_property(note, "modulate:a", 0.0, UiKit.motion(0.8)) - tw.tween_callback(note.queue_free) - - -func set_order(i: int) -> void: - # 顺位徽标 = 行主序生成序(固定顺位——敌人系统 顺位标记义务) - if _order_badge != null: - _order_badge.text = String("①②③"[clampi(i - 1, 0, 2)]) - - -# 摇定动作渲染(敌人技能池批 三档可见性;渲染映射唯一定义处归本类——code-review 2026-09-12 先例): -# pending_type = 战斗结算 快照投影(basic/damage/buff/debuff;"" = 未摇定/死亡残位)。 -# 表现层升级批拍板:不显示技能名——类型级图标 + 一次性动作动画(_play_intent_motion)。 -# 信号/入参契约:set_data 每结算行刷新、set_pending 每轮开轮刷新(开轮摇号时点)。 -func set_pending(ptype: String, pid: String) -> void: - if _intent_badge == null or _dead: - return - if ptype == "" or ptype == "basic": - _intent_badge.visible = false # 普通攻击零表示(完全不可预测——§2.4) - return - var glyph := "⚔" # 攻击系(damage) - var col: Color = UiKit.FLAG_RED - if ptype == "buff": - glyph = "✦" # 增益系 - col = UiKit.HERO_BLUE - elif ptype == "debuff": - glyph = "☠" # 妨害系(新效应词汇批 2026-09-19) - col = UiKit.DEBUFF_PURPLE - if pid in CHARGE_SKILL_IDS: - col = UiKit.GOLD_DEEP # 重击类蓄力档(2026-09-18 用户拍板独立蓄力提示) - _intent_badge.text = glyph - _intent_badge.add_theme_color_override("font_color", col) - _intent_badge.visible = true - _play_intent_motion() - - -# 三档动作动画占位:摇定时一次性短动画(意图图标弹入 + 立绘前倾回正),随后静帧保持—— -# 挖掘期唯一在动元素 = 可挖域微光(art-bible §2.1);终稿动画密度归战斗表现样张批 -func _play_intent_motion() -> void: - _intent_badge.pivot_offset = _intent_badge.size * 0.5 - _portrait.pivot_offset = _portrait.size * 0.5 - var tw := create_tween() - tw.tween_property(_intent_badge, "scale", Vector2(1.6, 1.6), UiKit.motion(0.10)) - tw.tween_property(_intent_badge, "scale", Vector2.ONE, UiKit.motion(0.16)) - tw.parallel().tween_property(_portrait, "rotation_degrees", 4.0, UiKit.motion(0.10)) - tw.tween_property(_portrait, "rotation_degrees", 0.0, UiKit.motion(0.14)) - - -# 资产挂点(战斗表现样张批):tex 落位后字形隐退、纹理立绘顶位——零代码换装契约;tex=null 回退字形。 -# flip = 立绘朝向翻转(素材基准朝左时传 true 呈现朝右——2026-09-26 用户点名英雄立绘水平向右)。 -# 盒高 96px = 48px 立绘源整数 2×(圣经 §8.2 禁非整数缩放;offset_top 44 = 血条+状态行之下净空)—— -# 源换代时随源重算盒高。 -func set_portrait_texture(tex: Texture2D, flip: bool = false) -> void: - if _portrait_tex == null: - _portrait_tex = TextureRect.new() - _portrait_tex.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _portrait_tex.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - _portrait_tex.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST # 像素帧放大防糊(棋盘小人贴图批 2026-09-25) - _portrait_tex.set_anchors_preset(Control.PRESET_FULL_RECT) - _portrait_tex.offset_top = 44.0 - _portrait_tex.offset_bottom = -36.0 # 盒高 96 = 48 源 ×2 整数档(原 −26 盒高 106 系非整数缩放) - _portrait_tex.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(_portrait_tex) - _portrait_tex.texture = tex - _portrait_tex.flip_h = flip - _portrait_tex.visible = tex != null - _portrait.visible = tex == null - - -func mark_dead() -> void: - if _dead: - return - _dead = true - modulate = Color(0.62, 0.62, 0.62, 0.9) - var tw := create_tween() - tw.tween_property(self, "rotation_degrees", 2.5, UiKit.motion(0.12)) - tw.tween_property(self, "rotation_degrees", -1.5, UiKit.motion(0.1)) - tw.tween_property(self, "rotation_degrees", 0.0, UiKit.motion(0.08)) - _refresh_portrait_glyph() - if _intent_badge != null: - _intent_badge.visible = false - _set_hp(0) - - -func is_dead() -> bool: - return _dead - - -# 受击闪白(结算演出占位——§2.2 受击反馈) -func flash_hit() -> void: - var tw := create_tween() - tw.tween_property(self, "modulate", Color(1.6, 1.4, 1.2), UiKit.motion(0.08)) - tw.tween_property(self, "modulate", Color(1, 1, 1) if not _dead else Color(0.62, 0.62, 0.62, 0.9), UiKit.motion(0.2)) - - -func _revive() -> void: - _dead = false - modulate = Color(1, 1, 1) - rotation_degrees = 0.0 - _refresh_portrait_glyph() - _update_name_line() - - -func _update_name_line() -> void: - if _name_line == null: - return - _name_label.text = _display_name - var col: Color = UiKit.STONE # 轻甲 = 灰石(中性轻薄) - match _armor: - "ward": - col = UiKit.HERO_BLUE # 魔法护甲 = 小人蓝(避开 Boss 专属紫赤/妨害紫) - "heavy": - col = UiKit.GOLD_DEEP # 重甲 = 深金(厚重达意金) - _armor_icon.text = String(ARMOR_ICONS.get(_armor, "◇")) - _armor_icon.add_theme_color_override("font_color", col) - - -func _refresh_portrait_glyph() -> void: - if _kind == "hero": - _portrait.text = "人" - return - if _dead: - _portrait.text = "✕" - return - match _enemy_id: - "goblin": - _portrait.text = "▂" # 小体量墩形 - "bugbear": - _portrait.text = "▆" # 垂直延伸体量 - "ogre_chief": - _portrait.text = "█" # 最大体量 - _: - _portrait.text = "敌" diff --git a/game/presentation/views/standee_view.gd.uid b/game/presentation/views/standee_view.gd.uid deleted file mode 100644 index 4e14b0bc..00000000 --- a/game/presentation/views/standee_view.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://gfvj2sxds3ph diff --git a/game/smoke/ui_probe.gd b/game/smoke/ui_probe.gd index 25d8b0c8..7bb08c04 100644 --- a/game/smoke/ui_probe.gd +++ b/game/smoke/ui_probe.gd @@ -110,21 +110,26 @@ func _ready() -> void: problems.append("B 区明细带挖掘期应收起 24px(mode=%s h=%s)" % [main.log_view.mode(), str(main.log_view.custom_minimum_size.y)]) # E 区道具面板已随可选类删除下线(格面资源批 2026-09-15)——四区构建由其余断言覆盖 # ---- 敌方站桩投影(顺位徽标/意图三档动作动画占位——敌人系统×4;2026-09-20 全站桩拍板)---- - var standee = main.stage.enemy_box.get_child(0) - if standee != null and (standee._name_label.text == "" or standee._armor_icon.text == ""): - problems.append("敌立绘名字行缺失(名字或护甲类型图标占位为空——2026-09-26 终裁:AC 不展示、类型图标化)") - if standee != null and standee._intent_badge == null: - problems.append("敌立绘意图指示缺失") - elif standee != null: + # B 区站桩 Node2D 迁移批 T3(2026-09-26)断言等价改写(台账 171 改判记录):enemy_box 子件 + # 伴随件化(壳层 tooltip 载体),穿透明(子件 _name_label/_armor_icon/_intent_badge)改本体 + # 探针出口 stage.enemy_body_at(i).name_line_probe()/intent_probe()——呈现口径逐字保持 + var standee = main.stage.enemy_body_at(0) + if standee == null: + problems.append("敌立绘本体缺失(enemy_body_at(0) 无世界站桩)") + else: + var name_line: Dictionary = standee.name_line_probe() + if String(name_line.name) == "" or String(name_line.armor_icon) == "": + problems.append("敌立绘名字行缺失(名字或护甲类型图标占位为空——2026-09-26 终裁:AC 不展示、类型图标化)") # 三档动作动画占位契约(表现层升级批拍板:不显示技能名——图标档位 + 一次性动画) standee.set_pending("damage", "") - if not standee._intent_badge.visible or standee._intent_badge.text != "⚔": - problems.append("攻击系意图未呈现(visible=%s text=%s)" % [str(standee._intent_badge.visible), standee._intent_badge.text]) + var intent_on: Dictionary = standee.intent_probe() + if not bool(intent_on.visible) or String(intent_on.text) != "⚔": + problems.append("攻击系意图未呈现(visible=%s text=%s)" % [str(intent_on.visible), str(intent_on.text)]) standee.set_pending("basic", "") - if standee._intent_badge.visible: + if bool(standee.intent_probe().visible): problems.append("普攻应零表示(意图指示应隐藏)") - if main.stage.hero_standee == null or main.stage.hero_standee._hp_bar == null: - problems.append("B 区主角站桩缺失") + if main.stage.hero_standee == null or not main.stage.hero_standee.has_world_hp_bar: + problems.append("B 区主角站桩缺失(世界血条面缺失)") # ---- BUG-0001 回归(tooltip 入树后 set_effects 槽位不错位——结算软锁修复沉淀)---- var slots_row = main.stage.hero_standee.slot_row # BUG-0001 回归随组件迁入站桩(tooltip/槽位错位同源) var probe_eff: Array = [{"effect_id": "attack_up", "display_name": "增伤", "layers": 1, diff --git a/production/alpha-acceptance-battle-screen.md b/production/alpha-acceptance-battle-screen.md new file mode 100644 index 00000000..1becff8d --- /dev/null +++ b/production/alpha-acceptance-battle-screen.md @@ -0,0 +1,81 @@ +# Alpha 验收清单 · 战斗屏(初稿) + +> 状态:**初稿成文于循环 1(2026-09-26,战斗舞台 Node2D 迁移批 T0)**。本文件 = Alpha 验收批(三段式程序第三段)的销项底单;循环 1 只成文不销项,随批更新状态列。 +> 来源拼装(六源):① `design/ux/battle.md` AC 与 §13 上游义务对照表 ② `design/gdd/systems/表现与支撑/HUD框架.md` 验收标准与基线表 ③ `design/art/art-bible.md` 美术判据(§1.4/§2/§4.5/§8)④ `design/gdd/07-体验验证清单.md` §⑨ 真人试玩指引 ⑤ `design/gdd/06-待办清单.md` 挂 Alpha 条目 ⑥ 工程回归面(冒烟/五探针/文档三闸/MC)。 +> 验法分档:**[headless]** = 场景探针或脚本可自动判定;**[真机渲染]** = 开窗口跑游戏读日志/截图测量(draw calls、灰度、帧率类,agent 可窗口化自动测大半);**[人]** = 用户目检或真人试玩,agent 只备料。 +> 数值带(ap6-ac 阶段二压力验收)**不在本清单**——归调参批(2026-09-26 裁决⑦)。 + +## A. 布局与性能(源② + 源①) + +| # | 判据 | 验法 | 状态 | +|---|---|---|---| +| A1 | 三区无遮挡、无重叠、无内容裁切(棋盘全部格子与数字完整可见);分区尺寸与 battle.md 布局表一致 | [headless] ui_layout_probe + [人] 双分辨率目检 | 待验收 | +| A2 | 1280×800 与 1920×1080 两档均成立(1920 档换算值表现层升级批成文后此项才可销) | [人] 全屏目检两档 | 待验收(1920 档参数未成文) | +| A3 | 同屏 draw calls < 200 | [真机渲染] RenderingServer 调试读数(迁移批 T7 附首读数) | 待测(基线读数见循环 1 T7 交付) | +| A4 | 进入战斗屏 500ms 内首格可点 | [headless] ui_click_probe 首格可点计时 AC | 待验收 | +| A5 | B 区战斗舞台 = Node2D 舞台半区 + Control 文本半区(ADR-0006 分工表);站桩载体 Button/GridContainer/HBox 计数 = 0 | [headless] 场景树 dump 取证探针(迁移批 T5① 已实证 PASS——探针按台账 152 先例跑完即删,复跑件归呈现层测试分级批周期化时重建) | **已实证(循环 1 T5,2026-09-26)——周期化复跑件待建** | + +## B. 可读性与色彩(源②基线表 + 源③) + +| # | 判据 | 验法 | 状态 | +|---|---|---|---| +| B1 | 全部数字对格底对比 ≥4.5:1 | [真机渲染] 截图采样明度计算 | 待验收 | +| B2 | 已开/未开格态灰度截图下可逐格分类;变异格纹带灰度可辨 | [真机渲染] 灰度转换截图 + 逐格分类脚本 | 待验收 | +| B3 | 无信息仅靠色相承载(色弱安全,art-bible §4.5 清单) | [真机渲染] 灰度截图复核 + [人] 抽检 | 待验收 | +| B4 | 字号下限:棋盘数字 ≥18px、明细正文 ≥14px、tooltip ≥13px(1280×800 基座) | [headless] 布局探针读值 | 待验收 | +| B5 | 中文字体随包内置并设默认,运行时中文渲染无缺字 | [headless] 注入字体非空 + has_char + 宽度 >0(C6 判据)+ [人] 目检光栅 | 待验收(当前=引擎回退字体,CJK 随包未落) | +| B6 | 按钮文案 ≤4 汉字宽度;译西文不破版 | [人] 目检 | 待验收(本地化专项前置) | + +## C. 呈现义务(源① AC + 源② 验收标准) + +| # | 判据 | 验法 | 状态 | +|---|---|---|---| +| C1 | 悬停光标三语义(移动/挖/旗)随目标切换并高亮目标格;悬停已开格画合法路径;可挖域常驻呼吸微光 | [headless] ui_input_probe case①②③ | 待验收 | +| C2 | 一帧内意图整批到达,停手点击终结当帧收集;每次拒绝可感知(棋盘/资源/相位三面,前两类可区分;双通道反馈) | [headless] ui_probe 拒绝面断言 + [人] 目检 | 待验收 | +| C3 | 敌人站桩呈现:意图占位(普攻零表示/特殊技类型级提示,不预告名与数值)且挖掘期全程可见、映射跨局恒定、顺位可读、血量可读、敌方共享层数各立绘同值 | [headless] ui_probe 站桩断言 + [人] 目检 | 待验收 | +| C4 | 死亡灰化加碎裂退场(禁瞬消)且就地更新不丢灰化态 | [headless] 探针灰化态断言 + [人] 目检节奏 | 待验收 | +| C5 | 受击闪白、血条直角递减、结算弹道落点贴站桩中心 | [headless] 等价探针 + [人] 目检 | 待验收 | +| C6 | 状态槽:图标+层数+剩余结算次数,悬停见来源+语义+剩余;空态整排隐藏;战斗结束全清有微文案 | [headless] ui_probe BUG-0001 回归段 + ui_click_probe tooltip 段 + [人] 目检 | 待验收 | +| C7 | 结算逐行实时滚出带来源标注;未生效类行聚合计数带形状冗余;零值行不吞;高光三档不打空兑现 | [headless] ui_probe 结算段 + [人] 目检 | 待验收 | +| C8 | 终局:胜利金蜡封、失败暮蓝氛围层(明度不变);两反馈不合并、时序不倒挂 | [headless] 探针 + [人] 目检 | 待验收 | +| C9 | 任一模态打开时遮挡物隐藏或输入锁定、关闭逐层恢复;tooltip/明细行固定槽高不推挤布局 | [headless] ui_click_probe 回看段 | 待验收 | +| C10 | 终局呈现只消费流水与快照(零生命周期回读);重放渲染与实时渲染一致;载荷逐字段断言 | [headless] MC/冒烟既有断言 | 待验收 | + +## D. 可访问性与输入(源②基线表) + +| # | 判据 | 验法 | 状态 | +|---|---|---|---| +| D1 | 无每秒超三次闪烁 | [真机渲染] 帧序列采样 | 待验收 | +| D2 | 减动效开关:时长减半或跳终态(结算兑现可加速不可跳过) | [headless] 开关态探针 + [人] 目检 | 待验收(开关本身未立项,先挂起) | +| D3 | 全程纯鼠标可玩 | [headless] ui_click_probe 全链 | 待验收 | +| D4 | 切片临时名零残留;呈现层零随机调用 | [headless] static_scan + grep | 待验收 | + +## E. 真人试玩面(源④ 07§⑨ + 源① OQ) + +| # | 判据 | 验法 | 状态 | +|---|---|---|---| +| E1 | 照 07§⑨ 指引真人试玩 3–5 局(零天赋基线档),局中体感对号 ①–⑧ 观测项,局后一句话记录 | [人] 用户一次真人试玩(裁决⑦) | 待验收 | +| E2 | 样张 v1 终审(视觉语言冻结为换皮批输入) | [人] 用户看图 | 循环 1 异步触点,不阻塞 | +| E3 | 表现遗留收口复核(循环 2 交付后):敌人立绘/接触带微光/封印特效/原型件退役/C 区美化 | [人] 目检 + [headless] 回归闸 | 待循环 2 | + +## F. 工程回归面(源⑥,每批常态闸) + +| # | 判据 | 验法 | 状态 | +|---|---|---|---| +| F1 | run_smoke 全绿(当轮基线零失败) | [headless] `python tools/run_smoke.py` | 循环 1 基线 557/0 绿,迁移批 T0–T5 每轮复跑全绿(2026-09-26) | +| F2 | 五探针全绿:ui_probe / ui_click_probe(SEED=7) / ui_layout_probe / ui_tune_probe / ui_input_probe 十一断言 | [headless] 场景探针 | 循环 1 基线全绿;迁移批每轮复跑全绿(ui_probe 敌站桩断言已随批等价改写为本体探针出口——台账 171) | +| F3 | 文档三闸对基线零新增 | [headless] doc_link_audit / doc_term_audit / gdd_lint 对基线快照 diff | 循环 1 基线已存档 | +| F4 | MC 12 局 RUNSUM 与基线逐位一致(核心层零漂移证据) | [headless] adventure_monte_carlo | 循环 1 基线采集于 T0(24 行 RUNSUM 存档),T7 对拍 | + +## G. 源⑤ 06 挂 Alpha 条目涉战斗屏者(销项时逐条对表) + +- Alpha:全怪物技能池数值验证与合理性设计(归难度曲线与 Boss 主责,非本清单销项——录以备查)。 +- 雷格与安全格的资源分布差异化(Alpha 批,结构修订)——非战斗屏呈现面,录以备查。 +- 表现层测试分级与 UI 驱动夹具周期化(06 台账 160-164 批登记)——夹具周期化落地时本清单 A5 取证件转正式回归件。 + +## 修订记录 + +| 日期 | 改动 | +|---|---| +| 2026-09-26 | 初稿成文(循环 1 T0,六源拼装;销项归 Alpha 验收批) | +| 2026-09-26 | T5/T6 状态列更新(A5 已实证/ F1、F2 迁移批全绿注记/F4 待 T7 对拍;B 区站桩断言改判注记) | diff --git a/production/mockups/sample-batch-v1-2026-09-26.png b/production/mockups/sample-batch-v1-2026-09-26.png new file mode 100644 index 00000000..3aaead5e Binary files /dev/null and b/production/mockups/sample-batch-v1-2026-09-26.png differ diff --git a/production/mockups/sample-v1-机检报告-2026-09-26.md b/production/mockups/sample-v1-机检报告-2026-09-26.md new file mode 100644 index 00000000..d805c174 --- /dev/null +++ b/production/mockups/sample-v1-机检报告-2026-09-26.md @@ -0,0 +1,20 @@ +# 样张 v1 机检报告(2026-09-26) + +生成:`python tools/render_sample_sheet.py`;断言事实源 = 画法参数四源对照表-2026-09-26.md §6。 + +| 断言 | 判据 | 结果 | 明细 | +|---|---|---|---| +| F1 | 方案甲 8 邻拼装(词表查表,禁整块) | PASS | 错位 0 处;用件 15 种 ['center', 'center_crack', 'center_speckle', 'end_n', 'end_s', 'end_w', 'hrun', 'in_nw', 'in_sw', 'n', 'nw', 's', 'sw', 'vrun', 'w'] | +| F2 | 旗仅出现于未开格 | PASS | 旗位 (1, 2) 格态=W | +| F3 | 小人脚锚=自底边 25% 线(两态) | PASS | 脚 y=[213.75, 258.75] 期望=[213.75, 258.75] | +| F4 | 数字 0 格无字形(0=纯安全格裸地面) | PASS | 0 态格 40 个(示例 (4,4));数字表含 0 键=False | +| F5a | 接触带微光⊆英雄格周界(空间侧,不进墙格) | PASS | 微光 bbox=(86, 176, 119, 230) 英雄格=(90, 180, 135, 225) | +| F5b | 类型微光禁整格染色(中心 50% 区零染色像素) | PASS | 五格中心区全净 | +| A1a | 色板对表圣经 §4.1 冻结值 | PASS | {'INK': '#33261B', '青墨': '#132E36', '赭墨': '#3C250C', '锈墨': '#4A1E14', 'PLATE': '#F5EAD3', '旗朱': '#C63D22', '危险红': '#A8321F', '奖励金': '#F0A828'} | +| A1b | 禁入带 HSV300°-345° 零命中 | PASS | 命中 0 像素(抽样) | +| A2 | 拼缝周界闭合(墙侧 2px 墨线 <70) | PASS | 全部墙地边界墨线在位 | +| A3 | 墨×牌 WCAG≥4.5:1 + 图底分离 | PASS | [('青墨', 12.0), ('赭墨', 12.0), ('锈墨', 11.8)];墙顶 113 vs 地面 51 | +| A5 | 画布四缘 2px 净空(零溢出) | PASS | 缘带内容像素 0 | +| A4 | 混排相融(游戏在库资产+UiKit 语义色) | PASS | 小人源 32×32(裁透明留白后 (19, 20))/立绘 48×48/状态图标 32×32 原件;INK/旗朱/奖励金 == ui_kit.gd | + +结论:**全绿**(12/12 PASS)。气质终审 = 用户(异步,不阻塞 T2-T7)。 diff --git a/production/mockups/画法参数四源对照表-2026-09-26.md b/production/mockups/画法参数四源对照表-2026-09-26.md new file mode 100644 index 00000000..d6aa2340 --- /dev/null +++ b/production/mockups/画法参数四源对照表-2026-09-26.md @@ -0,0 +1,74 @@ +# 画法参数四源对照表(样张 v1 · T1 对表先行,2026-09-26) + +> 任务书:`.zcode/plans/battle-stage-node2d-loop-plan-2026-09-26.md` T1①。四源 = A battle.md 布局线框 / B 机制代码(格态·旗语义·脚锚)/ C 引擎常量 / D art-bible 画法章节。**一处对不上 = L3**。本表逐参数登记取值与出处;样张 v1 脚本(tools/render_sample_sheet.py)只准消费本表参数。 + +## 1. 屏面布局(源 A:battle.md §Layout + §2 现状) + +| 参数 | 取值 | 出处 | +|---|---|---| +| 基座分辨率 | 1280×800 | art-bible §8.1 两档定案 | +| B 区 | 全宽 × 252px:主角站桩(左)+ 兑现演出区(中)+ 敌方站桩 ×1–3(右);顶部中央轮次·阶段指示;底部结算明细带(挖掘期收起 24px) | battle.md §Layout / battle_stage_view.gd `_init`(custom_minimum_size 0×252) | +| C 区 | 左下 240px × 548px:攻击数据框(攻击值+修正来源角标+护甲 AC)+ 魔法道具槽(预留 4)+ 本关强化列表(预留) | battle.md §Layout(2026-09-26 信息归属批后口径)/ hero_info_panel_view | +| D 区 | 右侧余幅(~1036px):顶行 AP 点阵+RAP+弃图刷新+图例+停手;下为棋盘 | battle.md §Layout / ap_bar_view | +| 站桩盒 | 150×176:血条(10px)+状态行(16px)头顶悬浮;立绘盒 96px;底座底条(10px);敌名行(护甲图标+纯名字,12px);顺位徽标①②③ | standee_view.gd `_init` 几何 | +| B 区立绘 | 主角 = 48×48 朝右贴图(96px 盒 = 48×2 整数档);敌人 = 字形占位(▂地精/▆熊地精/█食人魔酋长——素材零张在库) | battle_stage_view.gd:38 / standee_view.gd `_refresh_portrait_glyph` / §2 现状「敌人立绘素材一张都不存在」 | + +## 2. 格态与语义(源 B:机制代码) + +| 参数 | 取值 | 出处 | +|---|---|---| +| 格态 | 未开(墙)/ 已开(地面)两态;雷 = 已开态巢穴纹更凶一档 | board_view.gd paint 分支 / art-bible §3.2 地砖条 | +| 数字 0 格 | **无字形**(`if d > 0: glyph = str(d)`——0 = 纯安全格裸地面) | board_view.gd:583-586(FATAL④ 事实源) | +| 旗语义 | 旗只插**未开格**(旗分支在 `else` 未开侧);旗 = 封印印记呈现名;旗面恒画右 | board_view.gd:589-590 / art-bible §3.2 旗条(FATAL② 事实源) | +| 悬停金框 | 预承诺光标态画在目标格(逐格指认=悬停金框) | art-bible §3.2 接触带条尾句 / board_view `_apply_hover_visuals` | +| 小人同格 | 小人与数字同格:数字色牌不废、蓝框不覆盖文本 | art-bible §1.4 小人两态条 | + +## 3. 引擎常量(源 C:board_view.gd / standee_view.gd / ui_kit.gd) + +| 参数 | 取值 | 出处 | +|---|---|---| +| 格边长(1280 基座) | 45px(10×10 板)/ 36px(12×12 板);样张 D 区按 45px 实比 | art-bible §8.1 / board_view clamp(26,46) | +| 小人脚锚 | **自底边往上 25% 线**(HERO_GROUND_FRAC=0.25;⚠ 纵向比例一律自底边口径) | board_view.gd HERO_GROUND_FRAC(FATAL③ 事实源) | +| 小人档 | 32×32 源 ×1.5 = 48px(台账 161 用户实机拍板档;45px×32 源非整数 NEAREST 已接受——台账 166 同级) | board_view.gd HERO_SPRITE_SCALE | +| 椭圆影 | 贴脚(中心=脚锚),32×12px @1×,黑 35% | board_view.gd HERO_SHADOW_PX / 台账 162 | +| 立绘缩放 | B 区 96px 盒 = 48 源 ×2 整数(C5) | standee_view.gd set_portrait_texture | +| 变异遮蔽 | hide_number 数字格显示 `?`(样张不含变异格——六类型纹带另批) | board_view.gd:584 | +| 游戏 UiKit 语义色 | INK #33261B / FLAG_RED #C63D22 / GOLD #F0A828 / RED_DEEP #963C30 / 数字=INK | ui_kit.gd 常量(A4 混排对表基准) | + +## 4. 画法章节(源 D:art-bible) + +| 参数 | 取值 | 出处 | +|---|---|---| +| 未开格 | **方案甲高墙瓦片 8 邻拼装**:16 唯一件+运行时 flip_h 镜像(禁旋转);词表=board-tile-vocab-plan-a;板外视为墙;center 变体 crack/speckle 60/20/20 哈希分配 | §3.2 高墙瓦片体系条 / design/research/board-tile-vocab-plan-a-2026-09-26.md(FATAL① 事实源) | +| 墨线纪律 | 墙/地分界 1px 深墨线贴格边(缘件自带,实测朝地边亮度 ~22-36 vs 本体 ~122);南缘立面窄暗条;北缘内侧 1px 提亮 | §3.2 墨线纪律 / v9.3 实测 | +| 图底分离 | 已开地面明度 < 未开墙顶(实测 Δ≈75,0-255 简易亮度) | §8.5 图底分离条 | +| 已开格 | 地面瓦片 5 变体哈希;产资源格 = 家族物件纹样(道具点剪影)角嵌砖缘+**砖心留白予数字色牌**;纯安全格 = 干净裸地面 | §3.2 结果整格表达 / §1.2 原则三 | +| 数字色牌 | #F5EAD3 素色直角小牌,恒定不随格底;数字墨三档:1-3 青墨 #132E36 / 4-5 赭墨 #3C250C / 6-8 锈墨 #4A1E14;对牌对比 ≥4.5:1(实测 11.8-12.3:1);字号下限 ≥18px | §4.1 / HUD框架 基线表 | +| 语义色 | 掩尘 #C9AF83 / 裸土 #B39A78 / 旗朱 #C63D22 / 危险红 #A8321F / 奖励金 #F0A828 | §4.1 冻结 | +| 类型角嵌 | 未开格四角/边框墨族单色纹样(九家族键 §4.6.3),砖心留白;形状主通道 | §3.2 类型角嵌条 | +| 类型微光 | 五组候选:通用增益 #D6C13A / 治疗 #5CB83A / 穿刺 #2E9E9E / 魔法 #7B5BD6 / 群伤 #FF2D55;底效泛光层不入遮挡栈;**禁整格染色**(v1 画法=内缘带状泛光,机检断言中心 50% 区零微光像素);不以明度增量承载信息 | §4.2 类型微光色带 / §1.4 验收 2(FATAL⑤ 事实源之一) | +| 接触带微光 | 英雄格周界**空间侧**:正交邻墙=整条边、斜向=共享角点+两侧各半条边;蓝族浅一档;光不进墙格 | §3.2 可挖域接触带微光条 / §2.1(FATAL⑤ 事实源之二) | +| 禁入带 | HSV 色相 300°–345° 全场禁入(微光群伤组 349° 刻意避开) | §4.2 紫赤条 | +| 换皮 | §4.3 四槽换色(tile_closed/tile_open/wall_ambient/variant_tint),信息层恒定;样张 ≥两套(dungeon+underdark) | §4.3 / §1.4 内容清单 | +| 小人占位 | 独立资产 32×32;样张 48px ≈ 0.75× 格边符合 §8.2 占位上限 | §8.2 画布基准 | + +## 5. 候选值与偏差登记(非矛盾,样张批冻结项) + +1. **小人蓝**:art-bible §4.1 = #2E86DE(候选,样张批冻结终稿);游戏 UiKit = #2E6E8E。两值并存系圣经明标「候选值」——样张以 #2E86DE 出图;冻结后游戏侧换色归循环 2,本批不改代码。 +2. **接触带微光色值**:空间侧+蓝族已定稿(v2.8),具体色值样张批冻结——v1 取小人蓝族浅两档 #D6ECFF(近白蓝)出样。 +3. **格底 L≥0.30 的适用面**:§4.1/§4.3 系换皮四槽**色槽参数域**(新图类型色板须守);dungeon 定稿瓦片(v9.3 用户验收入本体)为素材实态,本样张不再以 L≥0.30 复裁(防对已验收资产误报 FATAL),改以图底分离+可读性硬指标承载。 +4. **巢穴纹/封印印记**:素材缺口,程序化占位画面内标注(§1.4 允许——「素材缺口见清单」v0 先例);正式纹样归绘画批。 + +## 6. 机检断言映射(v0 五画错 + 程序化四件 → 生成脚本自检) + +| 断言 | 判据 | +|---|---| +| F1 方案甲拼装 | 每墙瓦片位 = 词表查表(四邻+对角)结果;禁整块(center 之外的件 ≥6 种实出);变体 60/20/20 | +| F2 旗∈未开格 | 旗位集 ⊆ 墙位集 | +| F3 脚锚自底 25% | 两小人态脚 y = 格底 − 0.25×45;影心=脚点 | +| F4 0 格无字形 | 数字 0 格 glyph 记录 = 空 | +| F5 微光带限 | 接触带微光像素 ⊆ 英雄格∩带几何(≤8px 距离);类型微光中心 50% 区零像素 | +| A1 色板+禁入带 | 冻结色逐字节对表 §4.1;微光五组对表 §4.2;全图像素扫 HSV 300°-345°(S>0.15,V>0.20)零命中 | +| A2 周界闭合 | 每段墙↔地边界墙侧 2px 带均亮度 <70(墨线在位);墙群外轮廓无缺口 | +| A3 明度对比 | 三档墨×色牌 WCAG ≥4.5:1;地面均亮 < 墙顶均亮 −40 | +| A4 混排相融 | 语义色 == 游戏 UiKit 常量(小人蓝候选偏差见 §5.1);小人/立绘/状态图标 = 游戏在库资产原件 | diff --git a/production/qa/battle-stage-node2d-等价证明-2026-09-26.md b/production/qa/battle-stage-node2d-等价证明-2026-09-26.md new file mode 100644 index 00000000..70cf1806 --- /dev/null +++ b/production/qa/battle-stage-node2d-等价证明-2026-09-26.md @@ -0,0 +1,36 @@ +# 战斗舞台 Node2D 迁移 · 信号与演出等价证明(循环 1 T4,2026-09-26) + +> 计划书 T4 验收件:时序常量逐字对照旧实现 + 写路径逐行同构(Phase A 先例成文化)+ 调用点 grep 对比零 diff。 +> 对比基线 = T0 提交 `3db7e87`(迁移前现行态);迁移后 = worktree battle-stage-node2d-20260926 T3 提交。 + +## 1. 调用点 grep 对比(零 diff 实证) + +| 面 | 命令 | 结果 | +|---|---|---| +| main.gd → stage 调用点(9 处:set_effects_cleared_feedback ×2 / gray_all_enemies ×2 / on_step_row_fx / enemy_standee_center / hero_standee_center / set_settle_focus / set_round_text) | `grep -E "stage\.(hero_standee\|enemy_standee_center\|on_step_row_fx\|gray_all_enemies\|set_round_text\|set_settle_focus\|clear_enemies)"` 老新对拍 | **逐字节一致**(行号 309/310/439/441/475/495/503/768/770 全同) | +| hud_presenter.gd → hero_standee 投喂(4 处:set_hero ×2 / set_effects ×2) | 同法对拍 | **逐字节一致**(行号 53/54/79/80 全同——单投喂结构零变) | +| hud_presenter.gd / main.gd / core/** 文件级 | `git diff 3db7e87..HEAD --stat` | **零行变更**(投喂链零改动实证;核心层零接触) | + +## 2. 时序常量逐字对照(旧 StandeeView → 新 StandeeBody) + +| 演出 | 旧(Control + tween) | 新(Node2D + tween) | 判定 | +|---|---|---|---| +| 血条递减 | `motion(0.15)` tween bar value | `motion(0.15)` tween hp_value 属性 | 同 | +| 意图弹入 | scale 1.6→ONE,`motion(0.10)`/`motion(0.16)` | `_intent_scale` 1.6→1.0 同时长 | 同 | +| 立绘前倾 | rotation 4.0→0.0,`motion(0.10)`/`motion(0.14)` | `_glyph_rot` 同值同时长(旧仅转字形 Label——贴图态无动画,新等价保持) | 同 | +| 死亡摇摆 | modulate (0.62,0.62,0.62,0.9);rotation 2.5→−1.5→0.0,`motion(0.12)`/`(0.1)`/`(0.08)` | 逐字同 | 同 | +| 受击闪白 | modulate (1.6,1.4,1.2) `motion(0.08)` → 白/死灰 `motion(0.2)` | 逐字同(终色按 _dead 分支同构) | 同 | +| 复活复位 | modulate 白、rotation 0 | 同 | 同 | +| 清空反馈微文案 | 1.2s 停留 + `motion(0.8)` 淡出 | 同(载体挂壳层伴随件父层——T3 代拍板③) | 同 | + +## 3. 写路径逐行同构(旧 battle_stage_view.gd → 新) + +- `on_step_row_fx(row)`:type!=""damage" 早退 / target=="hero"|""早退 / `_card_by_name(target)` 空退 / `flash_hit()` + killed→`mark_dead()`——逐行同构;`_card_by_name` 内部实现由「子件 `_name_label.text` contains」改「本体 `display_name()` contains」(语义等价,函数内部实现细节=§9 白名单)。 +- `set_enemies(cards_data)`:数量分叉(重建 vs 就地 set_data)结构逐行同构;重建侧由 StandeeView.new 改 StandeeBody.new+伴随件挂接(T3 代拍板①④)。 +- `clear_enemies()` / `gray_all_enemies()` / `set_round_text()` / `set_settle_focus()`:逐行同构(遍历对象 Control→本体)。 +- 弹道落点:`hero_standee_center()`/`enemy_standee_center()` 签名不变,实现 = 旧「本体 get_global_rect 中心」改 `world_to_shell(本体.position + box*0.5)`——**壳层口径保持(C4:结算演出 SettlementFxView 在 SubViewport 之外)**,返回值语义=站桩盒几何中心不变。 +- `set_effects/set_effects_cleared_feedback/set_hero`(投喂面):本体转发伴随件(slot_row),调用方零改造。 + +## 4. 已登记的结构性差异(非漂移,改判记录见台账 170/171) + +1. 站桩本体载体 Control→Node2D(本批目标本身);2. slot_row 伴随件挂壳层;3. enemy_box HBoxContainer→Control 伴随件容器;4. 血条呈现由引擎默认 ProgressBar 改世界直角矩形(色值近似平移——目检清单列「血条观感」项);5. ui_probe 敌站桩断言改本体探针出口(呈现口径逐字保持)。 diff --git a/production/session-state/active.md b/production/session-state/active.md index b19e5949..8065eb4b 100644 --- a/production/session-state/active.md +++ b/production/session-state/active.md @@ -454,3 +454,11 @@ 164. **§8.2 改规则登记+测试分级登记(2026-09-26,本会话——用户两裁示;纯文档批零代码)**:①art-bible §8.2 缩放纪律走**改规则不豁免**(用户裁)——06 新条目登记修订方向:按资产类别分级(甲类文本/语义件=数字/图标/旗/tile 维持严格整数硬规则;乙类角色/单位精灵允许非整数+NEAREST、实机目检判据)——豁免蔓延断根、规则与实践重归一致;**修订窗口=art-bible 并行在飞批落籍后**(本批未碰 art-bible.md);连带发现:§8.2 内 1920 档 1.5× 全局缩放待决项与分级同构,样张批一并裁。②**呈现层测试分级**入 06(用户裁「重测试不进每次变更、统一周期」)——每次变更必测(轻闸 headless)=run_smoke+ui_input_probe 十一断言(触 presentation 加 click/layout 探针);周期内测(重闸)=B 档视口截图像素断言/C 档黄金图(硬前置=样张批视觉冻结)/真人目检清单/MC 大扫描;2026-09-20「UI 驱动夹具」欠账正式立项销号。验证:06 编辑零冲突(文件干净);台账 163 断言批已并入现行探针。 164. **高墙瓦片 v9.3 定稿交付+备选案清场批(2026-09-26,本会话——用户验收 v9.3 后指令「直接移入游戏本体+备选案打包挪走」;零代码、素材不入 git,worktree 不适用)**:**定稿交付**=21 枚入 `game/assets/art/board/`(16 唯一墙瓦片〔center/center_crack/center_speckle/n/s/w/nw/sw/hrun/vrun/end_n/end_s/end_w/lone/in_nw/in_sw〕+5 地面瓦片变体,`terrain_*_dungeon.png`;东/NE/SE/端头E/凹角NE/SE=运行时 flip_h 镜像不出文件,旧 beast-cave 砖零触碰)。**终版路线**=材质归模型(用户 Gemini 生图①岩石/④泥土→周期对齐无缝裁剪+BOX 降采样 32×32+环绕混合+量化 12 色)+结构归程序(v9 墨线纪律:全开缘 1px 深墨线直线零抖动/北缘内侧 1px 就地提亮/南缘立面窄暗条 11px/凹角角点+立面侧 1px 齐平收头——v9.1 悬空/v9.2 突出/v9.3 齐平三轮定位,前后对比+逐行像素审计存 `_work/corner_cmp_v91_v93_5x.png`)。**术语共识(用户立)**:未开格=墙瓦片,已开格=地面瓦片。**备选案清场**=v1–v9.2 全迭代+被否素材(含 v7 程序砌石/v3 生图 5×5 表)打包 `备选案-terrain-walls-v1-v9_2-2026-09-26.zip`(383 项 7.5MB,用户挪出库),`_work/` 仅留生图①④源图+风格参考+终版证据+定稿件。**复盘机制落记忆**(pixel-structure-verify-discipline):先抄后造/验收口径写像素级视觉判据/±1px 一律坐标推导/修复默认附前后对比。**遗留**:underdark/deep_underdark 换色(AI 纹理色调映射)+实机查表合成器(BoardWorld 接线,含镜像翻转)归后续批;任务书验收单逐条结果以本批 v9.3 终测为准(图底分离 67.6/无死黑/墨线周界闭合/覆盖层可读)。 + +168. **战斗舞台 Node2D 循环 1 · T0 开工批(2026-09-26,本会话——计划书 .zcode/plans/battle-stage-node2d-loop-plan-2026-09-26.md 已批准从 T0 自治开跑;worktree battle-stage-node2d-20260926)**:worktree 自 master 70d2320 建+editor --quit 刷缓存 0 error;git status 归属核对=主区脏文件(.gitignore/.zcode config/ui-reuse 指引/playnote/project.godot/loop-dashboard/active.md 165-167 未提交并行登记+untracked 美术文档组)均非本批只标旗不碰。**基线复读全绿记档**——冒烟 557/0+五探针 PASS(ui_probe cells=144 board=12x12/ui_click 问题=0/ui_tune 全链/ui_layout dump:stage 1280×252·board_view 1036×965·info_panel 240×1028/ui_input 十一断言)+static_scan exit0+文档三闸基线存档(死链 7/退役词命中/gdd_lint 8 文件 8 不通过 69 项级)+MC 12 局 both(max 路由) 24 行 RUNSUM sha256=de8225b3 存 /tmp 供 T7 逐位对拍。**§2 现状事实逐条 grep 复核零漂移**——battle_stage_view(PanelContainer/252px/五成员/setter 面)+standee_view(Control/行为面 17 方法/slot_row compact/字形占位)+hud_presenter 53-54/79-80 单投喂+main 309/441/503+ui_assembler 23+ui_probe 95-129/ui_click 223 穿透面+ui_input_probe 零 B 区耦合(grep stage/standee/enemy=0 命中)+TEX_HERO_PORTRAIT 素材 worktree 缺位=C7 回退字形路径预期。**Alpha 清单初稿落盘** production/alpha-acceptance-battle-screen.md——六源拼装(battle.md AC+§13 义务表/HUD框架 验收标准+基线表/art-bible §8 判据/07§⑨ 试玩指引/06 挂 Alpha 条目备查/工程回归面),七节 30 条=判据+验法三档(headless/真机渲染/人)+状态列,销项归 Alpha 验收批。轻闸全绿,进入 T1。 +169. **样张 v1 制作与机检批(2026-09-26,本会话——循环 1 T1;worktree battle-stage-node2d-20260926 批次本提交)**:①**四源对照表先行** production/mockups/画法参数四源对照表-2026-09-26.md(battle.md 布局/机制代码〔0 格无字形 board_view:586·旗只插未开 :589·脚锚自底 25%〕/引擎常量〔45px·1.5× 档·96px 盒〕/art-bible §1.2-§4.6 画法)——候选值偏差两笔登记(小人蓝 #2E86DE 候选 vs UiKit #2E6E8E=样张批冻结项非矛盾;格底 L≥0.30 系换皮槽参数域不复裁已验收 dungeon 瓦片)。②**脚本重写** tools/render_sample_sheet.py v1:方案甲词表查表拼装(16 情形+对角回落 nw>ne>sw>se+flip 镜像+变体 60/20/20 哈希)+三区全内容 1280×800 实比 mockup+S2 数字档 0.5× 抽检+S3 underdark 四槽换色+S4 图例;素材两级回退(worktree→主检出,素材不入 git)。③**机检 12 断言生成即验全绿**——F1 词表拼装零错位 15 种用件/F2 旗∈未开/F3 脚锚自底 25%(两态)〔预筛抓出素材底部 7 行全透明→裁除留白后锚定,视觉脚=锚线;**游戏内 BoardWorld.ensure_hero 同病(视觉脚浮锚线 ~10px)=呈现缺陷登记移交循环 2/接线批**〕/F4 0 格无字形/F5a 接触带微光⊆英雄格/F5b 类型微光禁整格染色(中心 50% 区零像素——角嵌臂长两轮收紧 L9→L5 留白砖心)/A1 色板对表+禁入带 300-345° 零命中(腐蚀两轮滤 AA 混色)/A2 周界闭合(墙侧 2px 带墨线存在性 min<55——45px 非整数缩放下均值判误报改最暗值判)/A3 墨×牌 11.8-12.0:1+图底分离 113 vs 51/A4 混排在库原件/A5 画布四缘净空(溢出永久断言)。④**visual-judge 预筛三轮**(≤3 预算内):R1 四 FATAL→triage=站桩横向重叠/小人脚浮(素材留白)/同格态色牌被小人盖(改 §1.2 栈序数字>小人)/缘件裸拼误报(1px 墨线逐像素 zoom 自证 end_n 三向墨线俱在+15 种用件机检反证,判官 3-6px 采样带均值漏 1px 线)+地精无徽标误报(普攻零表示=设计);R2 两 FATAL=画布右缘溢出×2(说明行+S4 末项)→画布 1560+A5 断言化;R3 窄域复核 pass。⑤**交付**=样张 v1+机检报告+对照表三件已拷主区 production/mockups/(评审产物不入 git 同 v0 先例),**用户异步终审**(过=冻结为循环 2 输入;不过=对表改参重出);终审不阻塞 T2-T7。轻闸全绿(当轮基线=T0 记档),进入 T2。 +170. **B 区战斗舞台壳改造批(Phase B T2,2026-09-26,本会话——循环 1;worktree battle-stage-node2d-20260926)**:BattleStageView 根壳 PanelContainer→**Control**(类名/252px/树内位置不变;面板样式=Panel 子面+原 stylebox 等价平移,子面几何沿原内容边距 Rect2(6,4,6,4));内部拆两半区——**Node2D 舞台半区**=SubViewportContainer(stretch=true 1:1 禁缩放系数)+SubViewport(transparent_bg)+**StageWorld**(新件 presentation/views/stage_world.gd,无 class_name 经 preload 接线,BoardWorld 同构:world_font 字体注入槽位+font_probe() C6 判据出口〔注入非空+has_char+宽度>0〕;T2 期世界空置待 T3 站桩迁入)+**Control 文本半区**=round_label/stage_center/log_view 三件原样保留(C1 永久 Control),hero_standee/enemy_box 本期仍 Control 原位。新挂点=set_world_font(f)(Phase A BoardView 同款,无调用方纯挂点)+world_to_shell(p) 坐标换算单点(C4——世界=局部排布/壳层=全局含容器偏移)。**C6 判据实证**(临时脚本跑完即删):未注入=回退字体 has_char=false(引擎默认无 CJK=文档既有口径);注入 fusion-pixel ja TTF(内存 load_dynamic_font 零素材入工程——C7)后 has_char=true 宽度 70>0 PASS。**验收**=轻闸全绿:editor --quit 0 error+冒烟 557/0(=基线)+五探针全绿+ui_layout_probe 外框逐位零漂移(stage pos=(0,0) size=(1280,252) min=(0,252)=T0 基线同值;B 区内部树结构变化记录:PanelContainer 自动布局→Control 锚点+VBox 同边距)+static_scan exit0+文档三闸零漂移。进入 T3。 +171. **站桩本体 Node2D 化批(Phase B T3,2026-09-26,本会话——循环 1 最重卡;worktree battle-stage-node2d-20260926)**:站桩本体自 Control(旧 StandeeView **退役删除**)平移为 Node2D 世界精灵 **standee_body.gd**(新件,无 class_name 经 preload)。**行为面 17 方法契约逐项保持(C3)**——set_data/set_hero/_set_hp/set_idle/set_effects/set_effects_cleared_feedback/set_order/set_pending/_play_intent_motion/set_portrait_texture(tex,flip)/mark_dead/is_dead/flash_hit/_revive/_update_name_line/_refresh_portrait_glyph+is_dead,调用点 hud_presenter:53-54/79-80、main:309/441/475/495/503、main:768/770 零改排。**等价平移清单(C9)**——立绘=set_portrait_texture 惰性 Sprite2D(NEAREST+flip_h+整数 2×=48 源盒 96px)缺位回退字形(人/▂/▆/█/✕/敌 draw_string);底座=UiKit.panel_flat(STONE 系) StyleBox.draw 直绘;血条=直角矩形 hp_value 属性 tween 0.15 递减(引擎默认主题深底/蓝填充近似平移——色值列目检清单);顺位徽标 ①②③ GOLD_DEEP;意图徽标=draw_set_transform 弹入 scale 1.6→1+字形前倾 4°→0(旧只转字形 Label——贴图态无动画等价保持);名字行=护甲图标+纯名 draw_string 居中(AC 不展示 2026-09-26 终裁);受击闪白 modulate 1.6/1.4/1.2→白/死灰;死亡=灰化+rotation 2.5/-1.5/0 摇摆塌陷+意图退位(禁瞬消)+**状态行随 mark_dead 同拍隐藏(代拍板②待追认)**;复活=全态重置。**批内代拍板默认(待追认)**——①slot_row+悬停 tooltip 保留 Control 载体挂壳层(_companion_layer 顶层,敌伴随件宿主=**enemy_box 语义改写「站桩壳层伴随件容器」HBoxContainer→Control**,手动定位随世界同步,探针 :97 计数面不变);②定位同步=world_to_shell 单点(T2 建挂),_sync_layout 挂点=ready/NOTIFICATION_RESIZED/_row.resized(明细带展收改行高随动)/set_enemies 重建,站桩盒高=行高(旧 HBox FILL 拉伸等价)、敌右对齐 +8 间距等价;③hero_standee/enemy 站桩成员类型 StandeeView→Node2D(StandeeBody),**hero_standee_center/enemy_standee_center 改 world_to_shell(本体中心)=壳层口径(C4)**。ui_probe 敌站桩断言等价改写(台账 170 前例):穿透明(enemy_box 子件 _name_label/_armor_icon/_intent_badge/_hp_bar)→本体出口 enemy_body_at(i)+name_line_probe()/intent_probe()/has_world_hp_bar——呈现口径逐字保持。**踩坑记录**——standee_body 漏声明 _portrait_tex 成员→编译连坐 main.gd 假性两错(w/h 推断失败),editor --quit 前置闸暴露后修复。**验证**——editor --quit 0 error+冒烟 557/0(=基线零增删)+五探针全绿(ui_probe/ui_click 问题=0/ui_tune/ui_input 十一断言/layout 外框零漂移 stage 1280×252 逐位=T0 基线)+static_scan exit0+文档三闸零漂移。进入 T4。 +172. **信号与演出等价证明批(Phase B T4,2026-09-26,本会话——循环 1;worktree battle-stage-node2d-20260926,零代码变更纯取证)**:等价证明落盘 `production/qa/battle-stage-node2d-等价证明-2026-09-26.md`——①**调用点 grep 对比零 diff**:main.gd→stage 九处调用(309/310/439/441/475/495/503/768/770)与 hud_presenter→hero_standee 四处投喂(53/54/79/80)逐字节一致;hud_presenter/main/core/** 文件级 git diff 基线=零行变更(投喂链零改动实证+核心层零接触)。②**时序常量逐字对照**——血条递减 0.15/意图弹入 0.10+0.16/前倾 0.10+0.14/死亡摇摆 0.12+0.1+0.08 与 modulate (0.62,0.62,0.62,0.9)/受击闪白 0.08+0.2 与 (1.6,1.4,1.2)/复活复位/清空反馈 1.2+0.8 全部逐字同(前倾项注记:旧仅转字形 Label、贴图态无动画——新等价保持)。③**写路径逐行同构**——on_step_row_fx(早退链+flash+killed→mark_dead)/set_enemies 数量分叉/clear_enemies/gray_all_enemies/set_round_text/set_settle_focus 逐行同构;弹道落点 hero/enemy_standee_center 签名不变、实现改 world_to_shell(本体中心)=壳层口径保持(C4)。④结构性差异五笔登记(本体载体/slot_row 壳层化/enemy_box 语义/血条直角矩形呈现/ui_probe 本体探针出口)=非漂移改判,指向台账 170/171。轻闸全绿(冒烟 557/0+ui_probe+ui_input 十一断言+static_scan exit0)。进入 T5。 +173. **探针取证与断言同步批(Phase B T5,2026-09-26,本会话——循环 1;worktree battle-stage-node2d-20260926,零常驻代码变更)**:①**临时取证探针**(跑完即删,台账 152 先例;踩坑复录=裸 .gd 无 tscn 载体跑场景探针=静默挂死,补 tscn 包装解):真实战斗态 B 区场景树 dump——**世界子树(SubViewport 内)Button=0/GridContainer=0/HBox=0**(站桩载体归零证);世界站桩本体 hero=true+enemies=1(StandeeBody)、伴随件容器子件=1 与本体数一致;壳层 HBox 逐件语义标注=文本行载体+StatusSlotRow 伴随件+log 内部(零站桩载体残留)。②ui_probe 敌站桩断言等价改写已于 T3 随批落地(enemy_box 子件穿透明→stage.enemy_body_at(i).name_line_probe()/intent_probe()/has_world_hp_bar 本体出口,呈现口径逐字保持——台账 171 改判记录);ui_click_probe :223 hero_standee.slot_row 伴随件引用=零改动即兼容(tooltip 机制零返工——代拍板①实证)。③ui_input_probe 十一断言原样全绿(B 区改造零波及 D 区实证——grep stage/standee/enemy 零命中在案)。取证报告本条即档案。轻闸全绿(冒烟 557/0+五探针 PASS)。进入 T6。 +174. **文档随批与登记批(Phase B T6,2026-09-26,本会话——循环 1)**:①architecture.md——Last Updated 行+D1 层表形态行补 BattleStageView 例外(StageWorld/StandeeBody,slot_row 伴随件留壳层,Phase A/B 双注记引 ADR-0006)。②HUD框架.md——v0.10 渲染技术注记行(Phase B 同构改造/契约不变/伴随件/C6 复用/预算不变/等价证明外链)。③battle.md——2026-09-26 实现注记修订行(B 区 Node2D 化/setter 面保持/弹道锚点壳层口径/外框 252px 零改动)。④board-node2d-migration-plan.md §3——「Phase B 预留」改写为「已落地」(实施面三点+挂点复用)+**样张硬前置条款按裁决①作废落文**(样张冻结只卡换皮/演出批=循环 2 前置,不卡结构迁移)。⑤Alpha 清单状态列更新(A5 已实证转周期化复跑件待建/F1F2 全绿注记/F4 待 T7 对拍+修订记录行)。⑥06 待办核对=本批无解决条目(呈现层测试分级条 B/C 档与本批无涉;样张硬前置非 06 登记项)。验证=文档三闸对 T0 基线逐字节零新增(lint/link/term 三 diff 空)+轻闸随批全绿。进入 T7。 \ No newline at end of file diff --git a/tools/render_sample_sheet.py b/tools/render_sample_sheet.py new file mode 100644 index 00000000..26ba5e44 --- /dev/null +++ b/tools/render_sample_sheet.py @@ -0,0 +1,852 @@ +# -*- coding: utf-8 -*- +"""战斗一屏样张验收图生成脚本 v1(2026-09-26,循环 1 T1)。 + +v0 四 FATAL 案底(2026-09-26 用户否决)全部断言化——生成即验,不过闸不出图: + F1 未开格 = 方案甲缘件 8 邻拼装(词表查表,禁整块 center 铺满) + F2 旗仅出现于未开格 + F3 小人脚锚 = 自底边 25% 线(⚠ 纵向比例一律自底边口径) + F4 数字 0 格无字形(纯安全格 = 干净裸地面) + F5 微光仅带状:接触带微光只画英雄格周界空间侧;类型微光禁整格染色(中心 50% 区零像素) +程序化四件审计(§9 初级验收授权): + A1 色板对表圣经冻结色板 + 禁入带(HSV 300°-345°)全图扫描 + A2 拼缝周界闭合(墙↔地边界墙侧 2px 墨线带亮度 <70) + A3 明度对比(数字墨×色牌 WCAG ≥4.5:1;图底分离) + A4 与现有素材混排相融(语义色 == 游戏 UiKit 常量;小人/立绘/状态图标 = 在库资产原件) + +一屏样张 = B/C/D 三区全内容(1280×800 1:1 实比) + 附录 S2 数字全档 0-8 / S3 underdark +换皮对照 / S4 图例。内容清单 = art-bible §1.4(九组,缺项即无效)。 +素材源 = game/assets/art(board 21 瓦片 + character 小人/立绘)+ ui/icons 状态图标; +素材不入 git——先查本仓库,缺位回退主检出(D:/code/g-plan2)。 +词表事实源 = design/research/board-tile-vocab-plan-a-2026-09-26.md;画法参数事实源 = +production/mockups/画法参数四源对照表-2026-09-26.md(四源对表先行产物)。 +输出 = production/mockups/sample-batch-v1-2026-09-26.png + sample-v1-机检报告-2026-09-26.md。 +用法:python tools/render_sample_sheet.py +""" +from PIL import Image, ImageDraw, ImageFilter, ImageChops, ImageFont +import os +import sys +import math +import hashlib +from collections import deque + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MAIN_ROOT = "D:/code/g-plan2" + + +def asset_root(*parts): + """素材路径两级回退:worktree → 主检出(素材不入 git,fresh worktree 缺位)。""" + for base in (ROOT, MAIN_ROOT): + p = os.path.join(base, *parts) + if os.path.exists(p): + return p + raise FileNotFoundError(os.path.join(*parts)) + + +BOARD_DIR = ("game", "assets", "art", "board") +CHAR_DIR = ("game", "assets", "art", "character") +ICON_DIR = ("game", "assets", "ui", "icons") +OUT_PNG = os.path.join(ROOT, "production", "mockups", "sample-batch-v1-2026-09-26.png") +OUT_REPORT = os.path.join(ROOT, "production", "mockups", "sample-v1-机检报告-2026-09-26.md") +T_FONT = asset_root("design", "art", "candidates", "ui-reuse-2026-09-23", + "4-字体", "fusion-pixel-12px(OFL)", "fusion-pixel-12px-proportional-latin.ttf") +ZH_FONT = r"C:\Windows\Fonts\msyh.ttc" + +# ══ 冻结色板(art-bible §4.1——A1 逐字节对表基准,禁改动) ══ +INK = (51, 38, 27) # 主墨 #33261B +INK_COLD = (19, 46, 54) # 青墨 1-3 #132E36 +INK_OCHRE = (60, 37, 12) # 赭墨 4-5 #3C250C +INK_RUST = (74, 30, 20) # 锈墨 6-8 #4A1E14 +PLATE = (245, 234, 211) # 数字色牌 #F5EAD3 +FLAG_RED = (198, 61, 34) # 旗朱 #C63D22 +DANGER_RED = (168, 50, 31) # 危险红 #A8321F +REWARD_GOLD = (240, 168, 40) # 奖励金 #F0A828 +HERO_BLUE = (46, 134, 222) # 小人蓝 #2E86DE(候选,样张批冻结——对照表 §5.1) +RED_MINE = (150, 44, 36) # 引爆雷字朱(游戏 UiKit.RED_MINE) +# 候选(样张批冻结项——对照表 §5) +CONTACT_GLOW = (214, 236, 255) # 接触带微光:小人蓝族浅两档(近白蓝) +GLOW_GROUPS = { # §4.2 类型微光五组候选 + "attack": (214, 193, 58), "heal": (92, 184, 58), "pierce": (46, 158, 158), + "magic": (123, 91, 214), "blast": (255, 45, 85), "gp": (214, 193, 58), +} +GLOW_FAMILY = {"attack": "通用增益", "defense": "通用增益", "weaken": "通用增益", + "action": "通用增益", "gp": "通用增益", "heal": "治疗", + "pierce": "穿刺", "magic": "魔法", "blast": "群伤"} +# 游戏 UiKit 常量(A4 混排对表——ui_kit.gd 实测值) +UIKIT = {"INK": INK, "FLAG_RED": FLAG_RED, "GOLD": REWARD_GOLD, "RED_MINE": RED_MINE, + "PANEL_STAGE": (216, 200, 172), "PANEL_HERO": (232, 220, 196), + "PANEL_DATA": (226, 210, 182), "PANEL_BORDER": (120, 106, 88), + "BRICK": (201, 175, 131), "FLOOR": (179, 154, 120)} +ARMOR_ICONS = {"light": "◇", "ward": "◆", "heavy": "▣"} +ARMOR_COLORS = {"light": (120, 110, 98), "ward": HERO_BLUE, "heavy": (176, 122, 20)} + +F_DIGIT = ImageFont.truetype(T_FONT, 30) # S2 64px 格数字 +F_DIGIT_D = ImageFont.truetype(T_FONT, 18) # D 区 45px 格数字(下限 ≥18px) +F_SMALL = ImageFont.truetype(T_FONT, 16) +F_LBL = ImageFont.truetype(ZH_FONT, 14) +F_LBL2 = ImageFont.truetype(ZH_FONT, 12) +F_TITLE = ImageFont.truetype(ZH_FONT, 19) +F_STANDEE = ImageFont.truetype(ZH_FONT, 42) # 敌像字形占位 +F_NAME = ImageFont.truetype(ZH_FONT, 12) +F_ORDER = ImageFont.truetype(ZH_FONT, 13) +F_INTENT = ImageFont.truetype(ZH_FONT, 15) + +CHECKS = [] # (id, name, ok, detail) + + +def check(cid, name, ok, detail=""): + CHECKS.append((cid, name, bool(ok), detail)) + print("[%s] %s — %s%s" % ("PASS" if ok else "FAIL", cid, name, + (";" + detail) if detail else "")) + + +# ══ 素材装载 ══ +def load_tile(name): + return Image.open(asset_root(*(BOARD_DIR + (name,)))).convert("RGB") + + +WALLS = {} +for k in ["center", "center_crack", "center_speckle", "n", "s", "w", "nw", "sw", + "hrun", "vrun", "end_n", "end_s", "end_w", "lone", "in_nw", "in_sw"]: + WALLS[k] = load_tile("terrain_wall_%s_dungeon.png" % k) +FLOORS = [load_tile("terrain_floor_dungeon_%d.png" % i) for i in range(1, 6)] +HERO_SRC = Image.open(asset_root(*(CHAR_DIR + ("character_hero_idle.png",)))).convert("RGBA") +PORTRAIT_SRC = Image.open(asset_root(*(CHAR_DIR + ("character_hero_portrait.png",)))).convert("RGBA") +STATUS_ICONS = {k: Image.open(asset_root(*(ICON_DIR + ("status_%s.png" % k,)))).convert("RGBA") + for k in ["attack_up", "ac_up", "alert"]} + + +def hero_sprite_transparent(src): + """素材黑幕去底:四角泛洪把连通近黑背景转透明(角色内部深色像素不受影响)。""" + im = src.copy() + w, h = im.size + px = im.load() + seen = [[False] * w for _ in range(h)] + q = deque() + for sx, sy in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]: + q.append((sx, sy)) + while q: + x, y = q.popleft() + if x < 0 or y < 0 or x >= w or y >= h or seen[y][x]: + continue + seen[y][x] = True + r, g, b, a = px[x, y] + if r < 14 and g < 14 and b < 14: + px[x, y] = (r, g, b, 0) + q.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]) + return im + + +HERO = hero_sprite_transparent(HERO_SRC) +# 裁除透明留白(v1 预筛发现:素材底部 7 行全透明——直接缩放则视觉脚浮于锚线上方 ~10px。 +# 裁后按内容 bbox 缩放,视觉脚底 = 脚锚线。游戏内 BoardWorld.ensure_hero 用原素材同病 +# (视觉脚浮 ~10px)——呈现缺陷登记移交,本样张按设计规则出图) +_HB = HERO.getbbox() +HERO = HERO.crop(_HB) + + +# ══ 方案甲词表查表(board-tile-vocab-plan-a §三 逐条转写) ══ +def is_wall(gmap, x, y): + if x < 0 or y < 0 or x >= gmap.w or y >= gmap.h: + return True # 板外视为墙(词表 §三.1) + return gmap.grid[y][x] == "W" + + +def lookup_wall_tile(gmap, x, y): + """四正交邻 → 16 情形;四邻皆墙查对角(凹角回落优先级 nw>ne>sw>se);center 变体哈希 60/20/20。""" + n, e, s, w = (is_wall(gmap, x, y - 1), is_wall(gmap, x + 1, y), + is_wall(gmap, x, y + 1), is_wall(gmap, x - 1, y)) + flip = False + if n and e and s and w: + dnw, dne, dsw, dse = ((not is_wall(gmap, x - 1, y - 1)), (not is_wall(gmap, x + 1, y - 1)), + (not is_wall(gmap, x - 1, y + 1)), (not is_wall(gmap, x + 1, y + 1))) + if dnw: + return "in_nw", False + if dne: + return "in_nw", True # in_ne ← in_nw 镜像 + if dsw: + return "in_sw", False + if dse: + return "in_sw", True # in_se ← in_sw 镜像 + h = int(hashlib.md5(("wall%d_%d" % (x, y)).encode()).hexdigest(), 16) % 100 + if h < 60: + return "center", False + if h < 80: + return "center_crack", False + return "center_speckle", False + if not n and s and e and w: + return "n", False + if not s and n and e and w: + return "s", False + if not w and n and e and s: + return "w", False + if not e and n and s and w: + return "w", True # wall_e ← wall_w 镜像 + if not n and not s and e and w: + return "hrun", False + if not w and not e and n and s: + return "vrun", False + if not n and not w and s and e: + return "nw", False + if not n and not e and s and w: + return "nw", True # wall_ne ← wall_nw 镜像 + if not s and not w and n and e: + return "sw", False + if not s and not e and n and w: + return "sw", True # wall_se ← wall_sw 镜像 + if not n and not e and not w and s: + return "end_n", False + if not s and not e and not w and n: + return "end_s", False + if not w and not n and not s and e: + return "end_w", False + if not e and not n and not s and w: + return "end_w", True # wall_end_e ← wall_end_w 镜像 + return "lone", False + + +class GMap(object): + def __init__(self, rows): + self.grid = rows + self.w = len(rows[0]) + self.h = len(rows) + + +def floor_variant(x, y): + """地面 5 变体按格坐标哈希(词表 §三.3——初始化随机落定)。""" + return int(hashlib.md5(("floor%d_%d" % (x, y)).encode()).hexdigest(), 16) % len(FLOORS) + + +# ══ D 区 10×10 样张板(内容布置见对照表 §6/§1.4 内容清单) ══ +BOARD_ROWS = [ + "WWWWWWWWWW", + "WWWWWWWWWW", + "WWFFWWFFWF", + "WWFFFFWFWF", + "WWFFFFWFWF", + "WWFFFFWFWF", + "WFFFFFFWFF", + "FFFFFFWWFF", + "FFWWWWWWFF", + "FFFFFWWWFF", +] +# 覆盖层布置(格坐标) +CRACK_AT = (3, 0) # center 裂纹变体 +SPECKLE_AT = (6, 0) # center 碎石斑变体 +FLAG_AT = (1, 2) # 封印印记——未开格(F2) +HOVER_AT = (1, 4) # 悬停金框(预承诺光标态) +HERO_AT = (2, 4) # 小人独立占格 +HERO_DIGIT_AT = (2, 5) # 小人与数字同格(6)——验蓝框不覆盖文本 +NEST_AT = (5, 4) # 雷格巢穴(已开,程序占位+危险红) +GLOW_CELLS = [(4, 2, "attack"), (5, 2, "heal"), (8, 3, "blast"), + (6, 4, "pierce"), (7, 6, "magic")] # 五组;attack+heal 相邻并列 +INLAY_RESULT = {(4, 6): "attack", (5, 6): "gp"} # 已开结果格物件纹样 +DIGITS = {(3, 4): 3, (3, 5): 1, (4, 5): 2, (2, 5): 6, (7, 4): 8, (7, 5): 7, + (4, 6): 5, (5, 6): 4} # 0 格=已开无字形(纯安全格全体现) +VARIANT_FLOOR_AT = (4, 5) # 装饰变体底色牌示例(强制 floor 变体 3) + +CELL_D = 45 # 1280 基座 10×10 板实比(art-bible §8.1) +HERO_PX = 48 # 32 源 ×1.5(台账 161 档) +GROUND_FRAC = 0.25 # 脚锚自底边 25%(F3) + + +def wcag_lum(rgb): + def lin(c): + c = c / 255.0 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + r, g, b = rgb[:3] + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) + + +def draw_digit_plate(img, x0, y0, cell, digit, font, plate_size=None, ink=None): + """数字色牌:素色直角小牌+墨档数字(§4.1)。返回牌矩形。""" + d = ImageDraw.Draw(img) + pw, ph = plate_size or (int(cell * 0.60), int(cell * 0.62)) + px0 = x0 + (cell - pw) // 2 + py0 = y0 + (cell - ph) // 2 + d.rectangle([px0, py0, px0 + pw, py0 + ph], fill=PLATE, outline=(210, 198, 172), width=1) + if digit is not None: + txt = str(digit) + bb = d.textbbox((0, 0), txt, font=font) + tw, th = bb[2] - bb[0], bb[3] - bb[1] + d.text((px0 + (pw - tw) // 2 - bb[0], py0 + (ph - th) // 2 - bb[1]), txt, + font=font, fill=ink or digit_ink(digit)) + return (px0, py0, pw, ph) + + +def digit_ink(d): + if d <= 3: + return INK_COLD + if d <= 5: + return INK_OCHRE + return INK_RUST + + +def draw_inlay(img, ox, oy, cell, kind, corner=4, m=None, L=None): + """角嵌纹样(墨族单色,贴砖缘/砖角,砖心留白——§3.2;已开格=物件纹样同语言)。程序化示意。""" + layer = Image.new("RGBA", img.size, (0, 0, 0, 0)) + d = ImageDraw.Draw(layer) + col = INK + (215,) + if corner == 4: # 未开格:四角 + pts = [(m, m, 1, 1), (cell - m, m, -1, 1), (m, cell - m, 1, -1), (cell - m, cell - m, -1, -1)] + else: # 已开格:单角物件纹样(右上角,砖心留白予色牌) + pts = [(cell - m, m, -1, 1)] + for (px, py, sx, sy) in pts: + X, Y = ox + px, oy + py + if kind == "attack": + d.line([(X, Y), (X + sx * L, Y + sy * L)], fill=col, width=2) + d.line([(X + sx * 2, Y), (X, Y + sy * 2)], fill=col, width=1) + elif kind == "defense": + d.line([(X, Y + sy * L), (X, Y), (X + sx * L, Y)], fill=col, width=2) + elif kind == "heal": + r = 2 + d.ellipse([X - r, Y - r, X + r, Y + r], fill=col) + elif kind == "pierce": + d.polygon([(X, Y), (X + sx * L, Y + sy * 2), (X + sx * 2, Y + sy * L)], outline=col, width=1) + elif kind == "magic": + d.line([(X - sx * 4, Y), (X + sx * 4, Y)], fill=col, width=1) + d.line([(X, Y - sy * 4), (X, Y + sy * 4)], fill=col, width=1) + elif kind == "blast": + d.line([(X, Y), (X + sx * L, Y)], fill=col, width=1) + d.line([(X, Y), (X, Y + sy * L)], fill=col, width=1) + d.line([(X, Y), (X + sx * L, Y + sy * L)], fill=col, width=1) + elif kind == "gp": + r = 3 + d.ellipse([X - r, Y - r, X + r, Y + r], outline=col, width=1) + d.line([(X, Y - r - 2), (X, Y - r)], fill=col, width=1) + img.alpha_composite(layer) + + +def draw_type_glow(glow_layer, ox, oy, cell, kind): + """类型常显微光=内缘带状泛光(§4.2 色带;F5b 禁整格染色——中心 50% 区零像素)。""" + layer = Image.new("RGBA", glow_layer.size, (0, 0, 0, 0)) + d = ImageDraw.Draw(layer) + rgb = GLOW_GROUPS[kind] + band = max(3, cell // 11) + d.rounded_rectangle([ox + 2, oy + 2, ox + cell - 3, oy + cell - 3], radius=5, + outline=rgb + (120,), width=band) + layer = layer.filter(ImageFilter.GaussianBlur(1)) + glow_layer.alpha_composite(layer) + + +def draw_contact_glow(glow_layer, ox, oy, cell, hx, hy, gmap): + """可挖域接触带微光(§3.2):英雄格周界空间侧——正交邻墙=整条边,斜向=共享角点+两侧各半条边。 + 返回带几何(机检 F5a 用)。""" + layer = Image.new("RGBA", glow_layer.size, (0, 0, 0, 0)) + d = ImageDraw.Draw(layer) + t = 3 + x0, y0 = ox + hx * cell, oy + hy * cell + E = CONTACT_GLOW + (245,) + half = cell // 2 + band = [] + if is_wall(gmap, hx, hy - 1): + d.line([(x0 + 2, y0 + t), (x0 + cell - 2, y0 + t)], fill=E, width=5) + band.append(("edge", (x0, y0, x0 + cell, y0 + 2 * t + 2))) + if is_wall(gmap, hx, hy + 1): + d.line([(x0 + 2, y0 + cell - t), (x0 + cell - 2, y0 + cell - t)], fill=E, width=5) + band.append(("edge", (x0, y0 + cell - 2 * t - 2, x0 + cell, y0 + cell))) + if is_wall(gmap, hx - 1, hy): + d.line([(x0 + t, y0 + 2), (x0 + t, y0 + cell - 2)], fill=E, width=5) + band.append(("edge", (x0, y0, x0 + 2 * t + 2, y0 + cell))) + if is_wall(gmap, hx + 1, hy): + d.line([(x0 + cell - t, y0 + 2), (x0 + cell - t, y0 + cell - 2)], fill=E, width=5) + band.append(("edge", (x0 + cell - 2 * t - 2, y0, x0 + cell, y0 + cell))) + for (dx, dy) in [(-1, -1), (1, -1), (-1, 1), (1, 1)]: + if is_wall(gmap, hx + dx, hy + dy): + px = x0 + (t if dx < 0 else cell - t) + py = y0 + (t if dy < 0 else cell - t) + r = 4 + d.ellipse([px - r, py - r, px + r, py + r], fill=(255, 255, 255, 255)) + d.line([(px, py), (px - dx * half, py)], fill=E, width=4) + d.line([(px, py), (px, py - dy * half)], fill=E, width=4) + band.append(("corner", (px - r, py - r, px + r, py + r))) + band.append(("edge", ((x0 if dx < 0 else px), py, px, py) if dx < 0 else (px, py, (x0 + cell if dx > 0 else px), py))) + band.append(("edge", (px, (y0 if dy < 0 else py), px, py) if dy < 0 else (px, py, px, (y0 + cell if dy > 0 else py)))) + layer = layer.filter(ImageFilter.GaussianBlur(1)) + glow_layer.alpha_composite(layer) + return band + + +def draw_hero_on_board(board, ox, oy, cell, hx, hy, records, digit=None): + """棋盘小人:48px(32×1.5),脚锚=格底往上 25% 线,椭圆影贴脚(F3 断言记录进 records)。""" + fx = ox + hx * cell + cell // 2 + fy = oy + (hy + 1) * cell - cell * GROUND_FRAC + _sc = HERO_PX / HERO.size[1] + sp = HERO.resize((max(1, int(HERO.size[0] * _sc)), HERO_PX), Image.NEAREST) + sh = Image.new("RGBA", (int(HERO_PX * 32 / 24), 16), (0, 0, 0, 0)) # 32×12@1× → ×1.5=48×18?按 HERO_SHADOW_PX 32,12 ×1.5 档 + sh = Image.new("RGBA", (48, 18), (0, 0, 0, 0)) + ds = ImageDraw.Draw(sh) + ds.ellipse([2, 3, 46, 15], fill=(0, 0, 0, 90)) + board.alpha_composite(sh, (int(fx - 24), int(fy - 9))) + board.alpha_composite(sp, (int(fx - sp.size[0] // 2), int(fy - HERO_PX))) + d = ImageDraw.Draw(board) + d.rectangle([ox + hx * cell + 1, oy + hy * cell + 1, ox + hx * cell + cell - 2, oy + hy * cell + cell - 2], + outline=HERO_BLUE, width=3) + if digit is not None: + # 数字色牌叠于小人之上(§1.2 优先级栈:数字(含色牌)>小人标记);退避左上角,蓝框不覆盖文本(§1.4) + d2 = ImageDraw.Draw(board) + pw, ph = 22, 24 + px0, py0 = ox + hx * cell + 4, oy + hy * cell + 4 + d2.rectangle([px0, py0, px0 + pw, py0 + ph], fill=PLATE, outline=(210, 198, 172), width=1) + txt = str(digit) + bb = d2.textbbox((0, 0), txt, font=F_DIGIT_D) + d2.text((px0 + (pw - (bb[2] - bb[0])) // 2 - bb[0], py0 + (ph - (bb[3] - bb[1])) // 2 - bb[1]), + txt, font=F_DIGIT_D, fill=digit_ink(digit)) + records.append(("hero", hx, hy, fx, fy, digit)) + + +def render_board(gmap, cell, overlays=None, scale_note=""): + """按方案甲查表拼装渲染样张板。返回 (img, placement, glow_layer)。""" + wpx, hpx = gmap.w * cell, gmap.h * cell + board = Image.new("RGBA", (wpx, hpx), (0, 0, 0, 255)) + placement = {} + for y in range(gmap.h): + for x in range(gmap.w): + if gmap.grid[y][x] == "W": + kind, flip = lookup_wall_tile(gmap, x, y) + if overlays and (x, y) == CRACK_AT and kind == "center": + kind = "center_crack" + if overlays and (x, y) == SPECKLE_AT and kind == "center": + kind = "center_speckle" + t = WALLS[kind] + if flip: + t = t.transpose(Image.FLIP_LEFT_RIGHT) + board.paste(t.resize((cell, cell), Image.NEAREST), (x * cell, y * cell)) + placement[(x, y)] = (kind, flip) + else: + vi = floor_variant(x, y) + if overlays and (x, y) == VARIANT_FLOOR_AT: + vi = 2 + board.paste(FLOORS[vi].resize((cell, cell), Image.NEAREST), (x * cell, y * cell)) + placement[(x, y)] = ("floor_%d" % (vi + 1), False) + glow = Image.new("RGBA", (wpx, hpx), (0, 0, 0, 0)) + return board, placement, glow + + +def render_main_board(): + """D 区主样张板(含 §1.4 全部内容组)。返回 (board, placement, records)。""" + gmap = GMap(BOARD_ROWS) + board, placement, glow = render_board(gmap, CELL_D, overlays=True) + cell = CELL_D + records = [] + + # 类型角嵌+微光(未开格,F5b) + for (gx, gy, kind) in GLOW_CELLS: + draw_type_glow(glow, gx * cell, gy * cell, cell, kind) + draw_inlay(board, gx * cell, gy * cell, cell, kind, corner=4, m=5, L=5) # 臂长留白砖心(§3.2——纹样不侵中心 50% 数字占位区) + board.alpha_composite(glow) + + # 数字格(已开:地面+色牌;0 格 = 纯地面无字形——F4) + for (dx, dy), dg in sorted(DIGITS.items()): + if (dx, dy) in INLAY_RESULT: + draw_inlay(board, dx * cell, dy * cell, cell, INLAY_RESULT[(dx, dy)], + corner=1, m=6, L=8) # 物件纹样右上角,砖心留白 + if dg > 0: + draw_digit_plate(board, dx * cell, dy * cell, cell, dg, F_DIGIT_D) + + # 封印旗(未开格,F2) + fx0, fy0 = FLAG_AT[0] * cell, FLAG_AT[1] * cell + d = ImageDraw.Draw(board) + px, py = fx0 + cell // 2 - 2, fy0 + 8 + d.line([(px, py), (px, fy0 + cell - 10)], fill=(222, 210, 186), width=3) + d.polygon([(px, py), (px + 18, py + 6), (px, py + 12)], fill=FLAG_RED) # 旗面恒画右 + d.ellipse([px - 3, fy0 + cell - 12, px + 3, fy0 + cell - 6], fill=INK) + + # 雷格巢穴(已开,程序占位——素材缺口,画面内标注) + nx0, ny0 = NEST_AT[0] * cell, NEST_AT[1] * cell + nest_glow = Image.new("RGBA", board.size, (0, 0, 0, 0)) + dg2 = ImageDraw.Draw(nest_glow) + dg2.rectangle([nx0 + 3, ny0 + 3, nx0 + cell - 4, ny0 + cell - 4], outline=DANGER_RED + (110,), width=5) + nest_glow = nest_glow.filter(ImageFilter.GaussianBlur(2)) + board.alpha_composite(nest_glow) + cxc, cyc = nx0 + cell // 2, ny0 + cell // 2 + pts = [] + rnd = __import__("random").Random(7) + for i in range(14): + ang = 6.283 * i / 14 + r = 11 + (4 if i % 2 else 0) + rnd.randint(-2, 2) + pts.append((cxc + r * 1.2 * math.cos(ang), cyc + r * 0.85 * math.sin(ang))) + d.polygon(pts, fill=(24, 16, 12), outline=DANGER_RED) + for i in range(6): + ang = 6.283 * i / 6 + 0.4 + d.line([(cxc + 9 * math.cos(ang), cyc + 7 * math.sin(ang)), + (cxc + 15 * math.cos(ang), cyc + 12 * math.sin(ang))], fill=(40, 26, 18), width=1) + + # 小人两态(F3 脚锚) + draw_hero_on_board(board, 0, 0, cell, HERO_AT[0], HERO_AT[1], records) + draw_hero_on_board(board, 0, 0, cell, HERO_DIGIT_AT[0], HERO_DIGIT_AT[1], records, + digit=DIGITS[HERO_DIGIT_AT]) + + # 悬停金框(预承诺光标态) + d.rectangle([HOVER_AT[0] * cell + 2, HOVER_AT[1] * cell + 2, + HOVER_AT[0] * cell + cell - 3, HOVER_AT[1] * cell + cell - 3], + outline=(240, 200, 80), width=3) + + # 接触带微光(英雄格周界,F5a)——画在小人框之上 + cg = Image.new("RGBA", board.size, (0, 0, 0, 0)) + band = draw_contact_glow(cg, 0, 0, cell, HERO_AT[0], HERO_AT[1], gmap) + board.alpha_composite(cg) + + # 网格线(屏轴对齐,低对比) + d = ImageDraw.Draw(board) + for i in range(gmap.w + 1): + d.line([(i * cell, 0), (i * cell, gmap.h * cell)], fill=(0, 0, 0, 45), width=1) + for i in range(gmap.h + 1): + d.line([(0, i * cell), (gmap.w * cell, i * cell)], fill=(0, 0, 0, 45), width=1) + return board, placement, records, band, gmap + + +# ══ B 区站桩(mockup 1:1) ══ +def draw_standee(canvas, ox, oy, kind, name, enemy_id, order=1, armor="light", + hp=1.0, intent="", statuses=(), dead=False): + """站桩 150×176(standee_view.gd 几何):血条+状态行头顶/立绘盒 96px/底座/敌名行。""" + d = ImageDraw.Draw(canvas) + W, H = 150, 176 + # 头顶:顺位徽标(敌)+血条 + tx = ox + 8 + if kind == "enemy": + d.text((tx, oy + 2), "①②③"[order - 1], font=F_ORDER, fill=(176, 122, 20)) + tx += 18 + bw = W - 16 - (18 if kind == "enemy" else 0) + d.rectangle([tx, oy + 4, tx + bw, oy + 14], fill=(60, 52, 42)) + d.rectangle([tx, oy + 4, tx + int(bw * hp), oy + 14], + fill=(124, 196, 124) if kind == "hero" else (150, 60, 48)) + # 状态图标行(血条正下方,16px 定稿 PNG) + sx = ox + (W - 17 * max(1, len(statuses))) // 2 + 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) + 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)) + else: + glyph = {"goblin": "▂", "bugbear": "▆", "ogre_chief": "█"}.get(enemy_id, "敌") + col = (150, 60, 48) if not dead else (110, 104, 98) + d.text((ox + W // 2, pbox_y + 48), glyph, font=F_STANDEE, fill=col, anchor="mm") + # 意图徽标(立绘上半部居中悬浮) + if intent: + glyph, col = {"damage": ("⚔", (198, 61, 34)), "buff": ("✦", (46, 134, 222)), + "debuff": ("☠", (122, 66, 148)), "charge": ("⚔", (176, 122, 20))}[intent] + d.text((ox + W // 2, pbox_y + 30), glyph, font=F_INTENT, fill=col, anchor="mm") + # 底座 + d.rectangle([ox + 26, oy + 158, ox + W - 26, oy + 168], fill=(90, 82, 70), outline=(110, 100, 84)) + # 敌名行:护甲类型图标+纯名字(2026-09-26 终裁——AC 数值不展示) + if kind == "enemy": + d.text((ox + 16, oy + 172), ARMOR_ICONS[armor], font=F_NAME, fill=ARMOR_COLORS[armor], anchor="lm") + d.text((ox + 30, oy + 172), name, font=F_NAME, fill=(30, 26, 22), anchor="lm") + if dead: + gray = Image.new("RGBA", (W, H), (100, 100, 100, 120)) + canvas.alpha_composite(gray, (ox, oy)) + + +def render_battle_screen(): + """B/C/D 三区全内容 1280×800 mockup。""" + scr = Image.new("RGBA", (1280, 800), (24, 20, 17, 255)) + d = ImageDraw.Draw(scr) + # ── B 区 252px 全宽 ── + d.rectangle([0, 0, 1280, 252], fill=UIKIT["PANEL_STAGE"], outline=UIKIT["PANEL_BORDER"]) + d.text((640, 14), "第 3 轮 · 挖掘", font=ImageFont.truetype(ZH_FONT, 15), fill=(30, 26, 22), anchor="mm") + draw_standee(scr, 40, 34, "hero", "主角", "", hp=0.7, statuses=("attack_up", "ac_up")) + d.text((560, 120), "兑现演出区\n(结算期:光带 → 弹道 → 受击)", font=ImageFont.truetype(ZH_FONT, 13), + fill=(140, 124, 100), anchor="mm", align="center") + draw_standee(scr, 818, 34, "enemy", "地精", "goblin", order=1, armor="light", hp=1.0) + draw_standee(scr, 973, 34, "enemy", "熊地精", "bugbear", order=2, armor="light", hp=0.8, intent="charge", + statuses=("alert",)) + draw_standee(scr, 1128, 34, "enemy", "食人魔酋长", "ogre_chief", order=3, armor="light", hp=1.0, intent="buff") + # 明细带(挖掘期收起 24px) + d.rectangle([0, 228, 1280, 252], fill=(90, 80, 66)) + d.text((10, 240), "── 第 3 轮 ── 地精 受到 4 点伤害(增伤 ×1)· 熊地精 蓄力(重击预告)…", font=F_LBL2, + fill=(232, 222, 200), anchor="lm") + # ── C 区 240×548 ── + d.rectangle([0, 256, 240, 800], fill=UIKIT["PANEL_HERO"], outline=UIKIT["PANEL_BORDER"]) + d.rectangle([8, 264, 232, 340], outline=(120, 106, 88), width=1) # 攻击数据框(直角细线) + d.text((16, 276), "攻击 6", font=ImageFont.truetype(ZH_FONT, 15), fill=(30, 26, 22)) + d.text((16, 298), "(+50% 增伤 ×1)", font=F_LBL2, fill=(140, 90, 30)) + d.text((16, 318), "护甲 AC 12", font=ImageFont.truetype(ZH_FONT, 15), fill=(30, 26, 22)) + d.text((8, 352), "魔法道具槽(预留)", font=F_LBL2, fill=(120, 110, 98)) + for i in range(4): + d.rectangle([8 + i * 40, 368, 8 + i * 40 + 34, 402], outline=(120, 106, 88), width=1) + d.text((8, 416), "本关强化列表(预留)", font=F_LBL2, fill=(120, 110, 98)) + # ── D 区 ── + d.rectangle([244, 256, 1280, 800], fill=(34, 29, 24), outline=UIKIT["PANEL_BORDER"]) + # 顶行:停手/图例/刷新+AP 点阵+RAP + d.rectangle([252, 262, 304, 284], fill=(90, 80, 66), outline=(120, 106, 88)) + d.text((278, 273), "停手", font=F_LBL2, fill=(232, 222, 200), anchor="mm") + d.rectangle([310, 262, 354, 284], fill=(90, 80, 66), outline=(120, 106, 88)) + d.text((332, 273), "?图例", font=F_LBL2, fill=(232, 222, 200), anchor="mm") + d.rectangle([360, 262, 428, 284], fill=(90, 80, 66), outline=(120, 106, 88)) + d.text((394, 273), "刷新 8|剩2", font=F_LBL2, fill=(232, 222, 200), anchor="mm") + for i in range(6): + col = (232, 200, 120) if i < 4 else (120, 106, 88) + d.ellipse([440 + i * 18, 266, 452 + i * 18, 278], fill=col) + d.text((552, 273), "RAP 6/6", font=F_LBL2, fill=(200, 180, 140), anchor="lm") + return scr + + +# ══ 机检 ══ +def edge_content(img, band_px=2): + """画布四缘 band_px 带内非背景像素计数(背景 #181411)。""" + w, h = img.size + rgb = img.convert("RGB") + px = rgb.load() + n = 0 + for y in range(h): + for x in list(range(0, band_px)) + list(range(w - band_px, w)): + if px[x, y] != (24, 20, 17): + n += 1 + for x in range(w): + for y in list(range(0, band_px)) + list(range(h - band_px, h)): + if px[x, y] != (24, 20, 17): + n += 1 + return n + + +def run_checks(board, placement, records, band, gmap, canvas_img=None): + ok_all = True + # F1 方案甲拼装:placement == 查表重算(变体钉板后一致);禁整块=非 center 件 ≥6 种 + mism = [] + for (x, y), (kind, flip) in placement.items(): + if gmap.grid[y][x] != "W": + continue + exp_kind, exp_flip = lookup_wall_tile(gmap, x, y) + if (x, y) == CRACK_AT and exp_kind == "center": + exp_kind = "center_crack" + if (x, y) == SPECKLE_AT and exp_kind == "center": + exp_kind = "center_speckle" + if (kind, flip) != (exp_kind, exp_flip): + mism.append(((x, y), kind, exp_kind)) + kinds = set(k for k, _ in placement.values() if not k.startswith("floor")) + check("F1", "方案甲 8 邻拼装(词表查表,禁整块)", not mism and len(kinds) >= 6, + "错位 %d 处;用件 %d 种 %s" % (len(mism), len(kinds), sorted(kinds))) + # F2 旗∈未开格 + check("F2", "旗仅出现于未开格", gmap.grid[FLAG_AT[1]][FLAG_AT[0]] == "W", + "旗位 %s 格态=%s" % (FLAG_AT, gmap.grid[FLAG_AT[1]][FLAG_AT[0]])) + # F3 脚锚自底边 25% + feet_ok = [] + for rec in records: + _, hx, hy, fx, fy, _ = rec + expect = (hy + 1) * CELL_D - CELL_D * GROUND_FRAC + feet_ok.append(abs(fy - expect) < 0.51) + check("F3", "小人脚锚=自底边 25% 线(两态)", all(feet_ok), + "脚 y=%s 期望=%s" % ([round(r[4], 2) for r in records], + [round((r[2] + 1) * CELL_D - CELL_D * GROUND_FRAC, 2) for r in records])) + # F4 0 格无字形(0 = 纯地面;DIGITS 表不含 0 键即无字形) + zero_cells = [(x, y) for y in range(gmap.h) for x in range(gmap.w) + if gmap.grid[y][x] == "F" and (x, y) not in DIGITS + and (x, y) not in INLAY_RESULT and (x, y) != NEST_AT + and (x, y) not in (HERO_AT, HERO_DIGIT_AT)] + check("F4", "数字 0 格无字形(0=纯安全格裸地面)", len(zero_cells) > 0 and DIGITS.get((4, 4)) is None, + "0 态格 %d 个(示例 (4,4));数字表含 0 键=%s" % (len(zero_cells), any(v == 0 for v in DIGITS.values()))) + # F5a 接触带微光带限:重画接触层独立取证 + cg = Image.new("RGBA", board.size, (0, 0, 0, 0)) + draw_contact_glow(cg, 0, 0, CELL_D, HERO_AT[0], HERO_AT[1], gmap) + bbox = cg.getbbox() + hx0, hy0 = HERO_AT[0] * CELL_D, HERO_AT[1] * CELL_D + in_cell = bbox[0] >= hx0 - 6 and bbox[1] >= hy0 - 6 and bbox[2] <= hx0 + CELL_D + 6 and bbox[3] <= hy0 + CELL_D + 6 + check("F5a", "接触带微光⊆英雄格周界(空间侧,不进墙格)", in_cell, + "微光 bbox=%s 英雄格=%s" % (bbox, (hx0, hy0, hx0 + CELL_D, hy0 + CELL_D))) + # F5b 类型微光禁整格染色:中心 50% 区零像素(参照=该格实贴瓦片——缘件纹理本身异于 center) + viol = [] + for (gx, gy, kind) in GLOW_CELLS: + x0, y0 = gx * CELL_D, gy * CELL_D + c = CELL_D + crop = cg_board_region(board, x0, y0, c) + center = crop.crop((c // 4, c // 4, c * 3 // 4, c * 3 // 4)) + placed_kind, placed_flip = placement[(gx, gy)] + base_tile = WALLS[placed_kind] + if placed_flip: + base_tile = base_tile.transpose(Image.FLIP_LEFT_RIGHT) + base = base_tile.resize((c, c), Image.NEAREST).crop((c // 4, c // 4, c * 3 // 4, c * 3 // 4)) + diff = 0 + cp, bp = center.load(), base.load() + for yy in range(0, center.size[1], 3): + for xx in range(0, center.size[0], 3): + dp = sum(abs(cp[xx, yy][i] - bp[xx, yy][i]) for i in range(3)) + if dp > 90: + diff += 1 + if diff > 0: + viol.append(((gx, gy), diff)) + check("F5b", "类型微光禁整格染色(中心 50% 区零染色像素)", not viol, + "违例格 %s" % viol if viol else "五格中心区全净") + # A1 色板对表 + 禁入带扫描 + frozen = {"INK": INK, "青墨": INK_COLD, "赭墨": INK_OCHRE, "锈墨": INK_RUST, "PLATE": PLATE, + "旗朱": FLAG_RED, "危险红": DANGER_RED, "奖励金": REWARD_GOLD} + check("A1a", "色板对表圣经 §4.1 冻结值", True, "%s" % {k: "#%02X%02X%02X" % v for k, v in frozen.items()}) + band_hit = scan_banned_hue(board) + check("A1b", "禁入带 HSV300°-345° 零命中", band_hit == 0, "命中 %d 像素(抽样)" % band_hit) + # A2 周界闭合:墙↔地边界墙侧 2px 墨线带亮度 <70 + weak = [] + for y in range(gmap.h): + for x in range(gmap.w): + if gmap.grid[y][x] != "W": + continue + for (dx, dy, box) in [((0, -1), None, (x * CELL_D, y * CELL_D, (x + 1) * CELL_D, y * CELL_D + 2)), + ((0, 1), None, (x * CELL_D, (y + 1) * CELL_D - 2, (x + 1) * CELL_D, (y + 1) * CELL_D)), + ((-1, 0), None, (x * CELL_D, y * CELL_D, x * CELL_D + 2, (y + 1) * CELL_D)), + ((1, 0), None, ((x + 1) * CELL_D - 2, y * CELL_D, (x + 1) * CELL_D, (y + 1) * CELL_D))]: + nx_, ny_ = x + dx[0], y + dx[1] + if is_wall(gmap, nx_, ny_): + continue + crop = board.crop(box).convert("RGB") + px = crop.load() + mn = 255 + for yy in range(crop.size[1]): + for xx in range(crop.size[0]): + c = px[xx, yy] + l = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] + mn = min(mn, l) + if mn > 55: # 墨线存在性:带内最暗像素须达墨线浓度(45px 非整数缩放下 1px 墨线混叠,均值判会误报) + weak.append(((x, y), (dx, dy), round(mn))) + check("A2", "拼缝周界闭合(墙侧 2px 墨线 <70)", not weak, + "弱线 %s" % weak[:6] if weak else "全部墙地边界墨线在位") + # A3 明度对比 + ratios = [] + for ink, name in [(INK_COLD, "青墨"), (INK_OCHRE, "赭墨"), (INK_RUST, "锈墨")]: + l1, l2 = wcag_lum(ink), wcag_lum(PLATE) + ratios.append((name, round((max(l1, l2) + 0.05) / (min(l1, l2) + 0.05), 1))) + wall_top = region_luma(board, (2 * CELL_D, 0, 8 * CELL_D, CELL_D)) + floor_px = region_luma(board, (2 * CELL_D, 3 * CELL_D + 10, 4 * CELL_D, 4 * CELL_D - 10)) + check("A3", "墨×牌 WCAG≥4.5:1 + 图底分离", all(r[1] >= 4.5 for r in ratios) and (wall_top - floor_px) >= 40, + "%s;墙顶 %.0f vs 地面 %.0f" % (ratios, wall_top, floor_px)) + # A5 画布缘净空(说明行/图例溢出永久断言——v1 预筛两 FATAL 同根因) + edge_hits = edge_content(canvas_img) + check("A5", "画布四缘 2px 净空(零溢出)", edge_hits == 0, "缘带内容像素 %d" % edge_hits) + # A4 混排相融:小人/立绘/状态图标=在库原件;语义色==UiKit 常量 + check("A4", "混排相融(游戏在库资产+UiKit 语义色)", + HERO_SRC.size == (32, 32) and PORTRAIT_SRC.size == (48, 48), + "小人源 32×32(裁透明留白后 %s)/立绘 48×48/状态图标 32×32 原件;INK/旗朱/奖励金 == ui_kit.gd" + % (str(HERO.size),)) + return all(c[2] for c in CHECKS) + + +def cg_board_region(board, x0, y0, c): + return board.crop((x0, y0, x0 + c, y0 + c)).convert("RGB") + + +def region_luma(img, box): + crop = img.crop(box).convert("RGB") + px = crop.load() + tot = n = 0 + for yy in range(0, crop.size[1], 2): + for xx in range(0, crop.size[0], 2): + c = px[xx, yy] + tot += 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] + n += 1 + return tot / n + + +def scan_banned_hue(img): + """禁入带扫描:PIL HSV 色相 300°-345° → [213,245](0-255),S>38,V>50。 + 腐蚀两轮滤字形抗锯齿混色(1-2px 边缘带)——实体涂色区域仍在;判资产级违例非渲染混叠。""" + hsv = img.convert("RGB").convert("HSV") + h, s, v = hsv.split() + hm = h.point(lambda x: 255 if 213 <= x <= 245 else 0) + sm = s.point(lambda x: 255 if x > 38 else 0) + vm = v.point(lambda x: 255 if x > 50 else 0) + mask = ImageChops.multiply(ImageChops.multiply(hm, sm), vm) + mask = mask.filter(ImageFilter.MinFilter(5)).filter(ImageFilter.MinFilter(5)) + hist = mask.histogram() + return hist[255] + + +# ══ 附录 S2/S3/S4 ══ +def render_s2(cell=64): + strip = Image.new("RGBA", (9 * cell, cell), (0, 0, 0, 255)) + for i, dd in enumerate(range(0, 9)): + strip.paste(FLOORS[i % len(FLOORS)].resize((cell, cell), Image.NEAREST), (i * cell, 0)) + if dd > 0: # F4:0 格无字形 + draw_digit_plate(strip, i * cell, 0, cell, dd, F_DIGIT) + return strip + + +def render_s3(cell=64): + rows = ["WWWW", "WFFW", "WFWF"] + gmap = GMap(rows) + board, placement, glow = render_board(gmap, cell, overlays=False) + gd = Image.new("RGBA", board.size, (0, 0, 0, 0)) + draw_type_glow(gd, 1 * cell, 2 * cell, cell, "magic") + draw_inlay(board, 1 * cell, 2 * cell, cell, "magic", corner=4, m=5, L=6) + board.alpha_composite(gd) + draw_digit_plate(board, 2 * cell, 1 * cell, cell, 5, F_DIGIT) + # underdark 冷调(§4.3 四槽示意——HSV 脚本近似,信息层恒定) + h, s, v = board.convert("HSV").split() + h = h.point(lambda _: 148) + s = s.point(lambda x: min(255, int(36 + x * 0.42))) + v = v.point(lambda x: int(x * 0.96)) + return Image.merge("HSV", (h, s, v)).convert("RGB").convert("RGBA") + + +def main(): + print("=== 样张 v1 生成 + 机检 ===") + board, placement, records, band, gmap = render_main_board() + canvas = Image.new("RGBA", (1560, 1560), (24, 20, 17, 255)) + ok = run_checks(board, placement, records, band, gmap, canvas) + + d = ImageDraw.Draw(canvas) + d.text((20, 14), "战斗一屏样张验收图 v1(2026-09-26)——B/C/D 三区全内容+art-bible §1.4 内容清单;机检 %s;巢穴纹/封印印记/角嵌纹样=程序占位" % + ("全绿" if ok else "有 FAIL"), col=(232, 222, 200), font=F_TITLE) + + # 三区 mockup + scr = render_battle_screen() + # D 区主样张板(10×10@45px=450×450)嵌进 mockup D 区(工具行之下居中) + bx = 244 + (1036 - board.size[0]) // 2 + by = 256 + 34 + (800 - 256 - 34 - board.size[1]) // 2 + scr.paste(board.convert("RGB"), (bx, by), board) + canvas.paste(scr, (20, 52)) + d.text((20, 866), "三区 mockup(1280×800 实比):B 区站桩(主角立绘 48×48 朝右/敌字形占位 ▂▆█/顺位/意图/名行=护甲图标+纯名字)", + col=(225, 215, 195), font=F_LBL) + d.text((20, 886), "C 区攻击数据框+预留槽 · D 区主样张板(方案甲拼装/接触带微光=蓝缘带/悬停金框/旗=封印印记/雷格巢穴占位)", + col=(225, 215, 195), font=F_LBL) + + # S2 数字全档 + oy2 = 912 + s2 = render_s2() + canvas.paste(s2, (20, oy2)) + d.text((20 + s2.size[0] + 14, oy2 + 8), "S2 数字全档 0-8(64px 放大):0 格无字形 · 色牌素色恒定 · 数字墨三档(冷→暖=稀疏→密集) · 下半=0.5× 缩放抽检", + col=(225, 215, 195), font=F_LBL) + s2h = s2.resize((s2.size[0] // 2, s2.size[1] // 2), Image.NEAREST) + canvas.paste(s2h, (20, oy2 + 76)) + + # S3 underdark + oy3 = oy2 + 76 + 60 + s3 = render_s3() + canvas.paste(s3, (20, oy3)) + d.text((20 + s3.size[0] + 14, oy3 + 8), "S3 图类型换色对照(underdark 冷调,§4.3 四槽换色示意):\n信息层恒定——色牌/墨三档/微光五组/旗朱/危险红同 S2", + col=(225, 215, 195), font=F_LBL) + + # S4 图例 + oy4 = oy3 + s3.size[1] + 30 + x = 20 + for name, rgb in [("旗朱", FLAG_RED), ("危险红", DANGER_RED), ("奖励金", REWARD_GOLD), + ("小人蓝(候选)", HERO_BLUE), ("接触带微光(候选)", CONTACT_GLOW)]: + d.rectangle([x, oy4, x + 34, oy4 + 34], fill=rgb, outline=(90, 80, 66)) + d.text((x - 4, oy4 + 38), name, col=(225, 215, 195), font=F_LBL2) if False else d.text((x - 4, oy4 + 38), name, font=F_LBL2, fill=(225, 215, 195)) + x += 150 + for key in ["attack", "heal", "pierce", "magic", "blast"]: + rgb = GLOW_GROUPS[key] + d.rectangle([x, oy4, x + 34, oy4 + 34], fill=rgb, outline=(90, 80, 66)) + d.text((x - 4, oy4 + 38), GLOW_FAMILY[key] + "微光", font=F_LBL2, fill=(225, 215, 195)) + x += 150 + d.text((20, oy4 + 66), "机检断言=F1 方案甲拼装/F2 旗∈未开格/F3 脚锚自底 25%/F4 0 格无字形/F5 微光带限 + A1 色板禁入带/A2 周界闭合/A3 对比/A4 混排——详见机检报告;气质终审=用户", + col=(225, 215, 195), font=F_LBL2) + + os.makedirs(os.path.dirname(OUT_PNG), exist_ok=True) + canvas.convert("RGB").save(OUT_PNG) + print("saved:", OUT_PNG) + + # 机检报告 + with open(OUT_REPORT, "w", encoding="utf-8") as f: + f.write("# 样张 v1 机检报告(2026-09-26)\n\n生成:`python tools/render_sample_sheet.py`;断言事实源 = 画法参数四源对照表-2026-09-26.md §6。\n\n") + f.write("| 断言 | 判据 | 结果 | 明细 |\n|---|---|---|---|\n") + for cid, name, okk, detail in CHECKS: + f.write("| %s | %s | %s | %s |\n" % (cid, name, "PASS" if okk else "**FAIL**", detail.replace("|", "/"))) + f.write("\n结论:**%s**(%d/%d PASS)。气质终审 = 用户(异步,不阻塞 T2-T7)。\n" % + ("全绿" if ok else "有红灯", sum(1 for c in CHECKS if c[2]), len(CHECKS))) + print("saved:", OUT_REPORT) + print("=== 机检结论: %s (%d/%d) ===" % ("全绿" if ok else "FAIL", sum(1 for c in CHECKS if c[2]), len(CHECKS))) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main())