merge: M-1 迭代 1「测试兜底与表现先行」loop——六卡落籍(T1 单元测试九套件/T2 高墙 D 投影/T3 Fusion Pixel 字体/T4 关卡画面三拍板/T5 胜利结算 GP/T6 瓦片换色两套)

This commit is contained in:
DKChan committed 2026-09-28 03:46:32 +08:00
commit aa18eb9d96
33 files changed
+1811 -117

No files matched your search

+96
View File
@@ -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.
Binary file not shown.
+10 -3
View File
@@ -449,11 +449,11 @@ func _on_battle_finished(payload: Dictionary) -> void:
return return
"level_end": "level_end":
_end_overlay.open_victory(Narrator.battle_end_text("victory", true), _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": "standalone_end":
if result == "victory": if result == "victory":
_end_overlay.open_victory(Narrator.battle_end_text("victory", false), _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: else:
_end_overlay.open_defeat(Narrator.battle_end_text("defeat", false), _end_overlay.open_defeat(Narrator.battle_end_text("defeat", false),
"返回", board_view.get_global_rect()) "返回", board_view.get_global_rect())
@@ -461,6 +461,13 @@ func _on_battle_finished(payload: Dictionary) -> void:
_refresh_presentations() _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 接线(战斗信号统一走这里)==================== # ==================== core↔view 接线(战斗信号统一走这里)====================
func _wire_battle(settlement_ref) -> void: func _wire_battle(settlement_ref) -> void:
@@ -561,7 +568,7 @@ func _on_node_continue() -> void:
func _on_choices_presented(candidates: Array) -> void: func _on_choices_presented(candidates: Array) -> void:
# 零自动推进的停点呈现:整幅节点图 + 候选按钮(战斗胜利/兑付确认统一到达此处) # 零自动推进的停点呈现:整幅节点图 + 图上点击选路(候选按钮退役 2026-09-28——战斗胜利/兑付确认统一到达此处)
if runner == null: if runner == null:
return return
log_view.append_bb(Narrator.route_line(candidates, runner)) log_view.append_bb(Narrator.route_line(candidates, runner))
+8
View File
@@ -40,6 +40,14 @@ static func build_battle_ui(root: Control, db) -> Dictionary:
var settlement_fx := SettlementFxView.new() var settlement_fx := SettlementFxView.new()
root.add_child(settlement_fx) 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/钥石余额位已随 2026-09-26 信息归属拍板退役(中/大循环资源迁出战斗屏——
# GP 去向 = 关卡画面+战斗结算画面、钥石去向 = 结算画面+营地,呈现归对应 UX spec 批次) # GP 去向 = 关卡画面+战斗结算画面、钥石去向 = 结算画面+营地,呈现归对应 UX spec 批次)
return { return {
+13 -3
View File
@@ -16,6 +16,7 @@ const COL_WARM := UiKit.WARM
var _dim: ColorRect var _dim: ColorRect
var _seal: Panel var _seal: Panel
var _verdict: Label var _verdict: Label
var _gp_line: Label # 战利品行(OQ14 口径:胜利总结结算展示 GP 总量+增量——T5 接线 2026-09-28)
var _review_btn: Button var _review_btn: Button
var _btn: Button var _btn: Button
var _board_rect := Rect2() var _board_rect := Rect2()
@@ -36,6 +37,11 @@ func _init() -> void:
_verdict.add_theme_color_override("font_color", COL_WARM) _verdict.add_theme_color_override("font_color", COL_WARM)
_verdict.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _verdict.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_verdict) 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 = UiKit.button("明细回看")
_review_btn.pressed.connect(func(): review_pressed.emit()) _review_btn.pressed.connect(func(): review_pressed.emit())
box.add_child(_review_btn) box.add_child(_review_btn)
@@ -54,11 +60,15 @@ func _init() -> void:
visible = false visible = false
# 终局画面数据锚 = 战斗结算 判定行(verdict)——判定行文案由组合根装配传入 # 终局画面数据锚 = 战斗结算 判定行(verdict)——判定行文案由组合根装配传入。
func open_victory(verdict_line: String, continue_label: String, board_rect: Rect2) -> void: # 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) _dim.color = Color(COL_WARM, 0.28)
_verdict.text = verdict_line _verdict.text = verdict_line
_verdict.add_theme_color_override("font_color", UiKit.INK_ON_GOLD) _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 _btn.text = continue_label
_board_rect = board_rect _board_rect = board_rect
visible = true 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) _dim.color = Color(COL_DUSK, 0.45)
_verdict.text = verdict_line _verdict.text = verdict_line
_verdict.add_theme_color_override("font_color", COL_WARM) _verdict.add_theme_color_override("font_color", COL_WARM)
_btn.text = continue_label _gp_line.visible = false # GP 总量+增量归胜利总结结算(OQ14)——失败侧不呈现
_board_rect = board_rect _board_rect = board_rect
visible = true visible = true
_seal.visible = false _seal.visible = false
@@ -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 contact_col := Color(0, 0, 0, 0) # 当前呼吸色(alpha 0 = 不画)
var plate := false # 数字色牌开关(BoardView paint 按已开格 glyph 写入——原则二) var plate := false # 数字色牌开关(BoardView paint 按已开格 glyph 写入——原则二)
var outline_sides: Array = [] # 墙格地面朝向描边码 0=N/1=S/2=W/3=E(轮廓加强批——程序侧结构件) 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 := "": # 文字位——数字/雷/旗/人/?(现行视觉旗与数字互斥,同位承载) var glyph := "": # 文字位——数字/雷/旗/人/?(现行视觉旗与数字互斥,同位承载)
set(v): set(v):
glyph = v glyph = v
@@ -51,6 +63,16 @@ var bevel := 0.0: # 顶面倒角强度(0=关——立体原
set(v): set(v):
bevel = v bevel = v
queue_redraw() 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 暂定档)── # ── 素材槽位(本批全 null = 程序化占位服役;art-bible §8.2 格面 32×32 暂定档)──
var tex_base: Texture2D = null # ① 砖面底 var tex_base: Texture2D = null # ① 砖面底
var tex_mutation: Texture2D = null # ② 变异纹带 var tex_mutation: Texture2D = null # ② 变异纹带
@@ -79,6 +101,8 @@ func _draw() -> void:
2: or_ = Rect2(0, 0, ow, edge) 2: or_ = Rect2(0, 0, ow, edge)
3: or_ = Rect2(edge - ow, 0, ow, edge) 3: or_ = Rect2(edge - ow, 0, ow, edge)
draw_rect(or_, oc, true) draw_rect(or_, oc, true)
_draw_south_facade()
_draw_north_projection()
# 瓦片态描边覆盖层(高墙瓦片接线批 2026-09-26):tex_base 在位时 BoardView 传入的 bg_sb # 瓦片态描边覆盖层(高墙瓦片接线批 2026-09-26):tex_base 在位时 BoardView 传入的 bg_sb
# 已降级为「透明底 + 描边」载体——呼吸微光/主角蓝框画在瓦片之上(art-bible §1.2 叠层序 # 已降级为「透明底 + 描边」载体——呼吸微光/主角蓝框画在瓦片之上(art-bible §1.2 叠层序
# 不变:底 < 纹带 < 数字 < 旗 < 焦点高亮);素材缺位回退走 elif 程序化原样,零观感变化 # 不变:底 < 纹带 < 数字 < 旗 < 焦点高亮);素材缺位回退走 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]; # 带宽 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 提亮) # 半条边=沿邻边自角点起 edge/2;角点圆 r=6 贴角点圆心,近白芯 r=2.5 提亮)
if contact_col.a > 0.0: if contact_col.a > 0.0:
_draw_north_projection() # 平色回退+微光并发的英雄格补画(瓦片路径已画——同像素重复无观感差)
var band := 4.0 var band := 4.0
var half := edge * 0.5 var half := edge * 0.5
for b in contact_bands: 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) draw_rect(Rect2(edge - band, pt.y if int(cc) == 1 else pt.y - half, band, half), contact_col, true)
elif bg_sb != null: elif bg_sb != null:
draw_style_box(bg_sb, rect) draw_style_box(bg_sb, rect)
_draw_south_facade() # 平色回退路径——D 投影档两件同样服役(结构归程序)
_draw_north_projection()
# 顶面倒角(立体原型批:光源左上——上/左内沿受光 2px、下/右内沿背光 2px;内缩 2px 让 # 顶面倒角(立体原型批:光源左上——上/左内沿受光 2px、下/右内沿背光 2px;内缩 2px 让
# 呼吸微光/主角蓝边框保持最外沿可见;强度随档位) # 呼吸微光/主角蓝边框保持最外沿可见;强度随档位)
if bevel > 0.0: 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 等价) 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 居中对齐等价) # 文字位绘制:水平居中 + 垂直居中(基线 = (格高 − 字高)/2 + 上伸部——旧 Button 居中对齐等价)
func _draw_glyph(g: String, col: Color, fs: int) -> void: func _draw_glyph(g: String, col: Color, fs: int) -> void:
var f := font if font != null else ThemeDB.fallback_font var f := font if font != null else ThemeDB.fallback_font
+64 -37
View File
@@ -54,6 +54,9 @@ const SEP := 2 # 格间距(旧 GridContainer v/
# §1.4 注记待 art-bible 并行批落籍后补写) # §1.4 注记待 art-bible 并行批落籍后补写)
const DEPTH_PX := 8.0 # 悬崖厚度(像素) const DEPTH_PX := 8.0 # 悬崖厚度(像素)
const DEPTH_BEVEL := 0.6 # 顶面倒角强度 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 时长档) # 英雄单位演出参数(棋盘小人贴图批 2026-09-25——计划书 §3.B/C 时长档)
const HERO_FRAME_PX := 32.0 # 英雄帧画布边长(素材 32×32 锁定——缩放比分母) 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 禁入)。词表 = # 路径常量落本文件单点(ui_kit.gd 由并行批占用,任务书 §2.5 禁入)。词表 =
# design/research/board-tile-vocab-plan-a-2026-09-26.md(16 唯一墙瓦片 + 5 地面变体; # design/research/board-tile-vocab-plan-a-2026-09-26.md(16 唯一墙瓦片 + 5 地面变体;
# 东/东北/东南/end_e/in_ne/in_se 六镜像件运行时 flip_x 合成,不出文件——词表 §二)。 # 东/东北/东南/end_e/in_ne/in_se 六镜像件运行时 flip_x 合成,不出文件——词表 §二)。
const TEX_WALL_CENTER := "res://assets/art/board/terrain_wall_center_dungeon.png" # 换色批(T6 2026-09-28):路径按 board_type 后缀参数化——terrain_<件名>_<board_type>.png;
const TEX_WALL_CENTER_CRACK := "res://assets/art/board/terrain_wall_center_crack_dungeon.png" # 词表 §一 16 唯一墙瓦片名单单点在本常量数组。underdark/deep_underdark 两套 =
const TEX_WALL_CENTER_SPECKLE := "res://assets/art/board/terrain_wall_center_speckle_dungeon.png" # tools/recolor_terrain_tiles.py 程序换色产(台账 164 换色遗留清偿);任一套缺位 → 整批回落
const TEX_WALL_N := "res://assets/art/board/terrain_wall_n_dungeon.png" # dungeon 套(禁同板混渲染);dungeon 套亦缺位 → 程序化平色回退(fresh checkout 冒烟不炸)。
const TEX_WALL_S := "res://assets/art/board/terrain_wall_s_dungeon.png" 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 TEX_WALL_W := "res://assets/art/board/terrain_wall_w_dungeon.png" # wall_e = 运行时镜像 const TILE_WALL_FMT := "res://assets/art/board/terrain_%s_%s.png" # %s=件名 %s=board_type
const TEX_WALL_NW := "res://assets/art/board/terrain_wall_nw_dungeon.png" # wall_ne = 运行时镜像 const TILE_FLOOR_FMT := "res://assets/art/board/terrain_floor_%s_%d.png" # %s=board_type 1..%d 变体
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 变体
const TILE_FLOOR_VARIANTS := 5 # 地面瓦片变体数(词表 §一 floor_1..5) const TILE_FLOOR_VARIANTS := 5 # 地面瓦片变体数(词表 §一 floor_1..5)
var _wrap: CenterContainer var _wrap: CenterContainer
@@ -131,6 +124,7 @@ var _hero_dig_tw: Tween
var _tiles := {} # 词表键 → Texture2D(镜像件已预翻转;装载见 _load_board_tiles) var _tiles := {} # 词表键 → Texture2D(镜像件已预翻转;装载见 _load_board_tiles)
var _floor_tiles: Array[Texture2D] = [] # 地面瓦片五变体(下标 0..4 = floor_1..5) var _floor_tiles: Array[Texture2D] = [] # 地面瓦片五变体(下标 0..4 = floor_1..5)
var _tiles_ok := false # false = 素材缺位/未装载——bg_sb 程序化平色原路径 var _tiles_ok := false # false = 素材缺位/未装载——bg_sb 程序化平色原路径
var _tiles_board_type := "dungeon" # 当前装载瓦片套的 board_type(rebuild 按板切换——T6 换色批)
var cell_size: float: # 只读属性(ui_layout_probe 断言面——旧私有名 _cell 的契约化出口) var cell_size: float: # 只读属性(ui_layout_probe 断言面——旧私有名 _cell 的契约化出口)
get: get:
@@ -252,6 +246,11 @@ func rebuild(board: BoardData, minesweeper) -> void:
_r0[c] = true _r0[c] = true
if _board == null: if _board == null:
return 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) var stride := _cell + float(SEP)
for y in _board.height: for y in _board.height:
for x in _board.width: for x in _board.width:
@@ -683,12 +682,28 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void:
u.glyph_color = digit_col u.glyph_color = digit_col
u.plate = revealed and glyph != "" # 数字色牌(原则二——已开格字形全垫牌;旗在未开格不垫) u.plate = revealed and glyph != "" # 数字色牌(原则二——已开格字形全垫牌;旗在未开格不垫)
u.outline_sides = [] 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)]: for d in [Vector2i(0, -1), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(1, 0)]:
var n: Vector2i = c + d 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): 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.badge_glyph = _update_badge(c)
u.queue_redraw() u.queue_redraw()
@@ -755,33 +770,37 @@ func _reset_breath() -> void:
# 瓦片整批守卫装载(_init 单次调用):21 枚全成才启用——任一缺位按整批缺位处理(禁同板混渲染; # 瓦片整批守卫装载(_init 单次调用):21 枚全成才启用——任一缺位按整批缺位处理(禁同板混渲染;
# fresh checkout 无素材目录 → 静默全回退)。缺位不 push_error——可选素材缺位是预期路径非故障 # fresh checkout 无素材目录 → 静默全回退)。缺位不 push_error——可选素材缺位是预期路径非故障
# (英雄批 load_tex 纪律同款,防冒烟刷屏)。镜像件运行时 flip_x 合成,不出文件。 # (英雄批 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 := { var t := {
"wall_center": UiKit.load_tex(TEX_WALL_CENTER), "wall_center": UiKit.load_tex(TILE_WALL_FMT % ["wall_center", board_type]),
"wall_center_crack": UiKit.load_tex(TEX_WALL_CENTER_CRACK), "wall_center_crack": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_crack", board_type]),
"wall_center_speckle": UiKit.load_tex(TEX_WALL_CENTER_SPECKLE), "wall_center_speckle": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_speckle", board_type]),
"wall_n": UiKit.load_tex(TEX_WALL_N), "wall_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_n", board_type]),
"wall_s": UiKit.load_tex(TEX_WALL_S), "wall_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_s", board_type]),
"wall_w": UiKit.load_tex(TEX_WALL_W), "wall_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_w", board_type]),
"wall_nw": UiKit.load_tex(TEX_WALL_NW), "wall_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_nw", board_type]),
"wall_sw": UiKit.load_tex(TEX_WALL_SW), "wall_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_sw", board_type]),
"wall_hrun": UiKit.load_tex(TEX_WALL_HRUN), "wall_hrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_hrun", board_type]),
"wall_vrun": UiKit.load_tex(TEX_WALL_VRUN), "wall_vrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_vrun", board_type]),
"wall_end_n": UiKit.load_tex(TEX_WALL_END_N), "wall_end_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_n", board_type]),
"wall_end_s": UiKit.load_tex(TEX_WALL_END_S), "wall_end_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_s", board_type]),
"wall_end_w": UiKit.load_tex(TEX_WALL_END_W), "wall_end_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_w", board_type]),
"wall_lone": UiKit.load_tex(TEX_WALL_LONE), "wall_lone": UiKit.load_tex(TILE_WALL_FMT % ["wall_lone", board_type]),
"wall_in_nw": UiKit.load_tex(TEX_WALL_IN_NW), "wall_in_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_nw", board_type]),
"wall_in_sw": UiKit.load_tex(TEX_WALL_IN_SW), "wall_in_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_sw", board_type]),
} }
var floors: Array[Texture2D] = [] var floors: Array[Texture2D] = []
for i in range(1, TILE_FLOOR_VARIANTS + 1): 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: for k in t:
if t[k] == null: if t[k] == null:
_request_dungeon_fallback(board_type)
return return
for tex in floors: for tex in floors:
if tex == null: if tex == null:
_request_dungeon_fallback(board_type)
return return
# 六镜像件(词表 §二:wall_e←wall_w / wall_ne←wall_nw / wall_se←wall_sw / # 六镜像件(词表 §二: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;只水平翻转禁旋转 # 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) t["wall_in_se"] = _flip_tex(t["wall_in_sw"] as Texture2D)
_tiles = t _tiles = t
_floor_tiles = floors _floor_tiles = floors
_tiles_board_type = board_type
_tiles_ok = true _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 零改动。 # 水平镜像贴图运行时合成(flip_h 语义):Image 级逐位翻转后重建 ImageTexture——CellUnit 零改动。
# VRAM 压缩源先解压(flip_x 不支持压缩格式);32×32 原尺寸逐位翻转,无 ±1px 取整问题 # VRAM 压缩源先解压(flip_x 不支持压缩格式);32×32 原尺寸逐位翻转,无 ±1px 取整问题
static func _flip_tex(tex: Texture2D) -> Texture2D: static func _flip_tex(tex: Texture2D) -> Texture2D:
+248 -55
View File
@@ -1,13 +1,19 @@
# VERTICAL SLICE - NOT FOR PRODUCTION # VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-17(分支节点图批——关卡节点地图 v1.0 图版选路呈现) # Date: 2026-09-17(分支节点图批——关卡节点地图 v1.0 图版选路呈现)
# 节点图选路覆盖层(模态):兑付确认后由组合根打开——整幅节点图(方块地牢观感:节点 = 地牢房间 # 2026-09-28 关卡画面实装批(M-1 迭代 1 T4——level-map.md 三拍板落地):
# 方块、连线 = 通道)+ 当前位置高亮 + 可选下一跳亮边 + 候选按钮。零自动推进的玩家停点语义: # ① 三停点标题定稿(布局决策 1——清偿台账 143:「继续前进(战斗房)」入口措辞矛盾退役)
# 唯一前进通道 = 点候选按钮 → route_chosen(node_id) → 组合根转 runner.pick_next_node。 # ② 图上点击选路唯一通道(布局决策 2)——候选按钮整列退役;候选节点亮金描边 = 可点目标,
# 高内聚边界:只读 runner 成文出口(nodes()/visited_ids()/node_index/next_candidate_ids())做渲染, # 悬停预承诺(描边加粗+指针)后点击提交;press_candidate 探针入口改走图上命中同路径
# 无任何写面;图形为程序化占位质量(占位纪律)。 # ③ 放弃冒险二次确认(布局决策 3)——首击转确认态(文案+危险红脉冲约 3 秒超时回落),二击提交
# 放弃入口(2026-09-21 用户拍板「放弃接口也要有」——台账 119;推翻 冒险结构.md「MVP 无界面入口」 # ④ 顶栏语境:关卡名(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 class_name RouteOverlay
extends Control extends Control
@@ -20,12 +26,30 @@ const COL_ROOM_FUTURE := UiKit.DARK_UPCOMING # 未达——暗
const COL_PASSAGE := UiKit.STONE # 通道——石灰 const COL_PASSAGE := UiKit.STONE # 通道——石灰
const COL_PASSAGE_LIT := UiKit.GOLD_LIT # 候选通道——亮金 const COL_PASSAGE_LIT := UiKit.GOLD_LIT # 候选通道——亮金
const COL_BOSS_BORDER := UiKit.BOSS_PURPLE # Boss 边框——紫赤(色相独占本色;旗朱退役 2026-09-26) 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 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 _runner = null
var _btn_box: VBoxContainer
var _title: Label 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_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开)
var _abandon_armed := false # 二次确认态(首击置位、二击提交、超时/重开回落)
var _confirm_left := 0.0 # 确认态剩余秒数(_process 倒计时+脉冲驱动)
func _init() -> void: func _init() -> void:
@@ -39,77 +63,244 @@ func _init() -> void:
var box := VBoxContainer.new() var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 10) box.add_theme_constant_override("separation", 10)
center.add_child(box) 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.add_theme_color_override("font_color", UiKit.WARM)
_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(_title) box.add_child(_title)
var canvas := Control.new() # 节点图画布(图上点击 = 唯一提交通道——布局决策 2;mouse_filter STOP 接悬停/点击)
canvas.custom_minimum_size = Vector2(620, 240) _canvas = Control.new()
canvas.draw.connect(_draw_graph.bind(canvas)) _canvas.custom_minimum_size = Vector2(620, 240)
box.add_child(canvas) _canvas.mouse_filter = Control.MOUSE_FILTER_STOP
_btn_box = VBoxContainer.new() _canvas.draw.connect(_draw_graph.bind(_canvas))
_btn_box.add_theme_constant_override("separation", 6) _canvas.gui_input.connect(_on_canvas_input)
box.add_child(_btn_box) box.add_child(_canvas)
abandon_btn = UiKit.button("🏳 放弃冒险(同失败结算——已获大奖励保留)") # 底部状态栏(布局决策 4:生命/属性/本关加成三组 + 放弃按钮右端异位)
abandon_btn.add_theme_color_override("font_color", UiKit.RED_MINE) # 破坏性动作红(危险红族) var bottom := HBoxContainer.new()
abandon_btn.pressed.connect(_emit_abandon) 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 abandon_btn.visible = false
box.add_child(abandon_btn) bottom.add_child(abandon_btn)
visible = false 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: func set_abandon_available(v: bool) -> void:
abandon_btn.visible = v 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() 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: func open_choices(cands: Array, runner) -> void:
_runner = runner _runner = runner
for child in _btn_box.get_children(): _hover_id = -1
child.queue_free() _reset_abandon_confirm()
_title.text = "选择下一跳(岔路口)" if cands.size() > 1 else "继续前进" 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: for cand in cands:
var nid := _id_of(cand) _cand_ids.append(_id_of(cand))
var t := String(cand.get("type", "")) _refresh_status()
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)
visible = true visible = true
# 探针复用:按候选序号点击(真实按钮路径) # 状态栏刷新(命令式纪律——组合根打开本屏/兑付确认回执后重查;零推送信号订阅)
func press_candidate(index: int) -> void: func _refresh_status() -> void:
var btns: Array = _btn_box.get_children() if _runner == null:
if index >= 0 and index < btns.size(): return
btns[index].pressed.emit() _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 visible = false
route_chosen.emit(nid) 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: func _id_of(node: Dictionary) -> int:
# 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth) # 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth)
return _runner.node_id_of(node) 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 深度,同层垂直排布)---- # ---- 整图自绘(程序化占位——分层布局:层 = 入口 BFS 深度,同层垂直排布)----
func _draw_graph(canvas: Control) -> void: func _draw_graph(canvas: Control) -> void:
@@ -121,7 +312,7 @@ func _draw_graph(canvas: Control) -> void:
# 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析 # 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析
var visited: Array = _runner.visited_ids() var visited: Array = _runner.visited_ids()
var cand_ids: Array = _runner.next_candidate_ids() var cand_ids: Array = _runner.next_candidate_ids()
# 连线(通道)先行画(方块覆盖其上) # 连线(通道)先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗)
for node in nodes: for node in nodes:
var from: Vector2 = pos[node] var from: Vector2 = pos[node]
for tid in _out_ids(node, nodes): for tid in _out_ids(node, nodes):
@@ -130,8 +321,9 @@ func _draw_graph(canvas: Control) -> void:
continue continue
var to: Vector2 = pos[target] var to: Vector2 = pos[target]
var lit: bool = _id_of(node) == current_id and cand_ids.has(tid) 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 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) _draw_passage(canvas, from, to, col, width)
# 房间方块 # 房间方块
for node in nodes: for node in nodes:
@@ -143,13 +335,14 @@ func _draw_graph(canvas: Control) -> void:
col = COL_ROOM_DONE col = COL_ROOM_DONE
if nid == current_id: if nid == current_id:
col = COL_ROOM_CURRENT 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) canvas.draw_rect(rect, col)
if t == "boss": if t == "boss":
canvas.draw_rect(rect, COL_BOSS_BORDER, false, 2.0) canvas.draw_rect(rect, COL_BOSS_BORDER, false, 2.0)
elif cand_ids.has(nid): elif cand_ids.has(nid):
canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 2.0) # 悬停预承诺 = 亮金描边加粗(4px),常态候选 = 亮金描边 2px(spec States 悬停预承诺行)
var font := ThemeDB.fallback_font 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, "?")) 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(-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) 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: func _draw_passage(canvas: Control, from: Vector2, to: Vector2, col: Color, width: float) -> void:
var a := from + Vector2(26, 0) var a := from + Vector2(NODE_HALF.x, 0)
var b := to - Vector2(26, 0) var b := to - Vector2(NODE_HALF.x, 0)
if b.x < a.x: if b.x < a.x:
return return
var mid_x := (a.x + b.x) / 2.0 var mid_x := (a.x + b.x) / 2.0
+11
View File
@@ -106,6 +106,17 @@ static func load_tex(path: String) -> Texture2D:
return null 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 工厂 ── # ── StyleBox 工厂 ──
# margin = (左, 上, 右, 下);border_w = 0 即无边框(根底/遮罩用) # margin = (左, 上, 右, 下);border_w = 0 即无边框(根底/遮罩用)
static func panel_flat(bg: Color, border: Color, corner: int = 0, border_w: int = 1, margin: Vector4 = Vector4.ZERO) -> StyleBoxFlat: static func panel_flat(bg: Color, border: Color, corner: int = 0, border_w: int = 1, margin: Vector4 = Vector4.ZERO) -> StyleBoxFlat:
+4
View File
@@ -26,6 +26,10 @@ window/size/viewport_height=800
window/stretch/mode="canvas_items" window/stretch/mode="canvas_items"
window/stretch/aspect="expand" window/stretch/aspect="expand"
[gui]
theme/custom_font="res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf"
[rendering] [rendering]
renderer/rendering_method="gl_compatibility" renderer/rendering_method="gl_compatibility"
+2 -2
View File
@@ -88,7 +88,7 @@ func _ready() -> void:
var t1 := Time.get_ticks_msec() var t1 := Time.get_ticks_msec()
main._start_adventure_from_menu() main._start_adventure_from_menu()
await get_tree().process_frame await get_tree().process_frame
# 停点前移批 2026-09-23:关卡开始 = 入口选路停点——真实按钮路径选入口候选进首战 # 停点前移批 2026-09-23:关卡开始 = 入口选路停点——press_candidate 图上命中路径选入口候选进首战(候选按钮退役 2026-09-28)
#(首格可点 AC 口径随之含入口选路一步:菜单点击 → 停点呈现 → pick → 棋盘安装) #(首格可点 AC 口径随之含入口选路一步:菜单点击 → 停点呈现 → pick → 棋盘安装)
if main.route_overlay.visible: if main.route_overlay.visible:
main.route_overlay.press_candidate(0) main.route_overlay.press_candidate(0)
@@ -114,7 +114,7 @@ func _ready() -> void:
_flags_placed.clear() _flags_placed.clear()
continue continue
if main.route_overlay.visible: if main.route_overlay.visible:
# 节点图选路口(零自动推进停点)——真实按钮路径交替选候选(岔路使用行为) # 节点图选路口(零自动推进停点)——图上命中路径交替选候选(岔路使用行为;布局决策 2 改造)
main.route_overlay.press_candidate(_route_clicks % 2) main.route_overlay.press_candidate(_route_clicks % 2)
_route_clicks += 1 _route_clicks += 1
_flags_placed.clear() _flags_placed.clear()
+1 -1
View File
@@ -62,7 +62,7 @@ func _ready() -> void:
await get_tree().process_frame await get_tree().process_frame
await get_tree().process_frame await get_tree().process_frame
# ---- 入口选路停点(停点前移批 2026-09-23:关卡开始即出节点图——level_started 后、首战前弹选路层; # ---- 入口选路停点(停点前移批 2026-09-23:关卡开始即出节点图——level_started 后、首战前弹选路层;
# 真实按钮路径 pick 入口单候选进首战)---- # press_candidate 图上命中路径 pick 入口单候选进首战——候选按钮退役 2026-09-28)----
if not main.route_overlay.visible: if not main.route_overlay.visible:
problems.append("关卡开始未弹入口选路停点(route_overlay 应可见)") problems.append("关卡开始未弹入口选路停点(route_overlay 应可见)")
elif main.runner == null or not main.runner.awaiting_choice() \ elif main.runner == null or not main.runner.awaiting_choice() \
+2
View File
@@ -32,6 +32,8 @@ godot --headless --path . res://tests/probes/<probe>.tscn
timeout 120s godot --headless --script tests/run_all.gd 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): **协议要点**(D2 冒烟协议 + ADR-0004):
- 冒烟脚本 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)` - 冒烟脚本 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)`
- `--script` 模式**不加载 autoload 且不注册全局标识符**(裸引用 `ConfigDB` = parse error)——ConfigDB/RngService 由脚本显式实例化,core 一律构造注入 - `--script` 模式**不加载 autoload 且不注册全局标识符**(裸引用 `ConfigDB` = parse error)——ConfigDB/RngService 由脚本显式实例化,core 一律构造注入
+31 -16
View File
@@ -1,13 +1,15 @@
# tests/run_all.gd — CI 冒烟统一入口(ADR-0004 双载体的逻辑冒烟调用点) # tests/run_all.gd — 单元测试统一入口(ADR-0004 双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b)
# 用法:godot --headless --script tests/run_all.gd (CI 侧包装外部超时:timeout 120s …) # 用法:godot --headless --path game --script res://tests/run_all.gd (CI 侧包装外部超时:timeout 120s …)
# 协议:套件清单逐条"套件 → 期望退出码"(D2 AC-2b manifest);致命类独立进程。 # 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。
# 现状:正式 Godot 工程尚未在根目录创建(代码在 game/, # 执行形态(单元测试与契约层批 2026-09-28):逐套件起独立 Godot 子进程实跑
# 其 smoke/smoke_all.gd 96/96 为参考实现)——本入口当前仅验证运行协议自身; #(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符,
# 正式工程落地后,把套件清单登记进 _suites()。 # 每个测试文件自带显式实例化夹具,见 tests/README 协议要点)。
# 场景探针(.tscn 载体)由 CI/轻闸单独步骤调用,不经本入口。
extends SceneTree extends SceneTree
func _init() -> void: 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": if DisplayServer.get_name() != "headless":
push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)") push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)")
var failures := 0 var failures := 0
@@ -20,22 +22,35 @@ func _init() -> void:
print("[run_all] all green (%d suites)" % _suites().size()) print("[run_all] all green (%d suites)" % _suites().size())
quit(0) quit(0)
# 返回 Array of Dictionary:{ "path": String, "expect": int }
# 正式工程落地后在此登记;探针场景(.tscn 载体)由 CI 单独步骤调用,不经本入口。 # 套件清单:{ "path": String, "expect": int };致命类(期望非 0)独立进程断言退出码。
func _suites() -> Array: func _suites() -> Array:
return [ return [
# {"path": "res://tests/unit/example_test.gd", "expect": 0}, {"path": "res://tests/unit/example_test.gd", "expect": 0},
# {"path": "res://tests/unit/reward_economy/reward_roll_test.gd", "expect": 0}, {"path": "res://tests/unit/data_config/config_db_test.gd", "expect": 0},
# 致命类(独立进程,期望非 0): {"path": "res://tests/unit/data_config/config_db_fatal_oob_test.gd", "expect": 1},
# {"path": "res://tests/unit/d2/fixture_missing_table_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: func _run_suite(suite: Dictionary) -> int:
# SceneTree 脚本内不能直接以 OS.execute 递归调 godot 的成熟形态留待正式工程
# (需解析 godot 二进制路径);当前协议检查阶段只断言清单形态合法。
var path: String = suite.get("path", "") var path: String = suite.get("path", "")
var expect: int = int(suite.get("expect", 0))
if path.is_empty(): if path.is_empty():
push_error("[run_all] suite missing path") push_error("[run_all] suite missing path")
return 1 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 return 0
+1
View File
@@ -0,0 +1 @@
uid://f1xscrew4ser
@@ -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)
@@ -0,0 +1 @@
uid://bg3nmj4y2rxxk
@@ -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)))
@@ -0,0 +1 @@
uid://ce116wl7vbdiu
@@ -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)
@@ -0,0 +1 @@
uid://k4da3suvcbn
@@ -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)
@@ -0,0 +1 @@
uid://b5rujibws7uqp
@@ -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)
@@ -0,0 +1 @@
uid://cxjn3c8p4dl68
@@ -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))
@@ -0,0 +1 @@
uid://bgqcfrksa3g6w
+1
View File
@@ -0,0 +1 @@
uid://csesvbdvqe8m3
@@ -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}
@@ -0,0 +1 @@
uid://bgc7dabk26is
@@ -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)
@@ -0,0 +1 @@
uid://dtfch2gldub31
+154
View File
@@ -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_<i>.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())