diff --git a/game/assets/fonts/OFL-fusion-pixel.txt b/game/assets/fonts/OFL-fusion-pixel.txt new file mode 100644 index 00000000..0427d1db --- /dev/null +++ b/game/assets/fonts/OFL-fusion-pixel.txt @@ -0,0 +1,96 @@ +Fusion Pixel Font +https://github.com/TakWolf/fusion-pixel-font + +Copyright (c) 2022, TakWolf (https://takwolf.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/game/assets/fonts/ui_font_fusion_pixel_zh_hans.ttf b/game/assets/fonts/ui_font_fusion_pixel_zh_hans.ttf new file mode 100644 index 00000000..ff3187c6 Binary files /dev/null and b/game/assets/fonts/ui_font_fusion_pixel_zh_hans.ttf differ diff --git a/game/presentation/main.gd b/game/presentation/main.gd index 02868d82..206061fa 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -449,11 +449,11 @@ func _on_battle_finished(payload: Dictionary) -> void: return "level_end": _end_overlay.open_victory(Narrator.battle_end_text("victory", true), - "返回", board_view.get_global_rect()) + "返回", board_view.get_global_rect(), _gp_summary_text(payload)) "standalone_end": if result == "victory": _end_overlay.open_victory(Narrator.battle_end_text("victory", false), - "回到遭遇选择", board_view.get_global_rect()) + "回到遭遇选择", board_view.get_global_rect(), _gp_summary_text(payload)) else: _end_overlay.open_defeat(Narrator.battle_end_text("defeat", false), "返回", board_view.get_global_rect()) @@ -461,6 +461,13 @@ func _on_battle_finished(payload: Dictionary) -> void: _refresh_presentations() +# 战利品行装配(battle.md OQ14——胜利总结结算展示 GP 总量+增量;账本语义零改动,纯呈现读数): +# 增量 = battle_finished 载荷 gp_dropped(战斗域上抛);总量 = runner.gp 现值(LevelRunner 入账 +# 连接先于组合根 handler——此读数即战后入账完毕的总量)。 +func _gp_summary_text(payload: Dictionary) -> String: + return "战利品 GP +%d · 合计 %d" % [int(payload.get("gp_dropped", 0)), int(runner.gp)] + + # ==================== core↔view 接线(战斗信号统一走这里)==================== func _wire_battle(settlement_ref) -> void: @@ -561,7 +568,7 @@ func _on_node_continue() -> void: func _on_choices_presented(candidates: Array) -> void: - # 零自动推进的停点呈现:整幅节点图 + 候选按钮(战斗胜利/兑付确认统一到达此处) + # 零自动推进的停点呈现:整幅节点图 + 图上点击选路(候选按钮退役 2026-09-28——战斗胜利/兑付确认统一到达此处) if runner == null: return log_view.append_bb(Narrator.route_line(candidates, runner)) diff --git a/game/presentation/ui_assembler.gd b/game/presentation/ui_assembler.gd index a7aa7b8c..032fc5dc 100644 --- a/game/presentation/ui_assembler.gd +++ b/game/presentation/ui_assembler.gd @@ -40,6 +40,14 @@ static func build_battle_ui(root: Control, db) -> Dictionary: var settlement_fx := SettlementFxView.new() root.add_child(settlement_fx) + # CJK 字体随包(2026-09-27 用户拍板 Fusion Pixel OFL):Control 侧默认主题字体走 project.godot + # gui/theme/custom_font;SubViewport 两世界不继承 Control 主题——显式注入挂点接线(C6 挂点链)。 + # 缺位守卫(load_font 返回 null = 引擎回退字体,与旧 Button 渲染同源,不炸)。 + var ui_font: Font = UiKit.load_font(UiKit.FONT_UI) + if ui_font != null: + stage.set_world_font(ui_font) + board_zone.board_view.set_world_font(ui_font) + # GP/钥石余额位已随 2026-09-26 信息归属拍板退役(中/大循环资源迁出战斗屏—— # GP 去向 = 关卡画面+战斗结算画面、钥石去向 = 结算画面+营地,呈现归对应 UX spec 批次) return { diff --git a/game/presentation/views/battle_end_overlay.gd b/game/presentation/views/battle_end_overlay.gd index 9f07e1e6..28c773f3 100644 --- a/game/presentation/views/battle_end_overlay.gd +++ b/game/presentation/views/battle_end_overlay.gd @@ -16,6 +16,7 @@ const COL_WARM := UiKit.WARM var _dim: ColorRect var _seal: Panel var _verdict: Label +var _gp_line: Label # 战利品行(OQ14 口径:胜利总结结算展示 GP 总量+增量——T5 接线 2026-09-28) var _review_btn: Button var _btn: Button var _board_rect := Rect2() @@ -36,6 +37,11 @@ func _init() -> void: _verdict.add_theme_color_override("font_color", COL_WARM) _verdict.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER box.add_child(_verdict) + _gp_line = UiKit.label("", 16, true) + _gp_line.add_theme_color_override("font_color", UiKit.GOLD) + _gp_line.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _gp_line.visible = false + box.add_child(_gp_line) _review_btn = UiKit.button("明细回看") _review_btn.pressed.connect(func(): review_pressed.emit()) box.add_child(_review_btn) @@ -54,11 +60,15 @@ func _init() -> void: visible = false -# 终局画面数据锚 = 战斗结算 判定行(verdict)——判定行文案由组合根装配传入 -func open_victory(verdict_line: String, continue_label: String, board_rect: Rect2) -> void: +# 终局画面数据锚 = 战斗结算 判定行(verdict)——判定行文案由组合根装配传入。 +# gp_summary(OQ14 口径——胜利总结结算展示 GP 总量+增量):组合根装配「战利品 +增量 · 合计总量」 +# 传入(入账次序 = LevelRunner 入账先于组合根 handler,runner.gp 即战后总量);空串 = 不显示(失败侧恒空)。 +func open_victory(verdict_line: String, continue_label: String, board_rect: Rect2, gp_summary: String = "") -> void: _dim.color = Color(COL_WARM, 0.28) _verdict.text = verdict_line _verdict.add_theme_color_override("font_color", UiKit.INK_ON_GOLD) + _gp_line.text = gp_summary + _gp_line.visible = gp_summary != "" _btn.text = continue_label _board_rect = board_rect visible = true @@ -71,7 +81,7 @@ func open_defeat(verdict_line: String, continue_label: String, board_rect: Rect2 _dim.color = Color(COL_DUSK, 0.45) _verdict.text = verdict_line _verdict.add_theme_color_override("font_color", COL_WARM) - _btn.text = continue_label + _gp_line.visible = false # GP 总量+增量归胜利总结结算(OQ14)——失败侧不呈现 _board_rect = board_rect visible = true _seal.visible = false diff --git a/game/presentation/views/board_cell_unit.gd b/game/presentation/views/board_cell_unit.gd index 42026571..df0ae451 100644 --- a/game/presentation/views/board_cell_unit.gd +++ b/game/presentation/views/board_cell_unit.gd @@ -22,6 +22,18 @@ var contact_corners: Array = [] # 斜向共享角码 0=NW/1=NE/2=SW/3=SE( var contact_col := Color(0, 0, 0, 0) # 当前呼吸色(alpha 0 = 不画) var plate := false # 数字色牌开关(BoardView paint 按已开格 glyph 写入——原则二) var outline_sides: Array = [] # 墙格地面朝向描边码 0=N/1=S/2=W/3=E(轮廓加强批——程序侧结构件) +# ── 高墙感 D 投影档(2026-09-28 挑档落码——研究件四变体用户裁 D=C+地面投影,ITB 同款高度线索)── +# 南立面 = 墙格南缘内侧「口沿高光线 + 双段暗带」(邻地面格才画,墙群内聚);地面投影 = 北邻墙格 +# 在本已开格北缘投渐隐暗带。参数按研究件 64px 拼版实测比例换算(立面双段 0.16/0.25 格边、投影带 +# 0.22 格边;坐标推导留档 commit)。素材缺位程序化回退同样服役(结构归程序——art-bible §8.5)。 +var facade_south := false: # 南立面开关(BoardView paint 按「南邻已开格」写入) + set(v): + facade_south = v + queue_redraw() +var proj_band := 0.0: # 地面投影带宽(像素,0 = 不画——BoardView 按北邻墙判定写入) + set(v): + proj_band = v + queue_redraw() var glyph := "": # 文字位——数字/雷/旗/人/?(现行视觉旗与数字互斥,同位承载) set(v): glyph = v @@ -51,6 +63,16 @@ var bevel := 0.0: # 顶面倒角强度(0=关——立体原 set(v): bevel = v queue_redraw() +# ── D 投影档绘制常量(研究件实测取色——立面高光/双段暗带/地面投影;黑罩 alpha 叠加实测等效)── +const FACADE_HIGHLIGHT_PX := 2.0 # 口沿高光线厚(像素) +const FACADE_NEAR_FRAC := 0.16 # 立面近段高(格边比——研究件 10/64) +const FACADE_FAR_FRAC := 0.25 # 立面远段高(格边比——研究件 16/64) +const FACADE_HIGHLIGHT_COL := Color8(196, 172, 132) # 口沿高光(研究件取色暖米白) +const FACADE_NEAR_COL := Color(0, 0, 0, 0.60) # 立面近段黑罩(墙顶 132→52 实测 α≈0.61) +const FACADE_FAR_COL := Color(0, 0, 0, 0.82) # 立面远段黑罩(墙顶 132→24 实测 α≈0.82) +const PROJECTION_NEAR_COL := Color(0, 0, 0, 0.28) # 投影贴墙段(地面 66→46 实测 α≈0.28) +const PROJECTION_MID_COL := Color(0, 0, 0, 0.16) # 投影中段(渐隐) +const PROJECTION_EDGE_COL := Color(0, 0, 0, 0.06) # 投影远段(渐隐收边) # ── 素材槽位(本批全 null = 程序化占位服役;art-bible §8.2 格面 32×32 暂定档)── var tex_base: Texture2D = null # ① 砖面底 var tex_mutation: Texture2D = null # ② 变异纹带 @@ -79,6 +101,8 @@ func _draw() -> void: 2: or_ = Rect2(0, 0, ow, edge) 3: or_ = Rect2(edge - ow, 0, ow, edge) draw_rect(or_, oc, true) + _draw_south_facade() + _draw_north_projection() # 瓦片态描边覆盖层(高墙瓦片接线批 2026-09-26):tex_base 在位时 BoardView 传入的 bg_sb # 已降级为「透明底 + 描边」载体——呼吸微光/主角蓝框画在瓦片之上(art-bible §1.2 叠层序 # 不变:底 < 纹带 < 数字 < 旗 < 焦点高亮);素材缺位回退走 elif 程序化原样,零观感变化 @@ -89,6 +113,7 @@ func _draw() -> void: # 带宽 4px 贴格边内侧(坐标推导:N=[0,0,edge,4] S=[0,edge-4,edge,4] W=[0,0,4,edge] E=[edge-4,0,4,edge]; # 半条边=沿邻边自角点起 edge/2;角点圆 r=6 贴角点圆心,近白芯 r=2.5 提亮) if contact_col.a > 0.0: + _draw_north_projection() # 平色回退+微光并发的英雄格补画(瓦片路径已画——同像素重复无观感差) var band := 4.0 var half := edge * 0.5 for b in contact_bands: @@ -124,6 +149,8 @@ func _draw() -> void: draw_rect(Rect2(edge - band, pt.y if int(cc) == 1 else pt.y - half, band, half), contact_col, true) elif bg_sb != null: draw_style_box(bg_sb, rect) + _draw_south_facade() # 平色回退路径——D 投影档两件同样服役(结构归程序) + _draw_north_projection() # 顶面倒角(立体原型批:光源左上——上/左内沿受光 2px、下/右内沿背光 2px;内缩 2px 让 # 呼吸微光/主角蓝边框保持最外沿可见;强度随档位) if bevel > 0.0: @@ -170,6 +197,33 @@ func _draw() -> void: draw_rect(Rect2(1, 1, edge - 2.0, edge - 2.0), Color(hl_color, 0.55), false, 2.0) # 2px 内描边(旧 hl Panel 等价) +# 南立面(D 档件一——墙格南缘内侧,自底边向上:双段暗带在下、口沿高光线贴其上沿): +# 坐标推导(自格底边起算):远段=[edge−far, edge],近段=[edge−far−near, edge−far], +# 高光线=[edge−far−near−hl, edge−far−near]——与研究件「墙顶面→高光→暗段→更暗段→格界」下行序一致 +func _draw_south_facade() -> void: + if not facade_south: + return + var far_h := edge * FACADE_FAR_FRAC + var near_h := edge * FACADE_NEAR_FRAC + var y_far := edge - far_h + var y_near := y_far - near_h + var y_hl := y_near - FACADE_HIGHLIGHT_PX + draw_rect(Rect2(0.0, y_hl, edge, FACADE_HIGHLIGHT_PX), FACADE_HIGHLIGHT_COL, true) + draw_rect(Rect2(0.0, y_near, edge, near_h), FACADE_NEAR_COL, true) + draw_rect(Rect2(0.0, y_far, edge, far_h), FACADE_FAR_COL, true) + + +# 地面投影(D 档件二——北邻墙格在本已开格北缘投渐隐暗带,自北缘向南三段衰减): +# 坐标推导:三等分 proj_band,α 0.28/0.16/0.06(研究件实测贴墙 α≈0.28、带末渐融入地面) +func _draw_north_projection() -> void: + if proj_band <= 0.0: + return + var seg := proj_band / 3.0 + draw_rect(Rect2(0.0, 0.0, edge, seg), PROJECTION_NEAR_COL, true) + draw_rect(Rect2(0.0, seg, edge, seg), PROJECTION_MID_COL, true) + draw_rect(Rect2(0.0, seg * 2.0, edge, seg), PROJECTION_EDGE_COL, true) + + # 文字位绘制:水平居中 + 垂直居中(基线 = (格高 − 字高)/2 + 上伸部——旧 Button 居中对齐等价) func _draw_glyph(g: String, col: Color, fs: int) -> void: var f := font if font != null else ThemeDB.fallback_font diff --git a/game/presentation/views/board_view.gd b/game/presentation/views/board_view.gd index 4b757bad..8697ba63 100644 --- a/game/presentation/views/board_view.gd +++ b/game/presentation/views/board_view.gd @@ -54,6 +54,9 @@ const SEP := 2 # 格间距(旧 GridContainer v/ # §1.4 注记待 art-bible 并行批落籍后补写) const DEPTH_PX := 8.0 # 悬崖厚度(像素) const DEPTH_BEVEL := 0.6 # 顶面倒角强度 +# 高墙感 D 投影档(2026-09-28 挑档落码):地面投影带宽 = 格边比(研究件 64px 拼版实测 14/64≈0.22; +# 立面/投影色参数在 CellUnit 常量——绘制侧单点) +const WALL_PROJECTION_FRAC := 0.22 # 英雄单位演出参数(棋盘小人贴图批 2026-09-25——计划书 §3.B/C 时长档) const HERO_FRAME_PX := 32.0 # 英雄帧画布边长(素材 32×32 锁定——缩放比分母) @@ -76,23 +79,13 @@ const HERO_SHADOW_PX := Vector2(32, 12) # 脚下椭圆影屏幕尺寸(Godot # 路径常量落本文件单点(ui_kit.gd 由并行批占用,任务书 §2.5 禁入)。词表 = # design/research/board-tile-vocab-plan-a-2026-09-26.md(16 唯一墙瓦片 + 5 地面变体; # 东/东北/东南/end_e/in_ne/in_se 六镜像件运行时 flip_x 合成,不出文件——词表 §二)。 -const TEX_WALL_CENTER := "res://assets/art/board/terrain_wall_center_dungeon.png" -const TEX_WALL_CENTER_CRACK := "res://assets/art/board/terrain_wall_center_crack_dungeon.png" -const TEX_WALL_CENTER_SPECKLE := "res://assets/art/board/terrain_wall_center_speckle_dungeon.png" -const TEX_WALL_N := "res://assets/art/board/terrain_wall_n_dungeon.png" -const TEX_WALL_S := "res://assets/art/board/terrain_wall_s_dungeon.png" -const TEX_WALL_W := "res://assets/art/board/terrain_wall_w_dungeon.png" # wall_e = 运行时镜像 -const TEX_WALL_NW := "res://assets/art/board/terrain_wall_nw_dungeon.png" # wall_ne = 运行时镜像 -const TEX_WALL_SW := "res://assets/art/board/terrain_wall_sw_dungeon.png" # wall_se = 运行时镜像 -const TEX_WALL_HRUN := "res://assets/art/board/terrain_wall_hrun_dungeon.png" -const TEX_WALL_VRUN := "res://assets/art/board/terrain_wall_vrun_dungeon.png" -const TEX_WALL_END_N := "res://assets/art/board/terrain_wall_end_n_dungeon.png" -const TEX_WALL_END_S := "res://assets/art/board/terrain_wall_end_s_dungeon.png" -const TEX_WALL_END_W := "res://assets/art/board/terrain_wall_end_w_dungeon.png" # wall_end_e = 运行时镜像 -const TEX_WALL_LONE := "res://assets/art/board/terrain_wall_lone_dungeon.png" -const TEX_WALL_IN_NW := "res://assets/art/board/terrain_wall_in_nw_dungeon.png" # wall_in_ne = 运行时镜像 -const TEX_WALL_IN_SW := "res://assets/art/board/terrain_wall_in_sw_dungeon.png" # wall_in_se = 运行时镜像 -const TEX_FLOOR_FMT := "res://assets/art/board/terrain_floor_dungeon_%d.png" # 1..5 变体 +# 换色批(T6 2026-09-28):路径按 board_type 后缀参数化——terrain_<件名>_.png; +# 词表 §一 16 唯一墙瓦片名单单点在本常量数组。underdark/deep_underdark 两套 = +# tools/recolor_terrain_tiles.py 程序换色产(台账 164 换色遗留清偿);任一套缺位 → 整批回落 +# dungeon 套(禁同板混渲染);dungeon 套亦缺位 → 程序化平色回退(fresh checkout 冒烟不炸)。 +const TILE_WALL_NAMES := ["wall_center", "wall_center_crack", "wall_center_speckle", "wall_n", "wall_s", "wall_w", "wall_nw", "wall_sw", "wall_hrun", "wall_vrun", "wall_end_n", "wall_end_s", "wall_end_w", "wall_lone", "wall_in_nw", "wall_in_sw"] +const TILE_WALL_FMT := "res://assets/art/board/terrain_%s_%s.png" # %s=件名 %s=board_type +const TILE_FLOOR_FMT := "res://assets/art/board/terrain_floor_%s_%d.png" # %s=board_type 1..%d 变体 const TILE_FLOOR_VARIANTS := 5 # 地面瓦片变体数(词表 §一 floor_1..5) var _wrap: CenterContainer @@ -131,6 +124,7 @@ var _hero_dig_tw: Tween var _tiles := {} # 词表键 → Texture2D(镜像件已预翻转;装载见 _load_board_tiles) var _floor_tiles: Array[Texture2D] = [] # 地面瓦片五变体(下标 0..4 = floor_1..5) var _tiles_ok := false # false = 素材缺位/未装载——bg_sb 程序化平色原路径 +var _tiles_board_type := "dungeon" # 当前装载瓦片套的 board_type(rebuild 按板切换——T6 换色批) var cell_size: float: # 只读属性(ui_layout_probe 断言面——旧私有名 _cell 的契约化出口) get: @@ -252,6 +246,11 @@ func rebuild(board: BoardData, minesweeper) -> void: _r0[c] = true if _board == null: return + # 瓦片套随板图型切换(T6 换色批——board_gen 词表 v1.1 dungeon/underdark/deep_underdark; + # 同图型重复 rebuild 零重载,_tiles_board_type 守卫) + var bt: String = _board.board_type if _board.board_type != "" else "dungeon" + if bt != _tiles_board_type: + _load_board_tiles(bt) var stride := _cell + float(SEP) for y in _board.height: for x in _board.width: @@ -683,12 +682,28 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void: u.glyph_color = digit_col u.plate = revealed and glyph != "" # 数字色牌(原则二——已开格字形全垫牌;旗在未开格不垫) u.outline_sides = [] - if _tiles_ok and not revealed: + u.facade_south = false + u.proj_band = 0.0 + var south_n: Vector2i = c + Vector2i(0, 1) + var north_n: Vector2i = c + Vector2i(0, -1) + var south_in: bool = south_n.x >= 0 and south_n.y >= 0 and south_n.x < _board.width and south_n.y < _board.height + var north_in: bool = north_n.x >= 0 and north_n.y >= 0 and north_n.x < _board.width and north_n.y < _board.height + if not revealed: + # 高墙感 D 投影档(2026-09-28 挑档落码):南邻地面格 → 墙格南缘立「口沿高光+双段暗带」立面 + #(邻地面才画——墙-墙内界保墙群内聚,轮廓加强批同口径);板外=墙不画 + u.facade_south = south_in and _minesweeper.is_revealed(south_n) # 轮廓加强:墙格四邻中已开(地面)格朝向记描边码(板外=墙不描——保墙群外轮廓只在墙/地交界) for d in [Vector2i(0, -1), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(1, 0)]: var n: Vector2i = c + d if n.x >= 0 and n.y >= 0 and n.x < _board.width and n.y < _board.height and _minesweeper.is_revealed(n): - u.outline_sides.append(0 if d.y < 0 else (1 if d.y > 0 else (2 if d.x < 0 else 3))) + var code := 0 if d.y < 0 else (1 if d.y > 0 else (2 if d.x < 0 else 3)) + if code == 1 and u.facade_south: + continue # 南缘由立面件承载(高光+双段暗带)——2px 描边让位防三重叠黑 + u.outline_sides.append(code) + else: + # 地面投影:北邻墙格在本已开格北缘投渐隐暗带(ITB 同款高度线索——带宽按格边比例常量) + if north_in and not _minesweeper.is_revealed(north_n): + u.proj_band = u.edge * WALL_PROJECTION_FRAC u.badge_glyph = _update_badge(c) u.queue_redraw() @@ -755,33 +770,37 @@ func _reset_breath() -> void: # 瓦片整批守卫装载(_init 单次调用):21 枚全成才启用——任一缺位按整批缺位处理(禁同板混渲染; # fresh checkout 无素材目录 → 静默全回退)。缺位不 push_error——可选素材缺位是预期路径非故障 # (英雄批 load_tex 纪律同款,防冒烟刷屏)。镜像件运行时 flip_x 合成,不出文件。 -func _load_board_tiles() -> void: +func _load_board_tiles(board_type: String = "dungeon") -> void: + if board_type == "": + board_type = "dungeon" var t := { - "wall_center": UiKit.load_tex(TEX_WALL_CENTER), - "wall_center_crack": UiKit.load_tex(TEX_WALL_CENTER_CRACK), - "wall_center_speckle": UiKit.load_tex(TEX_WALL_CENTER_SPECKLE), - "wall_n": UiKit.load_tex(TEX_WALL_N), - "wall_s": UiKit.load_tex(TEX_WALL_S), - "wall_w": UiKit.load_tex(TEX_WALL_W), - "wall_nw": UiKit.load_tex(TEX_WALL_NW), - "wall_sw": UiKit.load_tex(TEX_WALL_SW), - "wall_hrun": UiKit.load_tex(TEX_WALL_HRUN), - "wall_vrun": UiKit.load_tex(TEX_WALL_VRUN), - "wall_end_n": UiKit.load_tex(TEX_WALL_END_N), - "wall_end_s": UiKit.load_tex(TEX_WALL_END_S), - "wall_end_w": UiKit.load_tex(TEX_WALL_END_W), - "wall_lone": UiKit.load_tex(TEX_WALL_LONE), - "wall_in_nw": UiKit.load_tex(TEX_WALL_IN_NW), - "wall_in_sw": UiKit.load_tex(TEX_WALL_IN_SW), + "wall_center": UiKit.load_tex(TILE_WALL_FMT % ["wall_center", board_type]), + "wall_center_crack": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_crack", board_type]), + "wall_center_speckle": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_speckle", board_type]), + "wall_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_n", board_type]), + "wall_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_s", board_type]), + "wall_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_w", board_type]), + "wall_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_nw", board_type]), + "wall_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_sw", board_type]), + "wall_hrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_hrun", board_type]), + "wall_vrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_vrun", board_type]), + "wall_end_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_n", board_type]), + "wall_end_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_s", board_type]), + "wall_end_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_w", board_type]), + "wall_lone": UiKit.load_tex(TILE_WALL_FMT % ["wall_lone", board_type]), + "wall_in_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_nw", board_type]), + "wall_in_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_sw", board_type]), } var floors: Array[Texture2D] = [] for i in range(1, TILE_FLOOR_VARIANTS + 1): - floors.append(UiKit.load_tex(TEX_FLOOR_FMT % i)) + floors.append(UiKit.load_tex(TILE_FLOOR_FMT % [board_type, i])) for k in t: if t[k] == null: + _request_dungeon_fallback(board_type) return for tex in floors: if tex == null: + _request_dungeon_fallback(board_type) return # 六镜像件(词表 §二:wall_e←wall_w / wall_ne←wall_nw / wall_se←wall_sw / # wall_end_e←wall_end_w / wall_in_ne←wall_in_nw / wall_in_se←wall_in_sw;只水平翻转禁旋转 @@ -794,9 +813,17 @@ func _load_board_tiles() -> void: t["wall_in_se"] = _flip_tex(t["wall_in_sw"] as Texture2D) _tiles = t _floor_tiles = floors + _tiles_board_type = board_type _tiles_ok = true +# 整批回落(T6 换色批):请求套缺位 → 回落 dungeon 套(禁同板混渲染);dungeon 本身缺位 = +# 维持程序化平色回退原语义(_tiles_ok 恒 false,缺位不刷屏纪律不变) +func _request_dungeon_fallback(requested: String) -> void: + if requested != "dungeon": + _load_board_tiles("dungeon") + + # 水平镜像贴图运行时合成(flip_h 语义):Image 级逐位翻转后重建 ImageTexture——CellUnit 零改动。 # VRAM 压缩源先解压(flip_x 不支持压缩格式);32×32 原尺寸逐位翻转,无 ±1px 取整问题 static func _flip_tex(tex: Texture2D) -> Texture2D: diff --git a/game/presentation/views/route_overlay.gd b/game/presentation/views/route_overlay.gd index 034f7494..920d6158 100644 --- a/game/presentation/views/route_overlay.gd +++ b/game/presentation/views/route_overlay.gd @@ -1,13 +1,19 @@ # VERTICAL SLICE - NOT FOR PRODUCTION # Date: 2026-09-17(分支节点图批——关卡节点地图 v1.0 图版选路呈现) -# 节点图选路覆盖层(模态):兑付确认后由组合根打开——整幅节点图(方块地牢观感:节点 = 地牢房间 -# 方块、连线 = 通道)+ 当前位置高亮 + 可选下一跳亮边 + 候选按钮。零自动推进的玩家停点语义: -# 唯一前进通道 = 点候选按钮 → route_chosen(node_id) → 组合根转 runner.pick_next_node。 -# 高内聚边界:只读 runner 成文出口(nodes()/visited_ids()/node_index/next_candidate_ids())做渲染, -# 无任何写面;图形为程序化占位质量(占位纪律)。 -# 放弃入口(2026-09-21 用户拍板「放弃接口也要有」——台账 119;推翻 冒险结构.md「MVP 无界面入口」 -# 后置条款):选路停点 = 天然非战斗期(接口契约:战斗中放弃一律拒绝),按钮常驻本层底部; -# 可见性归组合根(只在冒险模式开——放弃的语义对象是"整局冒险";调试两层无节点图天然不可达)。 +# 2026-09-28 关卡画面实装批(M-1 迭代 1 T4——level-map.md 三拍板落地): +# ① 三停点标题定稿(布局决策 1——清偿台账 143:「继续前进(战斗房)」入口措辞矛盾退役) +# ② 图上点击选路唯一通道(布局决策 2)——候选按钮整列退役;候选节点亮金描边 = 可点目标, +# 悬停预承诺(描边加粗+指针)后点击提交;press_candidate 探针入口改走图上命中同路径 +# ③ 放弃冒险二次确认(布局决策 3)——首击转确认态(文案+危险红脉冲约 3 秒超时回落),二击提交 +# ④ 顶栏语境:关卡名(level_row.display_name)+ GP 常驻徽章(2026-09-26 二轮拍板去向落地面) +# ⑤ 底部状态栏(布局决策 4):生命/属性/本关加成三组常读(零加成隐藏),放弃按钮右端异位 +# 节点图选路覆盖层(模态):兑付确认后由组合根打开。零自动推进的玩家停点语义:唯一前进通道 = +# 图上点候选节点 → route_chosen(node_id) → 组合根转 runner.pick_next_node。 +# 高内聚边界:只读 runner 成文出口(nodes()/visited_ids()/node_index/next_candidate_ids()/level_row/ +# hero_hp/gp/effective_*/bonus)做渲染,无任何写面;图形为程序化占位质量(占位纪律)。 +# 放弃入口(2026-09-21 用户拍板「放弃接口也要有」——台账 119):选路停点 = 天然非战斗期(接口契约: +# 战斗中放弃一律拒绝),按钮常驻底栏右端;可见性归组合根(只在冒险模式开——放弃的语义对象是 +# "整局冒险";调试两层无节点图天然不可达)。 class_name RouteOverlay extends Control @@ -20,12 +26,30 @@ const COL_ROOM_FUTURE := UiKit.DARK_UPCOMING # 未达——暗 const COL_PASSAGE := UiKit.STONE # 通道——石灰 const COL_PASSAGE_LIT := UiKit.GOLD_LIT # 候选通道——亮金 const COL_BOSS_BORDER := UiKit.BOSS_PURPLE # Boss 边框——紫赤(色相独占本色;旗朱退役 2026-09-26) +const COL_ABANDON := UiKit.RED_MINE # 破坏性动作红(危险红族) const NODE_LABELS := {"battle": "战", "reward": "赏", "rest": "疗", "boss": "王", "shop": "商"} # 商 = GP+商店房实施批 2026-09-21 +const TITLE_ENTRY := "出发——选择首个节点" # 入口停点(布局决策 1——台账 143 遗留清偿) +const TITLE_SINGLE := "继续前进" # 单链停点 +const TITLE_FORK := "选择下一节点" # 岔路停点 +const NODE_HALF := Vector2(26, 18) # 节点方块半宽高(52×36 占位画布参数——样张批终稿) +const HIT_PAD := 6.0 # 图上命中外扩(指宽容差) +const CONFIRM_WINDOW_S := 3.0 # 放弃确认态窗口(秒——超时回落) var _runner = null -var _btn_box: VBoxContainer var _title: Label +var _canvas: Control +var _level_label: Label +var _gp_value: Label +var _hp_value: Label +var _hp_bar: Control +var _attr_value: Label +var _bonus_row: HBoxContainer +var _bonus_value: Label +var _cand_ids: Array = [] # 本停点候选 id 集(图上命中合法集) +var _hover_id := -1 # 悬停预承诺候选(-1 = 无) var abandon_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开) +var _abandon_armed := false # 二次确认态(首击置位、二击提交、超时/重开回落) +var _confirm_left := 0.0 # 确认态剩余秒数(_process 倒计时+脉冲驱动) func _init() -> void: @@ -39,77 +63,244 @@ func _init() -> void: var box := VBoxContainer.new() box.add_theme_constant_override("separation", 10) center.add_child(box) - _title = UiKit.label("选择下一跳", 18, true) + # 顶栏语境(spec Layout:左关卡名 / 右 GP 常驻徽章——直角细线数据框) + var top := HBoxContainer.new() + top.add_theme_constant_override("separation", 12) + box.add_child(top) + _level_label = UiKit.label("", 16, true) + _level_label.add_theme_color_override("font_color", UiKit.WARM) + _level_label.custom_minimum_size.x = 420 + top.add_child(_level_label) + var gp_box := PanelContainer.new() + gp_box.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1)) + top.add_child(gp_box) + var gp_row := HBoxContainer.new() + gp_row.add_theme_constant_override("separation", 6) + gp_box.add_child(gp_row) + _gp_value = UiKit.label("", 16, true) + _gp_value.add_theme_color_override("font_color", UiKit.GOLD) + gp_row.add_child(_gp_value) + _title = UiKit.label(TITLE_FORK, 18, true) _title.add_theme_color_override("font_color", UiKit.WARM) _title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER box.add_child(_title) - var canvas := Control.new() - canvas.custom_minimum_size = Vector2(620, 240) - canvas.draw.connect(_draw_graph.bind(canvas)) - box.add_child(canvas) - _btn_box = VBoxContainer.new() - _btn_box.add_theme_constant_override("separation", 6) - box.add_child(_btn_box) - abandon_btn = UiKit.button("🏳 放弃冒险(同失败结算——已获大奖励保留)") - abandon_btn.add_theme_color_override("font_color", UiKit.RED_MINE) # 破坏性动作红(危险红族) - abandon_btn.pressed.connect(_emit_abandon) + # 节点图画布(图上点击 = 唯一提交通道——布局决策 2;mouse_filter STOP 接悬停/点击) + _canvas = Control.new() + _canvas.custom_minimum_size = Vector2(620, 240) + _canvas.mouse_filter = Control.MOUSE_FILTER_STOP + _canvas.draw.connect(_draw_graph.bind(_canvas)) + _canvas.gui_input.connect(_on_canvas_input) + box.add_child(_canvas) + # 底部状态栏(布局决策 4:生命/属性/本关加成三组 + 放弃按钮右端异位) + var bottom := HBoxContainer.new() + bottom.add_theme_constant_override("separation", 10) + box.add_child(bottom) + bottom.add_child(_status_box("生命", true)) + bottom.add_child(_status_box("属性", false)) + # 本关加成框(零加成隐藏——spec 布局决策 4) + _bonus_row = HBoxContainer.new() + _bonus_row.add_theme_constant_override("separation", 10) + var bonus_box := PanelContainer.new() + bonus_box.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1)) + _bonus_row.add_child(bonus_box) + var bonus_inner := VBoxContainer.new() + bonus_box.add_child(bonus_inner) + bonus_inner.add_child(UiKit.label("本关加成", 12, false)) + _bonus_value = UiKit.label("", 15, true) + bonus_inner.add_child(_bonus_value) + bottom.add_child(_bonus_row) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + bottom.add_child(spacer) + abandon_btn = UiKit.button("放弃冒险(同失败结算——已获大奖励保留)") + abandon_btn.add_theme_color_override("font_color", COL_ABANDON) # 破坏性动作红(危险红族) + abandon_btn.pressed.connect(_on_abandon_pressed) abandon_btn.visible = false - box.add_child(abandon_btn) + bottom.add_child(abandon_btn) visible = false +# 状态数据框工厂(直角细线数据框——pattern 5 族;hp_box = 带迷你血条) +func _status_box(caption: String, with_bar: bool) -> PanelContainer: + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1)) + var inner := VBoxContainer.new() + panel.add_child(inner) + inner.add_child(UiKit.label(caption, 12, false)) + if with_bar: + _hp_bar = Control.new() + _hp_bar.custom_minimum_size = Vector2(120, 6) + _hp_bar.draw.connect(_draw_hp_bar.bind(_hp_bar)) + inner.add_child(_hp_bar) + var value := UiKit.label("", 15, true) + inner.add_child(value) + if caption == "生命": + _hp_value = value + else: + _attr_value = value + return panel + + +func _draw_hp_bar(bar: Control) -> void: + # 迷你血条(占位:暗底+金填充——比例 = hero_hp / effective_max_hp) + if _runner == null: + return + var max_hp := maxi(1, _runner.effective_max_hp()) + var ratio := clampf(float(_runner.hero_hp) / float(max_hp), 0.0, 1.0) + var full := Rect2(Vector2.ZERO, bar.size) + bar.draw_rect(full, UiKit.DARK_UPCOMING) + if ratio > 0.0: + bar.draw_rect(Rect2(Vector2.ZERO, Vector2(bar.size.x * ratio, bar.size.y)), UiKit.GOLD) + + # 放弃入口可见性(组合根在冒险启动时开、默认关——语义对象 = 整局冒险) func set_abandon_available(v: bool) -> void: abandon_btn.visible = v -func _emit_abandon() -> void: +# 放弃二次确认(布局决策 3):首击转确认态(文案+危险红脉冲约 3 秒超时回落),二击才提交。 +# 单击不提交——ui_probe 战斗中 pressed.emit() 单击路径保持「未受理」语义(战斗中拒绝归组合根)。 +func _on_abandon_pressed() -> void: + if not _abandon_armed: + _abandon_armed = true + _confirm_left = CONFIRM_WINDOW_S + abandon_btn.text = "再点一次确认放弃——不可逆" + return + _reset_abandon_confirm() abandon_requested.emit() -# 打开选路口(cands = 候选节点引用数组;runner 只读用于整图渲染) +func _reset_abandon_confirm() -> void: + _abandon_armed = false + _confirm_left = 0.0 + abandon_btn.modulate = Color(1, 1, 1, 1) + abandon_btn.text = "放弃冒险(同失败结算——已获大奖励保留)" + + +func _process(delta: float) -> void: + if not _abandon_armed: + return + _confirm_left -= delta + if _confirm_left <= 0.0: + _reset_abandon_confirm() + return + # 危险红边框脉冲(单段呼吸约 1Hz——闪烁基线 ≤3 次/秒内;减动效 = 静态半透不闪) + if UiKit.motion_scale < 1.0: + abandon_btn.modulate = Color(1, 1, 1, 0.75) + else: + var k := 0.5 + 0.5 * sin(_confirm_left * TAU) + abandon_btn.modulate = Color(1, 1, 1, 0.65 + 0.35 * k) + + +# 打开选路口(cands = 候选节点引用数组;runner 只读用于整图渲染与状态栏刷新) func open_choices(cands: Array, runner) -> void: _runner = runner - for child in _btn_box.get_children(): - child.queue_free() - _title.text = "选择下一跳(岔路口)" if cands.size() > 1 else "继续前进" + _hover_id = -1 + _reset_abandon_confirm() + var visited: Array = [] + if runner != null: + visited = runner.visited_ids() + if cands.size() > 1: + _title.text = TITLE_FORK + elif visited.is_empty(): + _title.text = TITLE_ENTRY # 入口停点(尚未走出第一步——台账 143 措辞矛盾清偿) + else: + _title.text = TITLE_SINGLE + _cand_ids = [] for cand in cands: - var nid := _id_of(cand) - var t := String(cand.get("type", "")) - var label := "节点 %d · %s" % [nid, _type_name(t)] if cands.size() > 1 else "继续前进(%s)" % _type_name(t) - var btn := UiKit.button(label) - btn.pressed.connect(_emit_choice.bind(nid)) - _btn_box.add_child(btn) + _cand_ids.append(_id_of(cand)) + _refresh_status() visible = true -# 探针复用:按候选序号点击(真实按钮路径) -func press_candidate(index: int) -> void: - var btns: Array = _btn_box.get_children() - if index >= 0 and index < btns.size(): - btns[index].pressed.emit() +# 状态栏刷新(命令式纪律——组合根打开本屏/兑付确认回执后重查;零推送信号订阅) +func _refresh_status() -> void: + if _runner == null: + return + _level_label.text = String(_runner.level_row.get("display_name", "")) + _gp_value.text = "◈ %d" % int(_runner.gp) + var max_hp: int = _runner.effective_max_hp() + _hp_value.text = "HP %d/%d" % [int(_runner.hero_hp), int(max_hp)] + _hp_bar.queue_redraw() + var atk_bonus := int(_runner.bonus.get("atk", 0)) + var ac_bonus := int(_runner.bonus.get("armor_class", 0)) + var atk_txt := "%d" % _runner.effective_atk() + if atk_bonus > 0: + atk_txt += " +%d" % atk_bonus # 加成角标(ASCII 加号——防像素字体缺 ⁺ 字形出豆腐块) + var ac_txt := "%d" % _runner.effective_ac() + if ac_bonus > 0: + ac_txt += " +%d" % ac_bonus + _attr_value.text = "攻击 %s · 护甲 AC %s" % [atk_txt, ac_txt] + var hp_bonus := int(_runner.bonus.get("max_hp", 0)) + var parts: Array = [] + if atk_bonus > 0: + parts.append("攻 +%d" % atk_bonus) + if hp_bonus > 0: + parts.append("上限 +%d" % hp_bonus) + if ac_bonus > 0: + parts.append("AC +%d" % ac_bonus) + _bonus_value.text = " · ".join(parts) + _bonus_row.visible = not parts.is_empty() -func _emit_choice(nid: int) -> void: +# ---- 图上命中(布局决策 2——唯一提交通道;探针 press_candidate 同路径) ---- + +func _on_canvas_input(ev: InputEvent) -> void: + if _runner == null or not visible: + return + if ev is InputEventMouseMotion: + var nid := _candidate_at(_canvas.get_local_mouse_position()) + if nid != _hover_id: + _hover_id = nid + _canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW + _canvas.queue_redraw() + elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT and ev.pressed: + var nid2 := _candidate_at(_canvas.get_local_mouse_position()) + if nid2 >= 0: + _submit_choice(nid2) + + +# 候选命中:位置 = 布局公式(与 _draw_graph 同源),矩形 = 节点方块 + 外扩容差;非候选格不响应 +func _candidate_at(local_pos: Vector2) -> int: + if _runner == null: + return -1 + var nodes: Array = _runner.nodes() + var pos := _layout(nodes, _canvas) + for nid in _cand_ids: + var node := _node_by_id(nid, nodes) + if node.is_empty(): + continue + var np: Vector2 = pos[node] + var rect := Rect2(np - NODE_HALF - Vector2(HIT_PAD, HIT_PAD), (NODE_HALF + Vector2(HIT_PAD, HIT_PAD)) * 2.0) + if rect.has_point(local_pos): + return nid + return -1 + + +func _submit_choice(nid: int) -> void: visible = false route_chosen.emit(nid) +# 探针复用:按候选序号走图上命中同路径(候选节点中心坐标 → 命中检测 → 提交——布局决策 2 探针面改造) +func press_candidate(index: int) -> void: + if index < 0 or index >= _cand_ids.size() or _runner == null: + return + var nodes: Array = _runner.nodes() + var node := _node_by_id(int(_cand_ids[index]), nodes) + if node.is_empty(): + return + var pos: Vector2 = _layout(nodes, _canvas)[node] + var nid := _candidate_at(pos) # 节点中心必命中自身(命中面含节点方块) + if nid >= 0: + _submit_choice(nid) + + func _id_of(node: Dictionary) -> int: # 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth) return _runner.node_id_of(node) -func _type_name(t: String) -> String: - match t: - "battle": return "战斗房" - "reward": return "奖励点" - "rest": return "恢复点" - "boss": return "Boss 房" - "shop": return "商店房" - return t - - # ---- 整图自绘(程序化占位——分层布局:层 = 入口 BFS 深度,同层垂直排布)---- func _draw_graph(canvas: Control) -> void: @@ -121,7 +312,7 @@ func _draw_graph(canvas: Control) -> void: # 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析 var visited: Array = _runner.visited_ids() var cand_ids: Array = _runner.next_candidate_ids() - # 连线(通道)先行画(方块覆盖其上) + # 连线(通道)先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗) for node in nodes: var from: Vector2 = pos[node] for tid in _out_ids(node, nodes): @@ -130,8 +321,9 @@ func _draw_graph(canvas: Control) -> void: continue var to: Vector2 = pos[target] var lit: bool = _id_of(node) == current_id and cand_ids.has(tid) + var hovered: bool = lit and _hover_id == tid var col := COL_PASSAGE_LIT if lit else COL_PASSAGE - var width := 3.0 if lit else 1.5 + var width := 4.0 if hovered else (3.0 if lit else 1.5) _draw_passage(canvas, from, to, col, width) # 房间方块 for node in nodes: @@ -143,13 +335,14 @@ func _draw_graph(canvas: Control) -> void: col = COL_ROOM_DONE if nid == current_id: col = COL_ROOM_CURRENT - var rect := Rect2(p - Vector2(26, 18), Vector2(52, 36)) + var rect := Rect2(p - NODE_HALF, NODE_HALF * 2.0) canvas.draw_rect(rect, col) if t == "boss": canvas.draw_rect(rect, COL_BOSS_BORDER, false, 2.0) elif cand_ids.has(nid): - canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 2.0) - var font := ThemeDB.fallback_font + # 悬停预承诺 = 亮金描边加粗(4px),常态候选 = 亮金描边 2px(spec States 悬停预承诺行) + canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 4.0 if _hover_id == nid else 2.0) + var font := get_theme_default_font() var label := String(NODE_LABELS.get(t, "?")) canvas.draw_string(font, p + Vector2(-6, 2), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, UiKit.INK_ROUTE) canvas.draw_string(font, p + Vector2(10, 14), str(nid), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, UiKit.WARM_ROUTE_SUB) @@ -216,8 +409,8 @@ func _node_by_id(nid: int, nodes: Array) -> Dictionary: # 通道画法:房间方块边缘到边缘的水平-折线(占位观感——地牢通道) func _draw_passage(canvas: Control, from: Vector2, to: Vector2, col: Color, width: float) -> void: - var a := from + Vector2(26, 0) - var b := to - Vector2(26, 0) + var a := from + Vector2(NODE_HALF.x, 0) + var b := to - Vector2(NODE_HALF.x, 0) if b.x < a.x: return var mid_x := (a.x + b.x) / 2.0 diff --git a/game/presentation/views/ui_kit.gd b/game/presentation/views/ui_kit.gd index df1d6c0e..88c403b2 100644 --- a/game/presentation/views/ui_kit.gd +++ b/game/presentation/views/ui_kit.gd @@ -106,6 +106,17 @@ static func load_tex(path: String) -> Texture2D: return null +# CJK 随包字体(2026-09-27 用户拍板 Fusion Pixel OFL——CJK 覆盖+授权干净;命名遵 art-bible §8.3 +# 全小写 snake_case 域前缀)。守卫装载同 load_tex 纪律:缺位返回 null(引擎回退字体接手,不炸)。 +const FONT_UI := "res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf" + +static func load_font(path: String) -> Font: + if ResourceLoader.exists(path): + var res: Resource = load(path) + return res as Font + return null + + # ── StyleBox 工厂 ── # margin = (左, 上, 右, 下);border_w = 0 即无边框(根底/遮罩用) static func panel_flat(bg: Color, border: Color, corner: int = 0, border_w: int = 1, margin: Vector4 = Vector4.ZERO) -> StyleBoxFlat: diff --git a/game/project.godot b/game/project.godot index 9ab870b3..a355141e 100644 --- a/game/project.godot +++ b/game/project.godot @@ -26,6 +26,10 @@ window/size/viewport_height=800 window/stretch/mode="canvas_items" window/stretch/aspect="expand" +[gui] + +theme/custom_font="res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf" + [rendering] renderer/rendering_method="gl_compatibility" diff --git a/game/smoke/ui_click_probe.gd b/game/smoke/ui_click_probe.gd index aef57215..f3a4d66f 100644 --- a/game/smoke/ui_click_probe.gd +++ b/game/smoke/ui_click_probe.gd @@ -88,7 +88,7 @@ func _ready() -> void: var t1 := Time.get_ticks_msec() main._start_adventure_from_menu() await get_tree().process_frame - # 停点前移批 2026-09-23:关卡开始 = 入口选路停点——真实按钮路径选入口候选进首战 + # 停点前移批 2026-09-23:关卡开始 = 入口选路停点——press_candidate 图上命中路径选入口候选进首战(候选按钮退役 2026-09-28) #(首格可点 AC 口径随之含入口选路一步:菜单点击 → 停点呈现 → pick → 棋盘安装) if main.route_overlay.visible: main.route_overlay.press_candidate(0) @@ -114,7 +114,7 @@ func _ready() -> void: _flags_placed.clear() continue if main.route_overlay.visible: - # 节点图选路口(零自动推进停点)——真实按钮路径交替选候选(岔路使用行为) + # 节点图选路口(零自动推进停点)——图上命中路径交替选候选(岔路使用行为;布局决策 2 改造) main.route_overlay.press_candidate(_route_clicks % 2) _route_clicks += 1 _flags_placed.clear() diff --git a/game/smoke/ui_probe.gd b/game/smoke/ui_probe.gd index 7bb08c04..996f1a3a 100644 --- a/game/smoke/ui_probe.gd +++ b/game/smoke/ui_probe.gd @@ -62,7 +62,7 @@ func _ready() -> void: await get_tree().process_frame await get_tree().process_frame # ---- 入口选路停点(停点前移批 2026-09-23:关卡开始即出节点图——level_started 后、首战前弹选路层; - # 真实按钮路径 pick 入口单候选进首战)---- + # press_candidate 图上命中路径 pick 入口单候选进首战——候选按钮退役 2026-09-28)---- if not main.route_overlay.visible: problems.append("关卡开始未弹入口选路停点(route_overlay 应可见)") elif main.runner == null or not main.runner.awaiting_choice() \ diff --git a/game/tests/README.md b/game/tests/README.md index fe402c25..d639d919 100644 --- a/game/tests/README.md +++ b/game/tests/README.md @@ -32,6 +32,8 @@ godot --headless --path . res://tests/probes/.tscn timeout 120s godot --headless --script tests/run_all.gd ``` +> 单元/契约套件状态(单元测试与契约层批 2026-09-28):`_suites()` manifest 已登记实跑七套件——data_config(ConfigDB 加载/进度坐标 ceil 解析/fatal 退出码/统一随机流)、battle_settlement(DamageMath 纯函数/DamageController 伤害单据五配方)、contracts(系统间接口契约断言);致命类独立进程 `expect=1`。期望值一律由表读值推导(零玩法数值字面量)、随机只经 RngService 统一流。 + **协议要点**(D2 冒烟协议 + ADR-0004): - 冒烟脚本 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)` - `--script` 模式**不加载 autoload 且不注册全局标识符**(裸引用 `ConfigDB` = parse error)——ConfigDB/RngService 由脚本显式实例化,core 一律构造注入 diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index e2703b71..73392664 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -1,13 +1,15 @@ -# tests/run_all.gd — CI 冒烟统一入口(ADR-0004 双载体的逻辑冒烟调用点) -# 用法:godot --headless --script tests/run_all.gd (CI 侧包装外部超时:timeout 120s …) -# 协议:套件清单逐条"套件 → 期望退出码"(D2 AC-2b manifest);致命类独立进程。 -# 现状:正式 Godot 工程尚未在根目录创建(代码在 game/, -# 其 smoke/smoke_all.gd 96/96 为参考实现)——本入口当前仅验证运行协议自身; -# 正式工程落地后,把套件清单登记进 _suites()。 +# tests/run_all.gd — 单元测试统一入口(ADR-0004 双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b) +# 用法:godot --headless --path game --script res://tests/run_all.gd (CI 侧包装外部超时:timeout 120s …) +# 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。 +# 执行形态(单元测试与契约层批 2026-09-28):逐套件起独立 Godot 子进程实跑 +#(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符, +# 每个测试文件自带显式实例化夹具,见 tests/README 协议要点)。 +# 场景探针(.tscn 载体)由 CI/轻闸单独步骤调用,不经本入口。 extends SceneTree + func _init() -> void: - print("[run_all] formal smoke entry — protocol check") + print("[run_all] unit/contract suite entry — %d suites" % _suites().size()) if DisplayServer.get_name() != "headless": push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)") var failures := 0 @@ -20,22 +22,35 @@ func _init() -> void: print("[run_all] all green (%d suites)" % _suites().size()) quit(0) -# 返回 Array of Dictionary:{ "path": String, "expect": int } -# 正式工程落地后在此登记;探针场景(.tscn 载体)由 CI 单独步骤调用,不经本入口。 + +# 套件清单:{ "path": String, "expect": int };致命类(期望非 0)独立进程断言退出码。 func _suites() -> Array: return [ - # {"path": "res://tests/unit/example_test.gd", "expect": 0}, - # {"path": "res://tests/unit/reward_economy/reward_roll_test.gd", "expect": 0}, - # 致命类(独立进程,期望非 0): - # {"path": "res://tests/unit/d2/fixture_missing_table_test.gd", "expect": 1}, + {"path": "res://tests/unit/example_test.gd", "expect": 0}, + {"path": "res://tests/unit/data_config/config_db_test.gd", "expect": 0}, + {"path": "res://tests/unit/data_config/config_db_fatal_oob_test.gd", "expect": 1}, + {"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0}, + {"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0}, + {"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0}, + {"path": "res://tests/unit/contracts/system_contracts_test.gd", "expect": 0}, + {"path": "res://tests/unit/presentation/ui_font_asset_test.gd", "expect": 0}, + {"path": "res://tests/unit/presentation/terrain_tile_sets_test.gd", "expect": 0}, ] + func _run_suite(suite: Dictionary) -> int: - # SceneTree 脚本内不能直接以 OS.execute 递归调 godot 的成熟形态留待正式工程 - # (需解析 godot 二进制路径);当前协议检查阶段只断言清单形态合法。 var path: String = suite.get("path", "") + var expect: int = int(suite.get("expect", 0)) if path.is_empty(): push_error("[run_all] suite missing path") return 1 - print("[run_all] registered: %s (expect=%d)" % [path, suite.get("expect", 0)]) + var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://"), "--script", path]) + var out := PackedStringArray() + var code: int = OS.execute(OS.get_executable_path(), args, out, true, false) + var text := "\n".join(out) + print(text) + if code != expect: + push_error("[run_all] FAIL %s — 退出码 %d ≠ 期望 %d" % [path, code, expect]) + return 1 + print("[run_all] PASS %s (exit=%d)" % [path, code]) return 0 diff --git a/game/tests/run_all.gd.uid b/game/tests/run_all.gd.uid new file mode 100644 index 00000000..04e8e4b0 --- /dev/null +++ b/game/tests/run_all.gd.uid @@ -0,0 +1 @@ +uid://f1xscrew4ser diff --git a/game/tests/unit/battle_settlement/damage_controller_test.gd b/game/tests/unit/battle_settlement/damage_controller_test.gd new file mode 100644 index 00000000..25513331 --- /dev/null +++ b/game/tests/unit/battle_settlement/damage_controller_test.gd @@ -0,0 +1,351 @@ +# tests/unit/battle_settlement/damage_controller_test.gd — 伤害控制器·伤害单据五配方单元测试 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/damage_controller_test.gd +# 覆盖:hero_attack / enemy_skill / board_typed / board_blast / board_flat_hero 五配方 + +# 结账时机双模式(A 即结 / B 攒批 flush)+ 死亡作废(来源敌死/主角倒下 halt)+ requires 前置门 + +# 池乘区收口(clamped_pool:池内加算→clamp;ac 型不入池)+ AC 合流(override/表值兜底/状态叠层)+ +# 行协议公共字段(architecture.md API Boundaries B1→D1)+ 同种子逐位复现。 +# 红线:期望值由表读值推导(零玩法数值字面量);零自建 RNG(RngService 统一流,夹具种子仅求复现)。 +# 断言口径:归因等式在「无敌人死亡」时严格成立;有死亡时降为下界(超额伤害溢出属合法语义)。 +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") +const RngServiceScript := preload("res://core/rng_service.gd") +const UnitContainerScript := preload("res://core/unit_container.gd") +const StatusContainerScript := preload("res://core/status_container.gd") +const DamageControllerScript := preload("res://core/damage_controller.gd") + +const TEST_SEED := 20260928 # 夹具种子(基建值非玩法数值——回放确定性契约的输入) + +var _failures := 0 +var _checks := 0 +var _rows: Array = [] # 当前 run 的行协议收集(_on_row 绑定法——避 lambda 多行控制流坑) + + +func _init() -> void: + var db = ConfigDBScript.new() + var load_ok: bool = db.load_tables("res://data/tables", true) + _check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0) + if load_ok: + _test_five_recipes_and_replay(db) + _test_policy_immediate_mode(db) + _test_source_death_void(db) + _test_requires_gate_and_zero(db) + _test_hero_death_halt(db) + _test_ac_merge(db) + _test_pool_clamp(db) + if _failures > 0: + push_error("[damage_controller_test] %d/%d 项失败" % [_failures, _checks]) + quit(1) + return + print("[damage_controller_test] all green (%d checks)" % _checks) + quit(0) + + +func _check(desc: String, ok: bool) -> void: + _checks += 1 + if ok: + print(" [PASS] " + desc) + else: + _failures += 1 + push_error(" [FAIL] " + desc) + + +# ---- 夹具 ---- + +func _on_row(type, source_class, origin_cell, ri, display, value, extra, highlight) -> void: + _rows.append({"type": type, "source_class": source_class, "origin_cell": origin_cell, + "round_index": ri, "display": display, "value": value, "extra": extra, "highlight": highlight}) + + +func _harness(db): + var rng = RngServiceScript.new() + rng.set_master_seed(TEST_SEED) + var units = UnitContainerScript.new(db) + var effects = StatusContainerScript.new(db) + var ctrl = DamageControllerScript.new(db, rng, units, effects) + ctrl.emit_row_cb = Callable(self, "_on_row") + effects.notify_battle_start() + var spawn_ok: bool = units.spawn(1, 0, int(db.hero.max_hp)) + return {"db": db, "rng": rng, "units": units, "effects": effects, "ctrl": ctrl, "spawn_ok": spawn_ok} + + +func _row_by_display_prefix(prefix: String) -> Dictionary: + for r in _rows: + if String(r.display).begins_with(prefix): + return r + return {} + + +# ---- 五配方一次全流程 + 双跑逐位复现 ---- + +func _run_recipe_sequence(db) -> Array: + _rows = [] + var h = _harness(db) + var units = h.units + var ctrl = h.ctrl + if not h.spawn_ok: + _check("前置:units.spawn(1,0) 成功(遭遇行在案)", false) + return [] + var hero_before: int = int(units.hero_unit().hp) + var enemy_hp_before: Array = [] + for e in units.all_enemies(): + enemy_hp_before.append(int(e.hp)) + var front = units.default_target() + var threshold_expected: int = ctrl.defender_ac(front) + int(h.db.settlement.attack_bonus) + var atk_type := "" + var eff_keys: Array = h.db.settlement.armor_effectiveness.keys() + if eff_keys.size() > 0: + atk_type = String(eff_keys[0]) + var floor_v: int = int(h.db.settlement.damage_floor) + + var t1: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", + "T1主角攻击", "战斗段", int(h.db.hero.atk), DamageControllerScript.TARGET_FRONT_ALIVE, 1)) + # 敌方技来源取后位敌(前位是攻击/直伤/群伤的靶位——来源与靶位分离,免死置干扰配方 redeemed 断言) + var alive_list: Array = units.query_alive("enemy") + var src0: Dictionary = alive_list[alive_list.size() - 1] + var ticket2: Dictionary = ctrl.open_ticket(DamageControllerScript.KIND_ENEMY_SKILL, "enemy_action", + "T2敌方技", "战斗段", int(src0.atk), DamageControllerScript.TARGET_HERO, 1, + Vector2i(-1, -1), "", floor_v, "", {"skill_id": "fixture_skill", "baseline": int(src0.atk)}) + ticket2["source_unit"] = src0 + var t2: int = ctrl.submit(ticket2) + var t3: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_TYPED, "clear_reward", + "T3克制直伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_FRONT_ALIVE, 1, + Vector2i(-1, -1), atk_type)) + var t4: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", + "T4群伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_ALL_ALIVE, 1)) + var t5: int = ctrl.submit(ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", + "T5反噬", "棋盘兑付", floor_v, DamageControllerScript.TARGET_HERO, 1, + Vector2i(-1, -1), "", 0, "", {"terrain": "fixture"})) + ctrl.flush() + + var outcomes_redeemed := true + for id in [t1, t2, t3, t4, t5]: + if ctrl.outcome(int(id)) != "redeemed": + outcomes_redeemed = false + _check("五配方单据 flush 后全部 redeemed(B 模式段界结账)", outcomes_redeemed) + + var hero_after: int = int(units.hero_unit().hp) + var r2: Dictionary = _row_by_display_prefix("T2敌方技") + var r5: Dictionary = _row_by_display_prefix("T5反噬") + _check("主角扣血 = 敌方技行值 + 反噬行值(逐行归因闭合)", + hero_before - hero_after == int(r2.get("value", -1)) + int(r5.get("value", -1))) + _check("敌方技行值 ≥ 下限 + rider(rider 后置不进乘区)", int(r2.get("value", 0)) >= floor_v + floor_v) + _check("反噬行值 = 落地下限(中性无类型表值)", int(r5.get("value", 0)) == floor_v) + + var r1: Dictionary = _row_by_display_prefix("T1主角攻击") + var ex1: Dictionary = r1.get("extra", {}) + _check("主角攻击行 AC = 防守方 AC + 攻击加成(开单前快照一致)", int(ex1.get("ac", -1)) == threshold_expected) + _check("掷点 ∈ [1, 骰面]", int(ex1.get("roll", -1)) >= 1 and int(ex1.get("roll", -1)) <= int(h.db.settlement.roll_faces)) + var tier_ok := false + for dv in h.db.settlement.roll_tier_discounts: + if int(dv) == int(ex1.get("tier", -1)): + tier_ok = true + if int(ex1.get("tier", -1)) == 100: + tier_ok = true + _check("掷点档位 ∈ 表档位值 ∪ 满额", tier_ok) + + var r4: Dictionary = _row_by_display_prefix("T4群伤") + var alive_at_blast: int = int(r4.get("extra", {}).get("count", -1)) + var any_enemy_dead := false + var enemy_drop_total := 0 + var idx := 0 + for e in units.all_enemies(): + var drop: int = enemy_hp_before[idx] - int(e.hp) + enemy_drop_total += maxi(0, drop) + if not bool(e.alive): + any_enemy_dead = true + idx += 1 + var attack_landed: int = int(r1.get("value", 0)) + var typed_landed: int = int(_row_by_display_prefix("T3克制直伤").get("value", 0)) + if any_enemy_dead: + _check("敌方总扣血 ≥ 攻击+克制+群伤(死亡溢出合法,下界归因)", + enemy_drop_total >= attack_landed + typed_landed + int(r4.get("value", 0)) * maxi(0, alive_at_blast)) + else: + _check("敌方总扣血 = 攻击 + 克制直伤 + 群伤×存活数(逐行归因闭合)", + enemy_drop_total == attack_landed + typed_landed + int(r4.get("value", 0)) * alive_at_blast) + + var protocol_ok := true + for r in _rows: + if String(r.type) != "damage": + continue + if String(r.source_class).is_empty() or String(r.display).is_empty(): + protocol_ok = false + if int(r.value) < int(h.db.settlement.damage_floor): + protocol_ok = false + if not (r.extra is Dictionary) or not r.extra.has("target"): + protocol_ok = false + _check("行协议公共字段封闭(type/source_class/display/value≥下限/extra.target)", protocol_ok) + + # 复现序列(供双跑对拍):逐行 (display, value) + var seq: Array = [] + for r in _rows: + seq.append([String(r.display), int(r.value)]) + return seq + + +func _test_five_recipes_and_replay(db) -> void: + var run1: Array = _run_recipe_sequence(db) + _check("五配方序列产出 5 行伤害行", run1.size() == 5) + var run2: Array = _run_recipe_sequence(db) + _check("同种子双跑行序列逐位一致(回放确定性契约)", run1 == run2) + + +# ---- A 模式(开单即结——回切位) ---- + +func _test_policy_immediate_mode(db) -> void: + _rows = [] + var h = _harness(db) + if not h.spawn_ok: + return + h.ctrl.policy = DamageControllerScript.POLICY_IMMEDIATE + var floor_v: int = int(h.db.settlement.damage_floor) + var t: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", + "A模式反噬", "棋盘兑付", floor_v, DamageControllerScript.TARGET_HERO, 1)) + _check("A 模式开单即结(无 flush 即 redeemed)", h.ctrl.outcome(t) == "redeemed") + _check("A 模式主角即时扣血", int(h.units.hero_unit().hp) == int(h.db.hero.max_hp) - floor_v) + + +# ---- 来源敌死亡:队列中未结单据作废(死亡即不动手) ---- + +func _test_source_death_void(db) -> void: + _rows = [] + var h = _harness(db) + if not h.spawn_ok: + return + var alive_list: Array = h.units.query_alive("enemy") + var src: Dictionary = alive_list[alive_list.size() - 1] + var hero_before: int = int(h.units.hero_unit().hp) + var ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_ENEMY_SKILL, "enemy_action", + "亡敌未结单", "战斗段", int(src.atk), DamageControllerScript.TARGET_HERO, 1) + ticket["source_unit"] = src + var t: int = h.ctrl.submit(ticket) + h.units.apply_damage(src, int(src.max_hp)) # 敌当场倒下 → unit_died → 该敌未结单作废 + h.ctrl.flush() + _check("来源敌死亡后其未结单据 voided(死亡即不动手)", h.ctrl.outcome(t) == "voided") + _check("作废单零行零扣血", int(h.units.hero_unit().hp) == hero_before and _rows.is_empty()) + + +# ---- requires 前置门:前置单未实际有效兑付 → 后继单静默作废;无存活敌 blast = 合法零兑付 ---- + +func _test_requires_gate_and_zero(db) -> void: + _rows = [] + var h = _harness(db) + if not h.spawn_ok: + return + var floor_v: int = int(h.db.settlement.damage_floor) + # 先清场(flush_submit 同步开单——清图批专用通道的契约面同时被覆盖) + var front_hp: int = int(h.units.query_alive("enemy")[0].max_hp) + var big: int = front_hp * 10 + var t_clear: int = h.ctrl.flush_submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", + "清场群伤", "棋盘兑付", big, DamageControllerScript.TARGET_ALL_ALIVE, 1)) + _check("flush_submit 同步开单即结(清图批契约)", h.ctrl.outcome(t_clear) == "redeemed" and h.units.query_alive("enemy").is_empty()) + var t_zero: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_BLAST, "clear_reward", + "空场群伤", "棋盘兑付", floor_v, DamageControllerScript.TARGET_ALL_ALIVE, 1)) + var gated_ticket: Dictionary = h.ctrl.open_ticket(DamageControllerScript.KIND_HERO_ATTACK, "hero_attack", + "前置门攻击", "战斗段", int(h.db.hero.atk), DamageControllerScript.TARGET_FRONT_ALIVE, 1) + gated_ticket["requires"] = t_zero + var t_gated: int = h.ctrl.submit(gated_ticket) + h.ctrl.flush() + _check("无存活敌 blast = 合法零兑付(zero 非 voided)", h.ctrl.outcome(t_zero) == "zero") + _check("前置单未实际有效 → 后继单 voided(requires 门)", h.ctrl.outcome(t_gated) == "voided") + + +# ---- 主角倒下:flush 余单全部作废(_halted 终止) ---- + +func _test_hero_death_halt(db) -> void: + _rows = [] + var h = _harness(db) + if not h.spawn_ok: + return + var max_hp: int = int(h.db.hero.max_hp) + var t_kill: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", + "致死反噬", "棋盘兑付", max_hp, DamageControllerScript.TARGET_HERO, 1)) + var t_after: int = h.ctrl.submit(h.ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", + "余单", "棋盘兑付", 1, DamageControllerScript.TARGET_HERO, 2)) + h.ctrl.flush() + _check("致死单 redeemed 且主角 hp 归零", h.ctrl.outcome(t_kill) == "redeemed" and int(h.units.hero_unit().hp) == 0) + _check("主角倒下后余单 voided(flush 终止)", h.ctrl.outcome(t_after) == "voided") + + +# ---- AC 合流:override 入参 / 表值兜底 / ac 型状态叠层(不入池) ---- + +func _test_ac_merge(db) -> void: + _rows = [] + var h = _harness(db) + if not h.spawn_ok: + return + var base_ac: int = int(h.db.hero.armor_class) + _check("缺省兜底:defender_ac(hero) = hero 表值", h.ctrl.defender_ac(h.units.hero_unit()) == base_ac) + var override_v: int = base_ac + int(h.db.settlement.damage_floor) + h.ctrl.set_hero_ac_override(override_v) + _check("override 合流入参生效", h.ctrl.defender_ac(h.units.hero_unit()) == override_v) + # ac 型状态效果叠其上(找 hero 侧 unit=ac 行) + var ac_eff := {} + for row in h.db.status_effects.rows: + if String(row.get("unit", "")) == "ac" and String(row.get("target_side", "")) == "hero": + ac_eff = row + break + if ac_eff.is_empty(): + _check("前置:状态表存在 hero 侧 ac 型行", false) + return + var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0} + h.effects.apply_effect(String(ac_eff.effect_id), src) + var expected: int = override_v + int(ac_eff.magnitude) + _check("ac 型效果叠层入 AC(层数×幅度,不入池)", h.ctrl.defender_ac(h.units.hero_unit()) == expected) + + +# ---- 池乘区收口:池内加算 → clamp;ac 型不入池 ---- + +func _test_pool_clamp(db) -> void: + _rows = [] + var h = _harness(db) + if not h.spawn_ok: + return + var lo: int = int(h.db.settlement.pool_clamp_lo) + var hi: int = int(h.db.settlement.pool_clamp_hi) + _check("空效果池读 0(表 clamp 域含 0 前提)", h.ctrl.clamped_pool("hero_offense") == 0 and lo <= 0 and hi >= 0) + # 找 hero_offense 池的 percent 型 hero 侧效果行 + var pool_eff := {} + for row in h.db.status_effects.rows: + var eid := String(row.get("effect_id", "")) + if String(row.get("unit", "")) == "percent" and String(row.get("target_side", "")) == "hero" \ + and String(h.db.settlement.effect_pool_map.get(eid, "")) == "hero_offense": + pool_eff = row + break + if pool_eff.is_empty(): + _check("前置:hero_offense 池存在 percent 型 hero 侧行", false) + return + var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0} + var first_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src) + var expected1: int = clampi(int(pool_eff.magnitude), lo, hi) + _check("池值 = 单实例 L×m 加算后 clamp", first_ok and h.ctrl.clamped_pool("hero_offense") == expected1) + var second_ok: bool = h.effects.apply_effect(String(pool_eff.effect_id), src) + _check("refresh 政策重复授予受理且层数恒 1(叠层改续时裁决)——池值不变", + second_ok and h.ctrl.clamped_pool("hero_offense") == expected1) + # ac 型效果不入 percent 池 + var ac_eff2 := {} + for row2 in h.db.status_effects.rows: + if String(row2.get("unit", "")) == "ac" and String(row2.get("target_side", "")) == "hero": + ac_eff2 = row2 + break + if not ac_eff2.is_empty(): + var before: int = h.ctrl.clamped_pool("hero_offense") + h.effects.apply_effect(String(ac_eff2.effect_id), src) + _check("ac 型效果不入池(percent 池隔离)", h.ctrl.clamped_pool("hero_offense") == before) + # stack 政策效果:全新容器授予至满层 → 再授拒绝(满层拒绝原子——容器零写入) + var stack_eff := {} + for row3 in h.db.status_effects.rows: + if String(row3.get("stack_policy", "")) == "stack": + stack_eff = row3 + break + if stack_eff.is_empty(): + _check("前置:状态表存在 stack 政策行", false) + return + var effects2 = StatusContainerScript.new(h.db) + effects2.notify_battle_start() + var granted := 0 + var max_n: int = int(stack_eff.max_stack) + for i in range(max_n + 1): + if effects2.apply_effect(String(stack_eff.effect_id), src): + granted += 1 + _check("stack 政策恰授予 max_stack 层后拒绝(满层拒绝原子)", granted == max_n) diff --git a/game/tests/unit/battle_settlement/damage_controller_test.gd.uid b/game/tests/unit/battle_settlement/damage_controller_test.gd.uid new file mode 100644 index 00000000..8418a3e1 --- /dev/null +++ b/game/tests/unit/battle_settlement/damage_controller_test.gd.uid @@ -0,0 +1 @@ +uid://bg3nmj4y2rxxk diff --git a/game/tests/unit/battle_settlement/damage_math_test.gd b/game/tests/unit/battle_settlement/damage_math_test.gd new file mode 100644 index 00000000..e1d5a294 --- /dev/null +++ b/game/tests/unit/battle_settlement/damage_math_test.gd @@ -0,0 +1,102 @@ +# tests/unit/battle_settlement/damage_math_test.gd — 战斗结算·伤害公式族纯函数单元测试 +# 运行:godot --headless --path game --script res://tests/unit/battle_settlement/damage_math_test.gd +# 协议:DamageMath = 零状态纯算术宿主(headless 可跑);掷点档位参数全读 settlement 表(零玩法数值字面量)。 +# 注:0/100 等出现在断言中的数值 = 语义常量(中性池/百分比满额),非玩法行值。 +extends SceneTree + +const DamageMathScript := preload("res://core/damage_math.gd") +const ConfigDBScript := preload("res://core/config_db.gd") + +var _failures := 0 +var _checks := 0 +var db + + +func _init() -> void: + db = ConfigDBScript.new() + var load_ok: bool = db.load_tables("res://data/tables", true) + _check("前置:settlement 表加载零 error", load_ok and db.diagnostics_count("error") == 0) + if load_ok: + _test_pool_damage() + _test_counter_component() + _test_roll_discount_percent() + _test_rolled_component() + if _failures > 0: + push_error("[damage_math_test] %d/%d 项失败" % [_failures, _checks]) + quit(1) + return + print("[damage_math_test] all green (%d checks)" % _checks) + quit(0) + + +func _check(desc: String, ok: bool) -> void: + _checks += 1 + if ok: + print(" [PASS] " + desc) + else: + _failures += 1 + push_error(" [FAIL] " + desc) + + +func _floor_val() -> int: + return int(db.settlement.damage_floor) + + +# ---- F2 乘区:池内加算 → 池间乘算 → floor(单一取整规则)---- + +func _test_pool_damage() -> void: + var d := _floor_val() + 9 # 表值推导的任意正探针量(非玩法行值) + _check("中性双池 (0,0) 原样透传", DamageMathScript.pool_damage(d, 0, 0) == d) + _check("负敌方池压低不增高", DamageMathScript.pool_damage(d, 0, int(db.settlement.pool_clamp_lo)) <= d) + _check("正主角池抬升不低于原值", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_hi), 0) >= d) + _check("主角池单调不减(+50% vs +49% 探针档)", DamageMathScript.pool_damage(d, 50, 0) >= DamageMathScript.pool_damage(d, 49, 0)) + _check("对称性:池值互换结果不变(乘算交换)", DamageMathScript.pool_damage(d, 30, -20) == DamageMathScript.pool_damage(d, -20, 30)) + _check("输出非负(maxi(0,·) 封底)", DamageMathScript.pool_damage(d, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_lo)) >= 0) + + +# ---- 克制分量:空桶不吃修正恒 0;落地下限保底([1,+∞) 全来源)---- + +func _test_counter_component() -> void: + var floor_v := _floor_val() + _check("空桶(点数≤0)恒 0 不吃修正", DamageMathScript.counter_component(0, floor_v, floor_v) == 0 + and DamageMathScript.counter_component(-1, 5, floor_v) == 0) + _check("正向修正直加", DamageMathScript.counter_component(floor_v, floor_v, floor_v) == floor_v * 2) + _check("负修正触底保落地伤害下限", DamageMathScript.counter_component(floor_v, -floor_v * 10, floor_v) == floor_v) + _check("下限参数缺省 = 0(无下限语义面)", DamageMathScript.counter_component(floor_v, -floor_v * 10) == 0) + + +# ---- 掷点折扣查档(纯函数):满阈值恒全额;档位边界/末档语义与表数组一致 ---- + +func _test_roll_discount_percent() -> void: + var threshold := 10 # 查档探针阈值(夹具值——档位参数本身全部来自表) + var bounds: Array = db.settlement.roll_tier_bounds + var discounts: Array = db.settlement.roll_tier_discounts + _check("前置:档位数 = 边界数 + 1(查档契约——档数 = 边界数 + 1)", discounts.size() == bounds.size() + 1 and discounts.size() > 0) + _check("掷点 ≥ 阈值恒全额 100", DamageMathScript.roll_discount_percent(threshold, threshold, bounds, discounts) == 100 + and DamageMathScript.roll_discount_percent(threshold + 5, threshold, bounds, discounts) == 100) + # 缺口 = 1:落首档(缺口 ∈ [1, bounds[0]] → discounts[0]) + var tier0_expected: int = int(discounts[0]) + if int(bounds[0]) >= 1: + _check("缺口 1 落首档", DamageMathScript.roll_discount_percent(threshold - 1, threshold, bounds, discounts) == tier0_expected) + # 缺口超末边界:落末档 + var last_expected: int = int(discounts[-1]) + _check("缺口超末边界落末档", DamageMathScript.roll_discount_percent(threshold - int(bounds[-1]) - 1, threshold, bounds, discounts) == last_expected) + # 档位值降序(重档折扣更深)——表自检 + var descending := true + for i in range(discounts.size() - 1): + if int(discounts[i]) < int(discounts[i + 1]): + descending = false + _check("档位值随档位加深不升(折扣单调)", descending) + + +# ---- 吃掷点分量算术:折扣→(暴击)→floor→+克制修正(单一取整链)---- + +func _test_rolled_component() -> void: + var base := float(_floor_val() + 7) # 表值推导探针基线 + var crit_mult: int = int(db.settlement.crit_multiplier) + _check("满额折扣无暴击 = floor(基线) + 修正 0", DamageMathScript.rolled_component(base, 100, false, crit_mult, 0) == int(floor(base))) + _check("满额折扣暴击按表倍率放大", DamageMathScript.rolled_component(base, 100, true, crit_mult, 0) == int(floor(base * float(crit_mult) / 100.0))) + _check("折扣降低不高于满额", DamageMathScript.rolled_component(base, 50, false, crit_mult, 0) <= DamageMathScript.rolled_component(base, 100, false, crit_mult, 0)) + _check("克制修正加在 floor 之后(加法核)", DamageMathScript.rolled_component(base, 100, false, crit_mult, _floor_val()) == int(floor(base)) + _floor_val()) + _check("复合序 = 基线×折扣%×暴击%(floor 只在链末)", + DamageMathScript.rolled_component(base, 50, true, crit_mult, 0) == int(floor(base * 50.0 / 100.0 * float(crit_mult) / 100.0))) diff --git a/game/tests/unit/battle_settlement/damage_math_test.gd.uid b/game/tests/unit/battle_settlement/damage_math_test.gd.uid new file mode 100644 index 00000000..aefa7bdd --- /dev/null +++ b/game/tests/unit/battle_settlement/damage_math_test.gd.uid @@ -0,0 +1 @@ +uid://ce116wl7vbdiu diff --git a/game/tests/unit/contracts/system_contracts_test.gd b/game/tests/unit/contracts/system_contracts_test.gd new file mode 100644 index 00000000..6f332bf1 --- /dev/null +++ b/game/tests/unit/contracts/system_contracts_test.gd @@ -0,0 +1,146 @@ +# tests/unit/contracts/system_contracts_test.gd — 系统间接口契约断言(宁缺勿错——只断言已定稿行为) +# 运行:godot --headless --path game --script res://tests/unit/contracts/system_contracts_test.gd +# 契约线清单来源:design/dev/architecture/architecture.md「API Boundaries」表 + 各系统 AC 前言(规范源)。 +# 覆盖面: +# 各系统→D2 ConfigDB 类型化单点(TABLE_MANIFEST 词表封闭——伤害单据 kind/目标规则/结账政策常量); +# D2↔B1 掷点用途标签 ∈ RngService 封闭词表(EC12——settlement.armor_roll_stream 成员性); +# B1→D1 行协议公共字段形态(open_ticket 九栏缺省 + 协议公共键); +# B4→B3 增益授予布尔受理口径(域闭 EC1 / 未知 id EC2 / 受理后投影)。 +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") +const RngServiceScript := preload("res://core/rng_service.gd") +const StatusContainerScript := preload("res://core/status_container.gd") +const DamageControllerScript := preload("res://core/damage_controller.gd") + +var _failures := 0 +var _checks := 0 + + +func _init() -> void: + var db = ConfigDBScript.new() + var load_ok: bool = db.load_tables("res://data/tables", true) + _check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0) + if load_ok: + _test_damage_controller_vocabularies() + _test_rng_tag_closure(db) + _test_ticket_nine_columns() + _test_status_boolean_acceptance(db) + if _failures > 0: + push_error("[system_contracts_test] %d/%d 项失败" % [_failures, _checks]) + quit(1) + return + print("[system_contracts_test] all green (%d checks)" % _checks) + quit(0) + + +func _check(desc: String, ok: bool) -> void: + _checks += 1 + if ok: + print(" [PASS] " + desc) + else: + _failures += 1 + push_error(" [FAIL] " + desc) + + +# ---- B1 兑付配方词表:五配方/三目标规则/双结账政策——常量非空且互异(封闭枚举负空间) ---- + +func _test_damage_controller_vocabularies() -> void: + var kinds: Array = [ + DamageControllerScript.KIND_HERO_ATTACK, DamageControllerScript.KIND_ENEMY_SKILL, + DamageControllerScript.KIND_BOARD_TYPED, DamageControllerScript.KIND_BOARD_BLAST, + DamageControllerScript.KIND_BOARD_FLAT_HERO, + ] + var nonempty := true + for k in kinds: + if String(k).is_empty(): + nonempty = false + _check("五配方常量非空(开单面唯一分派键)", nonempty) + var unique_kinds: Dictionary = {} + for k in kinds: + unique_kinds[String(k)] = true + _check("五配方常量互异(词表封闭无别名)", unique_kinds.size() == kinds.size()) + var targets: Array = [ + DamageControllerScript.TARGET_FRONT_ALIVE, DamageControllerScript.TARGET_HERO, + DamageControllerScript.TARGET_ALL_ALIVE, + ] + var unique_targets: Dictionary = {} + for t in targets: + unique_targets[String(t)] = true + _check("三目标规则常量互异(结账时刻现场解析词表)", unique_targets.size() == targets.size()) + _check("双结账政策常量互异(A 即结 / B 攒批回切位)", + String(DamageControllerScript.POLICY_IMMEDIATE) != String(DamageControllerScript.POLICY_SEGMENT)) + + +# ---- D2↔B1:掷点用途标签 ∈ RngService 封闭词表(EC12——跨系统字面量契约的成员性断言) ---- + +func _test_rng_tag_closure(db) -> void: + var stream_label: String = String(db.settlement.armor_roll_stream) + _check("settlement.armor_roll_stream ∈ RngService 封闭标签词表(EC12)", + RngServiceScript.PURPOSE_TAGS.has(stream_label)) + _check("RngService 用途标签词表非空且互异(封闭语义由流改标签)", + RngServiceScript.PURPOSE_TAGS.size() > 0) + + +# ---- B1→D1 行协议:单据九栏缺省齐备 + 协议公共键形态 ---- + +func _test_ticket_nine_columns() -> void: + var ctrl = _bare_controller() + var t: Dictionary = ctrl.open_ticket(DamageControllerScript.KIND_BOARD_FLAT_HERO, "mine_hit", + "契约探针", "棋盘兑付", 1, DamageControllerScript.TARGET_HERO, 0) + var columns := ["kind", "source_class", "origin_cell", "round_index", "display", + "channel", "attack_type", "base_value", "target_rule", "rider", "highlight", "context", + "requires", "source_unit"] + var all_present := true + for c in columns: + if not t.has(c): + all_present = false + _check("单据九栏 + 可选两栏缺省齐备(开单面只写差异栏前提)", all_present) + _check("缺省无类型轴(attack_type 空 = 恒不查克制矩阵)", String(t.attack_type) == "") + _check("缺省无前置依赖(requires = -1)", int(t.requires) == -1) + + +func _bare_controller(): + var db = ConfigDBScript.new() + # 只取 open_ticket 纯构造面——容器/依赖传桩(构造期不消费) + var units = UnitContainerStub.new() + var effects = StatusContainerStub.new() + return DamageControllerScript.new(db, RngStub.new(), units, effects) + + +# 桩容器:构造期零消费(DamageController._init 只 connect unit_died 信号——桩需同名信号) +class UnitContainerStub: + signal unit_died(u: Dictionary) + + +class StatusContainerStub: + pass + + +class RngStub: + pass + + +# ---- B4→B3:增益授予布尔受理口径(受理/拒绝二元面 + 投影一致性) ---- + +func _test_status_boolean_acceptance(db) -> void: + var effects = StatusContainerScript.new(db) + # EC1 域闭:notify_battle_start 前授予 = false(受理面封闭) + var any_row: Dictionary = {} + if db.status_effects.rows.size() > 0: + any_row = db.status_effects.rows[0] + _check("前置:状态表非空", not any_row.is_empty()) + if any_row.is_empty(): + return + var probe_id: String = String(any_row.effect_id) + var src := {"source_class": "safe_cell", "origin_cell": Vector2i(-1, -1), "round_index": 0} + _check("域闭授予拒绝(EC1——战斗域外 apply_effect = false)", effects.apply_effect(probe_id, src) == false) + _check("未知 effect_id 拒绝(EC2)", effects.apply_effect("no_such_effect_id__", src) == false) + effects.notify_battle_start() + _check("开域后受理已知 id(布尔受理口径 true 通道)", effects.apply_effect(probe_id, src) == true) + var active: Array = effects.query_active(String(any_row.target_side)) + var projected := false + for a in active: + if String(a.effect_id) == probe_id: + projected = int(a.effective_value) == int(a.layers) * int(a.snap_magnitude) + _check("受理后投影 effective_value = 层数×授予时快照(抗热重载 EC9 面)", projected) diff --git a/game/tests/unit/contracts/system_contracts_test.gd.uid b/game/tests/unit/contracts/system_contracts_test.gd.uid new file mode 100644 index 00000000..73afbf05 --- /dev/null +++ b/game/tests/unit/contracts/system_contracts_test.gd.uid @@ -0,0 +1 @@ +uid://k4da3suvcbn diff --git a/game/tests/unit/data_config/config_db_fatal_oob_test.gd b/game/tests/unit/data_config/config_db_fatal_oob_test.gd new file mode 100644 index 00000000..dc1e6892 --- /dev/null +++ b/game/tests/unit/data_config/config_db_fatal_oob_test.gd @@ -0,0 +1,37 @@ +# tests/unit/data_config/config_db_fatal_oob_test.gd — 致命类用例(独立进程,期望退出码 1) +# 进度坐标低于合法域(关卡序号 <1)触发 ConfigDB fail-fast(运行期数据致命——2026-09-18 用户拍板)。 +# 运行:godot --headless --path game --script res://tests/unit/data_config/config_db_fatal_oob_test.gd +# 退出码协议:1 = fatal 通道正确触发(预期);2 = fatal 未触发(回归信号,与 1 区分便于诊断)。 +# 时序注:_fatal 的 quit 通道依赖 Engine.get_main_loop() 已注册主循环——--script 的 _init 期未注册, +# 越域请求延后到首帧(call_deferred)再触发,保 quit(1) 通道而非 OS.crash 兜底。 +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") + +var _db + + +func _init() -> void: + _db = ConfigDBScript.new() + var load_ok: bool = _db.load_tables("res://data/tables", true) + if not load_ok: + push_error("[fatal_oob_test] 前置表加载失败,无法测 fatal 通道") + quit(2) + return + _db.reset_diagnostics() + call_deferred("_trigger_oob") + + +func _trigger_oob() -> void: + # 越域请求(关卡序号 0 < 合法域 ≥1)——_fatal 内部 push fatal 诊断并 quit(1)(帧末退出) + var got: Dictionary = _db.resolve_board_gen(0, 0) + if _db.diagnostics_count("fatal") >= 1: + print("[fatal_oob_test] fatal 通道已触发(诊断在案)") + quit(1) # 与 _fatal 的 quit(1) 同码双保险 + return + # 兜底:fatal 未触发 = 回归——显式退出码 2 与预期 1 区分 + if got.is_empty(): + push_error("[fatal_oob_test] 返回空但无 fatal 诊断——fail-fast 通道静默化(回归)") + else: + push_error("[fatal_oob_test] 越域请求未触发 fatal 且返回了行——fail-fast 失效(回归)") + quit(2) diff --git a/game/tests/unit/data_config/config_db_fatal_oob_test.gd.uid b/game/tests/unit/data_config/config_db_fatal_oob_test.gd.uid new file mode 100644 index 00000000..a39989eb --- /dev/null +++ b/game/tests/unit/data_config/config_db_fatal_oob_test.gd.uid @@ -0,0 +1 @@ +uid://b5rujibws7uqp diff --git a/game/tests/unit/data_config/config_db_test.gd b/game/tests/unit/data_config/config_db_test.gd new file mode 100644 index 00000000..5362bd42 --- /dev/null +++ b/game/tests/unit/data_config/config_db_test.gd @@ -0,0 +1,198 @@ +# tests/unit/data_config/config_db_test.gd — 数据配置单元测试:表加载/进度坐标 ceil 解析/变异池门控/消费面访问器 +# 运行:godot --headless --path game --script res://tests/unit/data_config/config_db_test.gd +# 协议(ADR-0004 + tests/README):extends SceneTree + quit(code);--script 模式无 autoload——显式实例化。 +# 红线:期望值由表读值推导,不写死玩法数值(tests/README 协议要点)。 +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") + +var _failures := 0 +var _checks := 0 + + +func _init() -> void: + var db = ConfigDBScript.new() + var load_ok: bool = db.load_tables("res://data/tables", true) + _check("全部表加载 + 校验通过(含跨表护栏零 error 诊断)", load_ok and db.diagnostics_count("error") == 0) + _check("is_ready 通道随加载置位", db.is_ready) + if not load_ok: + for d in db.diagnostics(): + if d.severity == "error": + push_error("[config_db_test] 加载 error: [%s] %s" % [d.category, d.message]) + else: + _test_progress_resolution(db) + _test_variant_pool_gate(db) + _test_shop_accessors(db) + _test_meta_accessors(db) + _test_manifest_dir_sync(db) + if _failures > 0: + push_error("[config_db_test] %d/%d 项失败" % [_failures, _checks]) + quit(1) + return + print("[config_db_test] all green (%d checks)" % _checks) + quit(0) + + +func _check(desc: String, ok: bool) -> void: + _checks += 1 + if ok: + print(" [PASS] " + desc) + else: + _failures += 1 + push_error(" [FAIL] " + desc) + + +# ---- 进度坐标 ceil 解析(R1:向上下取整保证偏难;越最大行 clamp 取最大值 + 警告去重)---- + +func _test_progress_resolution(db) -> void: + var bg_rows: Array = db.board_gen.rows + # 行键全集自表推导(禁写死关卡/深度数值) + var level_keys: Array = [] + var depth_keys_by_level: Dictionary = {} + for row in bg_rows: + var lv := int(row.level_index) + if not level_keys.has(lv): + level_keys.append(lv) + if not depth_keys_by_level.has(lv): + depth_keys_by_level[lv] = [] + var dv := int(row.node_depth) + if not depth_keys_by_level[lv].has(dv): + depth_keys_by_level[lv].append(dv) + level_keys.sort() + var min_l: int = level_keys[0] + var max_l: int = level_keys[-1] + var min_depths: Array = depth_keys_by_level[min_l] + min_depths.sort() + + # 精确命中:请求 = 表内现存 (L,D) → 原行返回(字段逐位相等) + var probe_row: Dictionary = bg_rows[0] + var got: Dictionary = db.resolve_board_gen(int(probe_row.level_index), int(probe_row.node_depth)) + _check("进度解析精确命中返回原行(board_gen 首行)", + int(got.get("level_index", -99)) == int(probe_row.level_index) + and int(got.get("node_depth", -99)) == int(probe_row.node_depth) + and String(got.get("board_type", "")) == String(probe_row.get("board_type", ""))) + + # enemy 行集同通道:同 (L,D) 精确行存在(关卡节点地图 前置——battle 节点双表精确行契约) + var en_got: Dictionary = db.resolve_enemy_rowset(int(probe_row.level_index), int(probe_row.node_depth)) + _check("enemy 行集同坐标解析非空(双表精确行契约)", not en_got.is_empty()) + + # 深度 clamp:请求深度超最大行 → 取该关最大深度行 + 警告诊断一次(去重) + db.reset_diagnostics() + var deep: Dictionary = db.resolve_board_gen(min_l, int(min_depths[-1]) + 1) + _check("深度越最大行 clamp 取该关最大深度行", + int(deep.get("level_index", -99)) == min_l and int(deep.get("node_depth", -99)) == int(min_depths[-1])) + _check("clamp 解析发警告诊断(resolve_depth_clamp)", db.diagnostics_count("warning") >= 1) + var found_clamp := false + for d in db.diagnostics(): + if d.category == "resolve_depth_clamp": + found_clamp = true + _check("警告类别 = resolve_depth_clamp", found_clamp) + var warn_before: int = db.diagnostics_count("warning") + db.resolve_board_gen(min_l, int(min_depths[-1]) + 1) + _check("同键重复请求警告去重(四元组注册表)", db.diagnostics_count("warning") == warn_before) + + # 关卡 clamp:请求关卡超最大行 → 取最大关卡行 + db.reset_diagnostics() + var high: Dictionary = db.resolve_board_gen(max_l + 1, 0) + _check("关卡越最大行 clamp 取最大关卡行", int(high.get("level_index", -99)) == max_l) + + +# ---- 变异池门控(unlock_level 闸 + ∅ board_types 通配)---- + +func _test_variant_pool_gate(db) -> void: + var rows: Array = db.variation.rows + var gated_row := {} + var wildcard_row := {} + for row in rows: + var tags: Array = row.get("board_types", []) + if gated_row.is_empty() and not tags.is_empty(): + gated_row = row + if wildcard_row.is_empty() and tags.is_empty(): + wildcard_row = row + if not gated_row.is_empty(): + var u: int = int(gated_row.unlock_level) + var tag: String = String(gated_row.board_types[0]) + var before: Array = db.variant_pool(u - 1, tag) + var after: Array = db.variant_pool(u, tag) + _check("变异池 unlock 前不含未解锁行(level=unlock−1)", not _rows_contain(before, gated_row)) + _check("变异池 unlock 当级含该行", _rows_contain(after, gated_row)) + if not wildcard_row.is_empty(): + var u2: int = int(wildcard_row.unlock_level) + var any_pool: Array = db.variant_pool(u2, "nonexistent_board_type_tag") + _check("∅ board_types 行通配(任一 board_type 命中)", _rows_contain(any_pool, wildcard_row)) + + +func _rows_contain(pool: Array, row: Dictionary) -> bool: + for r in pool: + if r == row: + return true + return false + + +# ---- ShopTable 消费面(行集解析单点——runner/编排禁自扫表的契约消费侧验证)---- + +func _test_shop_accessors(db) -> void: + var products: Array = db.shop_products() + if products.is_empty(): + _check("ShopTable products 行族非空(表已落籍前提)", false) + return + _check("ShopTable products 行族非空", true) + var first: Dictionary = products[0] + var got: Dictionary = db.shop_product_row(String(first.product_id)) + _check("shop_product_row 按 product_id 精确命中首行", got == first) + _check("shop_product_row 未知 id 返回空字典(容错通道)", db.shop_product_row("no_such_product_id__").is_empty()) + var tier_rows: Array = db._shop_rows("gp_drops") + if not tier_rows.is_empty(): + var tier0: String = String(tier_rows[0].tier) + _check("shop_gp_drop_row 按 tier 命中", not db.shop_gp_drop_row(tier0).is_empty()) + var bonus_rows: Array = db._shop_rows("level_gp_bonus") + if not bonus_rows.is_empty(): + var blv: int = int(bonus_rows[0].level_index) + _check("shop_level_gp_bonus 按关卡序号命中行值", db.shop_level_gp_bonus(blv) == int(bonus_rows[0].bonus)) + _check("shop_level_gp_bonus 未登记关卡返回 0(兜底通道)", db.shop_level_gp_bonus(-1) == 0) + var price_rows: Array = db._shop_rows("price_level_bonus") + if not price_rows.is_empty(): + var plv: int = int(price_rows[0].level_index) + _check("shop_price_level_bonus 按关卡序号命中行值", db.shop_price_level_bonus(plv) == int(price_rows[0].bonus)) + + +# ---- MetaTable 消费面 ---- + +func _test_meta_accessors(db) -> void: + var forge_rows: Array = db._meta_rows("forge_products") + if forge_rows.is_empty(): + _check("MetaTable forge_products 行族非空", false) + else: + var attr0: String = String(forge_rows[0].attr) + _check("meta_forge_row 按 attr 命中首行", db.meta_forge_row(attr0) == forge_rows[0]) + _check("meta_forge_row 未知 attr 返回空字典", db.meta_forge_row("no_such_attr__").is_empty()) + var key_rows: Array = db._meta_rows("boss_keystone") + if not key_rows.is_empty(): + var klv: int = int(key_rows[0].level_index) + _check("meta_boss_keystone_row 按关卡序号命中", db.meta_boss_keystone_row(klv) == key_rows[0]) + var conv_rows: Array = db._meta_rows("conversion") + if not conv_rows.is_empty(): + var cattr: String = String(conv_rows[0].attr) + _check("meta_conversion_row 按 attr 命中", db.meta_conversion_row(cattr) == conv_rows[0]) + + +# ---- TABLE_MANIFEST ↔ data/tables 目录同步(各系统→D2 单点契约的登记面)---- + +func _test_manifest_dir_sync(db) -> void: + var manifest: Dictionary = db.TABLE_MANIFEST + var dir := DirAccess.open("res://data/tables") + var disk_files: Array = [] + if dir != null: + dir.list_dir_begin() + var f := dir.get_next() + while f != "": + if not dir.current_is_dir() and f.ends_with(".tres"): + disk_files.append(f) + f = dir.get_next() + dir.list_dir_end() + var synced := disk_files.size() == manifest.size() + if synced: + for key in manifest.keys(): + if not disk_files.has(String(manifest[key])): + synced = false + _check("TABLE_MANIFEST 与 data/tables 目录逐文件同步(无缺登/无悬登)", synced) diff --git a/game/tests/unit/data_config/config_db_test.gd.uid b/game/tests/unit/data_config/config_db_test.gd.uid new file mode 100644 index 00000000..d8cd8255 --- /dev/null +++ b/game/tests/unit/data_config/config_db_test.gd.uid @@ -0,0 +1 @@ +uid://cxjn3c8p4dl68 diff --git a/game/tests/unit/data_config/rng_service_test.gd b/game/tests/unit/data_config/rng_service_test.gd new file mode 100644 index 00000000..14861c97 --- /dev/null +++ b/game/tests/unit/data_config/rng_service_test.gd @@ -0,0 +1,95 @@ +# tests/unit/data_config/rng_service_test.gd — 统一随机流单元测试:单盐派生确定性/计数器账本/标签词表封闭 +# 运行:godot --headless --path game --script res://tests/unit/data_config/rng_service_test.gd +# 红线:零自建 RNG——本测试只经 RngService 统一流取数(确定性契约:同种子逐位复现)。 +# 测试种子为夹具基建值(非玩法数值),与冒烟黄金向量锚(smoke_all m=42 scope 0-4)分工:本文件测 +# 「同种子两实例逐位一致」的相对确定性,绝对向量锚归冒烟单点,避免双处维护同一常量。 +extends SceneTree + +const RngServiceScript := preload("res://core/rng_service.gd") + +const TEST_SEED := 20260928 # 夹具种子(任意固定值;语义 = 可复现性,非玩法数值) + +var _failures := 0 +var _checks := 0 + + +func _init() -> void: + _test_derive_determinism() + _test_counter_ledger() + _test_stream_draw_determinism() + _test_negative_seed_normalization() + if _failures > 0: + push_error("[rng_service_test] %d/%d 项失败" % [_failures, _checks]) + quit(1) + return + print("[rng_service_test] all green (%d checks)" % _checks) + quit(0) + + +func _check(desc: String, ok: bool) -> void: + _checks += 1 + if ok: + print(" [PASS] " + desc) + else: + _failures += 1 + push_error(" [FAIL] " + desc) + + +func _fresh_service(): + var rng = RngServiceScript.new() + rng.set_master_seed(TEST_SEED) + return rng + + +# 单盐派生纯函数:两全新实例同种子逐位一致(derive_at 不推计数器——纯函数契约) +func _test_derive_determinism() -> void: + var a = _fresh_service() + var b = _fresh_service() + var same := true + for scope in range(8): + if a.derive_at(scope) != b.derive_at(scope): + same = false + _check("同种子两实例 derive_at(0..7) 逐位一致", same) + var a2 = _fresh_service() + var before: int = a2.counter() + a2.derive_at(0) + _check("derive_at 为纯函数不推计数器", a2.counter() == before) + + +# 全局取数计数器:唯一账本——open_stream 逐次 +1(scope 单调) +func _test_counter_ledger() -> void: + var rng = _fresh_service() + var c0: int = rng.counter() + rng.open_stream("armor_roll") + var c1: int = rng.counter() + rng.open_stream("armor_roll") + var c2: int = rng.counter() + _check("open_stream 逐次推进全局计数器(+1/次)", c1 == c0 + 1 and c2 == c0 + 2) + # 用途标签只进事件日志不参与派生——同 scope 同派生种子(ADR-0007 裁丙口径) + var rng_x = _fresh_service() + var rng_y = _fresh_service() + var s1: int = int(rng_x.open_stream("board_gen").seed) + var s2: int = int(rng_y.open_stream("reward_roll").seed) + _check("同 scope 异用途标签派生种子一致(标签不参与派生)", s1 == s2) + + +# 同种子两实例同标签流的实际取数序列逐位一致(回放确定性契约——存档快照=主种子+计数器的前提) +func _test_stream_draw_determinism() -> void: + var faces := 20 # d20 探针面数(夹具值,非玩法表值——只用于产生可比较序列) + var a = _fresh_service() + var b = _fresh_service() + var same := true + for i in range(12): + var ra: int = a.open_stream("armor_roll").randi_range(1, faces) + var rb: int = b.open_stream("armor_roll").randi_range(1, faces) + if ra != rb: + same = false + _check("同种子两实例同标签流取数 12 次逐位一致", same) + + +# 负输入 abs 归一化(警告通道)——|−s| 与 s 同派生 +func _test_negative_seed_normalization() -> void: + var pos = _fresh_service() + var neg = RngServiceScript.new() + neg.set_master_seed(-TEST_SEED) + _check("负种子 abs 归一化后派生与正种子一致", pos.derive_at(0) == neg.derive_at(0)) diff --git a/game/tests/unit/data_config/rng_service_test.gd.uid b/game/tests/unit/data_config/rng_service_test.gd.uid new file mode 100644 index 00000000..0dda6009 --- /dev/null +++ b/game/tests/unit/data_config/rng_service_test.gd.uid @@ -0,0 +1 @@ +uid://bgqcfrksa3g6w diff --git a/game/tests/unit/example_test.gd.uid b/game/tests/unit/example_test.gd.uid new file mode 100644 index 00000000..ccaa4c17 --- /dev/null +++ b/game/tests/unit/example_test.gd.uid @@ -0,0 +1 @@ +uid://csesvbdvqe8m3 diff --git a/game/tests/unit/presentation/terrain_tile_sets_test.gd b/game/tests/unit/presentation/terrain_tile_sets_test.gd new file mode 100644 index 00000000..ca09ff6f --- /dev/null +++ b/game/tests/unit/presentation/terrain_tile_sets_test.gd @@ -0,0 +1,130 @@ +# tests/unit/presentation/terrain_tile_sets_test.gd — 瓦片换色机检(T6 卡交付:色相域抽检+套完整性) +# 运行:godot --headless --path game --script res://tests/unit/presentation/terrain_tile_sets_test.gd +# 事实源:board_gen.tres board_type 词表 v1.1(dungeon/underdark/deep_underdark——从表读值,禁写死); +# 色相域方向 = art-bible §4.3 三关方向(underdark 灰白蓝紫 / deep_underdark 蓝黑紫黑); +# 图底分离 = 圣经 §8.5(已开地面明度低于未开墙体)。 +# 前置守卫:素材不入 git(用户裁定)——fresh checkout 无瓦片文件时本套件跳过(打印注记,不红); +# 源 dungeon 套在盘而换色套缺位 = 回归(换色脚本在库可复现)。 +extends SceneTree + +const ConfigDBScript := preload("res://core/config_db.gd") +const BoardViewScript := preload("res://presentation/views/board_view.gd") + +var _failures := 0 +var _checks := 0 + + +func _init() -> void: + var board_dir := ProjectSettings.globalize_path("res://assets/art/board/") + var probe_path := board_dir.path_join("terrain_wall_center_dungeon.png") + if not FileAccess.file_exists(probe_path): + print("[terrain_tile_sets_test] SKIP——素材工作区缺位(瓦片不入 git 纪律,fresh checkout 预期态)") + quit(0) + return + var db = ConfigDBScript.new() + var load_ok: bool = db.load_tables("res://data/tables", true) + _check("前置:表加载零 error", load_ok and db.diagnostics_count("error") == 0) + if not load_ok: + quit(1) + return + # board_type 词表自 board_gen 行集推导(禁测试侧写死词表) + var types: Array = [] + for row in db.board_gen.rows: + var bt := String(row.get("board_type", "")) + if bt != "" and not types.has(bt): + types.append(bt) + _check("前置:board_gen 词表非空(%d 型)" % types.size(), types.size() > 0) + _test_set_completeness(board_dir, types) + _test_hue_domains(board_dir) + if _failures > 0: + push_error("[terrain_tile_sets_test] %d/%d 项失败" % [_failures, _checks]) + quit(1) + return + print("[terrain_tile_sets_test] all green (%d checks)" % _checks) + quit(0) + + +func _check(desc: String, ok: bool) -> void: + _checks += 1 + if ok: + print(" [PASS] " + desc) + else: + _failures += 1 + push_error(" [FAIL] " + desc) + + +# 套完整性:词表每个 board_type 的 16 墙 + 5 地面变体全部在盘(缺位 → BoardView 整批回落 dungeon 的 +# 缺位回退断言由 ui_input_probe 覆盖;本断言管「入包完整」面) +func _test_set_completeness(board_dir: String, types: Array) -> void: + var wall_names: Array = BoardViewScript.TILE_WALL_NAMES + var variants: int = int(BoardViewScript.TILE_FLOOR_VARIANTS) + for bt in types: + var missing: Array = [] + for w in wall_names: + var p := board_dir.path_join("terrain_%s_%s.png" % [String(w), String(bt)]) + if not FileAccess.file_exists(p): + missing.append(String(w)) + for i in range(1, variants + 1): + var pf := board_dir.path_join("terrain_floor_%s_%d.png" % [String(bt), i]) + if not FileAccess.file_exists(pf): + missing.append("floor_%d" % i) + _check("图型 %s 瓦片套完整(16 墙 + %d 地面,缺=%s)" % [String(bt), variants, str(missing)], missing.is_empty()) + + +# 色相域抽检:wall_center 上半幅(墙顶面)与 floor_1 全幅的均值色相/明度 +func _test_hue_domains(board_dir: String) -> void: + var bands := { + "dungeon": [0.02, 0.16], # 暖石棕 ≈15°–58° + "underdark": [0.50, 0.72], # 灰白蓝紫 ≈180°–260° + "deep_underdark": [0.55, 0.78], # 蓝黑紫黑 ≈200°–280° + } + var wall_v := {} + var floor_v := {} + for bt in bands.keys(): + var wall := _load_stats(board_dir, "terrain_wall_center_%s.png" % String(bt), true) + var floor_img := _load_stats(board_dir, "terrain_floor_%s_1.png" % String(bt), false) + if wall.is_empty() or floor_img.is_empty(): + _check("图型 %s 抽检样本可读" % String(bt), false) + continue + wall_v[bt] = float(wall["v"]) + floor_v[bt] = float(floor_img["v"]) + var band: Array = bands[bt] + _check("图型 %s 墙顶均值色相在带 %s(实测 %.3f)" % [String(bt), str(band), float(wall["h"])], + float(wall["h"]) >= float(band[0]) and float(wall["h"]) <= float(band[1])) + _check("图型 %s 图底分离(墙顶明度 %.3f > 地面 %.3f)" % [String(bt), wall_v[bt], floor_v[bt]], + wall_v[bt] > floor_v[bt]) + if wall_v.has("underdark") and wall_v.has("deep_underdark"): + _check("deep_underdark 墙顶更暗于 underdark(更深蓝黑方向)", float(wall_v["deep_underdark"]) < float(wall_v["underdark"])) + + +# 均值读数:hue 取饱和度 > 0.05 的像素(墨线/灰黑不影响色相均值);v 取全幅均值 +func _load_stats(board_dir: String, fname: String, top_half: bool) -> Dictionary: + var p := board_dir.path_join(fname) + if not FileAccess.file_exists(p): + return {} + var img := Image.load_from_file(p) + if img == null: + return {} + if img.is_compressed(): + img.decompress() + var w := img.get_width() + var h := img.get_height() + var y0 := 0 + var y1 := h + if top_half: + y1 = h / 2 # 墙顶面 = 上半幅(立面暗带不参与色相均值) + var h_sum := 0.0 + var v_sum := 0.0 + var n_h := 0 + var n_v := 0 + for y in range(y0, y1): + for x in range(w): + var c := img.get_pixel(x, y) + v_sum += c.v + n_v += 1 + if c.s > 0.05: + h_sum += c.h + n_h += 1 + if n_v == 0 or n_h == 0: + return {} + return {"h": h_sum / n_h, "v": v_sum / n_v} diff --git a/game/tests/unit/presentation/terrain_tile_sets_test.gd.uid b/game/tests/unit/presentation/terrain_tile_sets_test.gd.uid new file mode 100644 index 00000000..086f45fa --- /dev/null +++ b/game/tests/unit/presentation/terrain_tile_sets_test.gd.uid @@ -0,0 +1 @@ +uid://bgc7dabk26is diff --git a/game/tests/unit/presentation/ui_font_asset_test.gd b/game/tests/unit/presentation/ui_font_asset_test.gd new file mode 100644 index 00000000..eba1ec4c --- /dev/null +++ b/game/tests/unit/presentation/ui_font_asset_test.gd @@ -0,0 +1,44 @@ +# tests/unit/presentation/ui_font_asset_test.gd — CJK 随包字体资产契约测试(T3 字体卡) +# 运行:godot --headless --path game --script res://tests/unit/presentation/ui_font_asset_test.gd +# 契约源:2026-09-27 用户拍板 Fusion Pixel OFL(CJK 覆盖+授权干净)+ battle.md C6 headless 判据 +#(注入字体非空 + 中文字形 has_char + 采样文本宽度 > 0——实际光栅观感归真人目检)。 +# 路径常量从 UiKit 单点读取(字体路径改动 = 断言随动,禁测试侧复制字面量路径)。 +# 前置:fresh checkout 需先 --editor --quit 刷导入缓存(.ttf 经导入管线成 FontFile——全素材同纪律)。 +extends SceneTree + +const UiKitScript := preload("res://presentation/views/ui_kit.gd") + +var _failures := 0 +var _checks := 0 + + +func _init() -> void: + var path: String = UiKitScript.FONT_UI + var f: Font = UiKitScript.load_font(path) + _check("随包字体守卫装载非空(res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf)", f != null) + if f != null: + _check("中文字形在集(攻/雷/旗/封)", f.has_char("攻".unicode_at(0)) and f.has_char("雷".unicode_at(0)) \ + and f.has_char("旗".unicode_at(0)) and f.has_char("封".unicode_at(0))) + _check("ASCII/数字字形在集(A/0/%)", f.has_char("A".unicode_at(0)) and f.has_char("0".unicode_at(0)) and f.has_char("%".unicode_at(0))) + var sz := 18 + var w_cn: float = f.get_string_size("战斗胜利", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x + var w_label: float = f.get_string_size("攻击 2", HORIZONTAL_ALIGNMENT_LEFT, -1, sz).x + _check("中文采样文本宽度 > 0(C6 headless 判据)", w_cn > 0.0 and w_label > 0.0) + # 注:不设「私用区 has_char=false」负探针——fusion pixel 实测覆盖 PUA 码位,负向覆盖非契约面(宁缺勿错) + else: + _check("字体缺失时后续断言跳过(缺位=回归,此行仅占位计数)", false) + if _failures > 0: + push_error("[ui_font_asset_test] %d/%d 项失败" % [_failures, _checks]) + quit(1) + return + print("[ui_font_asset_test] all green (%d checks)" % _checks) + quit(0) + + +func _check(desc: String, ok: bool) -> void: + _checks += 1 + if ok: + print(" [PASS] " + desc) + else: + _failures += 1 + push_error(" [FAIL] " + desc) diff --git a/game/tests/unit/presentation/ui_font_asset_test.gd.uid b/game/tests/unit/presentation/ui_font_asset_test.gd.uid new file mode 100644 index 00000000..b66908e6 --- /dev/null +++ b/game/tests/unit/presentation/ui_font_asset_test.gd.uid @@ -0,0 +1 @@ +uid://dtfch2gldub31 diff --git a/tools/recolor_terrain_tiles.py b/tools/recolor_terrain_tiles.py new file mode 100644 index 00000000..349ef5a1 --- /dev/null +++ b/tools/recolor_terrain_tiles.py @@ -0,0 +1,154 @@ +# tools/recolor_terrain_tiles.py — 高墙/地面瓦片程序换色(T6,M-1 迭代 1) +# 用法: +# python tools/recolor_terrain_tiles.py # 产 underdark + deep_underdark 两套到源目录 +# python tools/recolor_terrain_tiles.py --palettes underdark +# 事实源: +# - 源瓦片 = game/assets/art/board/terrain_*_dungeon.png(v9.3 定稿,active.md 台账 164; +# 素材不入 git——本脚本读盘上现件,勿删活件) +# - 目标色表 = _tools/gen_terrain_walls.py PALETTES(归档 归档-terrain-walls-2026-09-26.zip 内 +# 活件;underdark/deep_underdark 槽值系圣经 §4.3 三关方向、三关换色证明条在案的已过闸色表) +# 方法:槽位家族保持的重映射——源图每唯一色 → 最近 dungeon 锚槽(RGB 距离)定家族(墙顶/立面/ +# 地面/雷坑);家族内按亮度秩序线性映射到目标色表同家族槽列。亮度秩序 = 图底分离与结构读数 +# 的承载面,逐像素保持;输出重排唯一色数与源一致(量化纪律)。确定性零随机。 +from __future__ import annotations +import argparse +import os +import sys +from PIL import Image + +# ── dungeon 锚槽(v9.3 生成器同款——家族归属判定用)── +DUNGEON = { + "OUT": (43, 34, 30), "D2": (76, 61, 49), "D1": (122, 101, 78), + "M": (139, 117, 91), "H1": (156, 133, 103), "H2": (181, 155, 119), + "F_OUT": (26, 21, 19), "F3": (36, 29, 26), "F2": (49, 39, 34), + "F1": (63, 51, 44), "FH": (88, 71, 56), + "G_BASE": (88, 66, 50), "G_LIT": (108, 76, 54), "G_DARK": (75, 61, 50), + "G_CHIP": (134, 82, 52), "G_STONE": (114, 98, 82), "G_STONE_D": (84, 72, 60), + "P_HOLE": (17, 25, 24), "P_DEEP": (12, 17, 17), "P_GLINT": (33, 58, 48), + "P_GLINT2": (45, 70, 56), "P_RIM_D": (40, 30, 26), "P_RIM_L": (129, 92, 65), +} +FAMILY_OF = {} +for s in ["OUT", "D2", "D1", "M", "H1", "H2"]: + FAMILY_OF[s] = "wall" +for s in ["F_OUT", "F3", "F2", "F1", "FH"]: + FAMILY_OF[s] = "facade" +for s in ["G_BASE", "G_LIT", "G_DARK", "G_CHIP", "G_STONE", "G_STONE_D"]: + FAMILY_OF[s] = "floor" +for s in ["P_HOLE", "P_DEEP", "P_GLINT", "P_GLINT2", "P_RIM_D", "P_RIM_L"]: + FAMILY_OF[s] = "crater" + +PALETTES = { + "underdark": { + "OUT": (36, 38, 50), "D2": (72, 78, 96), "D1": (108, 116, 138), + "M": (138, 145, 166), "H1": (162, 170, 190), "H2": (186, 193, 210), + "F_OUT": (20, 22, 30), "F3": (30, 33, 44), "F2": (42, 46, 60), + "F1": (56, 61, 78), "FH": (80, 87, 108), + "G_BASE": (62, 66, 80), "G_LIT": (76, 82, 98), "G_DARK": (52, 55, 68), + "G_CHIP": (96, 84, 122), "G_STONE": (84, 90, 108), "G_STONE_D": (62, 66, 80), + "P_HOLE": (14, 15, 22), "P_DEEP": (9, 10, 15), "P_GLINT": (38, 40, 66), + "P_GLINT2": (52, 56, 86), "P_RIM_D": (32, 34, 44), "P_RIM_L": (110, 116, 140), + }, + "deep_underdark": { + "OUT": (24, 25, 36), "D2": (48, 51, 70), "D1": (74, 79, 104), + "M": (98, 104, 132), "H1": (124, 131, 160), "H2": (150, 158, 186), + "F_OUT": (13, 13, 20), "F3": (20, 21, 32), "F2": (29, 31, 46), + "F1": (40, 43, 62), "FH": (58, 63, 88), + "G_BASE": (46, 48, 64), "G_LIT": (62, 66, 86), "G_DARK": (38, 40, 54), + "G_CHIP": (88, 72, 120), "G_STONE": (88, 94, 116), "G_STONE_D": (60, 64, 82), + "P_HOLE": (8, 8, 14), "P_DEEP": (5, 5, 9), "P_GLINT": (28, 28, 52), + "P_GLINT2": (40, 42, 70), "P_RIM_D": (22, 23, 34), "P_RIM_L": (84, 90, 116), + }, +} + + +def _lum(c): + return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] + + +def _dist(a, b): + return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2 + + +def _lerp(a, b, t): + return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3)) + + +def build_map(src_colors, target_palette): + """源唯一色 → 目标色。家族归属 = 最近 dungeon 锚;家族内按亮度秩序映射到目标同家族槽列。""" + fam_anchors = {} # family -> [(lum, slot, rgb)] 升序 + for slot, fam in FAMILY_OF.items(): + fam_anchors.setdefault(fam, []).append((_lum(DUNGEON[slot]), slot, DUNGEON[slot])) + for fam in fam_anchors: + fam_anchors[fam].sort() + tgt_ramps = {} + for fam in fam_anchors: + slots = [( _lum(target_palette[s]), s, target_palette[s]) for s in FAMILY_OF if FAMILY_OF[s] == fam] + slots.sort() + tgt_ramps[fam] = slots + mapping = {} + fam_members = {} + for c in src_colors: + best_fam, best = None, None + for fam, anchors in fam_anchors.items(): + for _, slot, rgb in anchors: + d = _dist(c, rgb) + if best is None or d < best: + best, best_fam = d, fam + fam_members.setdefault(best_fam, []).append(c) + for fam, members in fam_members.items(): + members.sort(key=_lum) + ramp = tgt_ramps[fam] + n_s, n_t = len(members), len(ramp) + for i, c in enumerate(members): + t = 0.0 if n_s <= 1 else i / (n_s - 1) + pos = t * (n_t - 1) + lo = int(pos) + hi = min(lo + 1, n_t - 1) + mapping[c] = _lerp(ramp[lo][2], ramp[hi][2], pos - lo) + return mapping + + +def remap_tile(src_path, out_path, target_palette): + im = Image.open(src_path).convert("RGBA") + px = im.load() + w, h = im.size + colors = sorted({px[x, y][:3] for y in range(h) for x in range(w)}) + mapping = build_map(colors, target_palette) + for y in range(h): + for x in range(w): + r, g, b, a = px[x, y] + nr, ng, nb = mapping[(r, g, b)] + px[x, y] = (nr, ng, nb, a) + im.save(out_path) + return len(colors) + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + default_board = os.path.normpath(os.path.join(here, "..", "game", "assets", "art", "board")) + ap = argparse.ArgumentParser() + ap.add_argument("--src", default=default_board) + ap.add_argument("--out", default=default_board) + ap.add_argument("--palettes", default="underdark,deep_underdark") + args = ap.parse_args() + # 墙瓦片 = terrain_*_dungeon.png;地板变体 = terrain_floor_dungeon_.png(数字后缀——词表 §一) + import re + pat = re.compile(r"^terrain_.*_dungeon(_\d+)?\.png$") + sources = sorted(f for f in os.listdir(args.src) if pat.match(f)) + if not sources: + print("[recolor] 源目录无 terrain_*_dungeon* 瓦片——素材缺位(不入 git 纪律),零输出", file=sys.stderr) + return 1 + for pal in args.palettes.split(","): + P = PALETTES[pal] + n_out = 0 + for f in sources: + out_name = f.replace("_dungeon", "_%s" % pal) + n = remap_tile(os.path.join(args.src, f), os.path.join(args.out, out_name), P) + n_out += 1 + print("[recolor] %s <- %s(唯一色 %d)" % (out_name, f, n)) + print("[recolor] %s:%d 枚完成" % (pal, n_out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())