merge: M-1 迭代 1「测试兜底与表现先行」loop——六卡落籍(T1 单元测试九套件/T2 高墙 D 投影/T3 Fusion Pixel 字体/T4 关卡画面三拍板/T5 胜利结算 GP/T6 瓦片换色两套)
This commit is contained in:
commit
aa18eb9d96
33 files changed
+1811
-117
No files matched your search
@@ -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.
@@ -449,11 +449,11 @@ func _on_battle_finished(payload: Dictionary) -> void:
|
||||
return
|
||||
"level_end":
|
||||
_end_overlay.open_victory(Narrator.battle_end_text("victory", true),
|
||||
"返回", board_view.get_global_rect())
|
||||
"返回", board_view.get_global_rect(), _gp_summary_text(payload))
|
||||
"standalone_end":
|
||||
if result == "victory":
|
||||
_end_overlay.open_victory(Narrator.battle_end_text("victory", false),
|
||||
"回到遭遇选择", board_view.get_global_rect())
|
||||
"回到遭遇选择", board_view.get_global_rect(), _gp_summary_text(payload))
|
||||
else:
|
||||
_end_overlay.open_defeat(Narrator.battle_end_text("defeat", false),
|
||||
"返回", board_view.get_global_rect())
|
||||
@@ -461,6 +461,13 @@ func _on_battle_finished(payload: Dictionary) -> void:
|
||||
_refresh_presentations()
|
||||
|
||||
|
||||
# 战利品行装配(battle.md OQ14——胜利总结结算展示 GP 总量+增量;账本语义零改动,纯呈现读数):
|
||||
# 增量 = battle_finished 载荷 gp_dropped(战斗域上抛);总量 = runner.gp 现值(LevelRunner 入账
|
||||
# 连接先于组合根 handler——此读数即战后入账完毕的总量)。
|
||||
func _gp_summary_text(payload: Dictionary) -> String:
|
||||
return "战利品 GP +%d · 合计 %d" % [int(payload.get("gp_dropped", 0)), int(runner.gp)]
|
||||
|
||||
|
||||
# ==================== core↔view 接线(战斗信号统一走这里)====================
|
||||
|
||||
func _wire_battle(settlement_ref) -> void:
|
||||
@@ -561,7 +568,7 @@ func _on_node_continue() -> void:
|
||||
|
||||
|
||||
func _on_choices_presented(candidates: Array) -> void:
|
||||
# 零自动推进的停点呈现:整幅节点图 + 候选按钮(战斗胜利/兑付确认统一到达此处)
|
||||
# 零自动推进的停点呈现:整幅节点图 + 图上点击选路(候选按钮退役 2026-09-28——战斗胜利/兑付确认统一到达此处)
|
||||
if runner == null:
|
||||
return
|
||||
log_view.append_bb(Narrator.route_line(candidates, runner))
|
||||
|
||||
@@ -40,6 +40,14 @@ static func build_battle_ui(root: Control, db) -> Dictionary:
|
||||
var settlement_fx := SettlementFxView.new()
|
||||
root.add_child(settlement_fx)
|
||||
|
||||
# CJK 字体随包(2026-09-27 用户拍板 Fusion Pixel OFL):Control 侧默认主题字体走 project.godot
|
||||
# gui/theme/custom_font;SubViewport 两世界不继承 Control 主题——显式注入挂点接线(C6 挂点链)。
|
||||
# 缺位守卫(load_font 返回 null = 引擎回退字体,与旧 Button 渲染同源,不炸)。
|
||||
var ui_font: Font = UiKit.load_font(UiKit.FONT_UI)
|
||||
if ui_font != null:
|
||||
stage.set_world_font(ui_font)
|
||||
board_zone.board_view.set_world_font(ui_font)
|
||||
|
||||
# GP/钥石余额位已随 2026-09-26 信息归属拍板退役(中/大循环资源迁出战斗屏——
|
||||
# GP 去向 = 关卡画面+战斗结算画面、钥石去向 = 结算画面+营地,呈现归对应 UX spec 批次)
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,7 @@ const COL_WARM := UiKit.WARM
|
||||
var _dim: ColorRect
|
||||
var _seal: Panel
|
||||
var _verdict: Label
|
||||
var _gp_line: Label # 战利品行(OQ14 口径:胜利总结结算展示 GP 总量+增量——T5 接线 2026-09-28)
|
||||
var _review_btn: Button
|
||||
var _btn: Button
|
||||
var _board_rect := Rect2()
|
||||
@@ -36,6 +37,11 @@ func _init() -> void:
|
||||
_verdict.add_theme_color_override("font_color", COL_WARM)
|
||||
_verdict.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
box.add_child(_verdict)
|
||||
_gp_line = UiKit.label("", 16, true)
|
||||
_gp_line.add_theme_color_override("font_color", UiKit.GOLD)
|
||||
_gp_line.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_gp_line.visible = false
|
||||
box.add_child(_gp_line)
|
||||
_review_btn = UiKit.button("明细回看")
|
||||
_review_btn.pressed.connect(func(): review_pressed.emit())
|
||||
box.add_child(_review_btn)
|
||||
@@ -54,11 +60,15 @@ func _init() -> void:
|
||||
visible = false
|
||||
|
||||
|
||||
# 终局画面数据锚 = 战斗结算 判定行(verdict)——判定行文案由组合根装配传入
|
||||
func open_victory(verdict_line: String, continue_label: String, board_rect: Rect2) -> void:
|
||||
# 终局画面数据锚 = 战斗结算 判定行(verdict)——判定行文案由组合根装配传入。
|
||||
# gp_summary(OQ14 口径——胜利总结结算展示 GP 总量+增量):组合根装配「战利品 +增量 · 合计总量」
|
||||
# 传入(入账次序 = LevelRunner 入账先于组合根 handler,runner.gp 即战后总量);空串 = 不显示(失败侧恒空)。
|
||||
func open_victory(verdict_line: String, continue_label: String, board_rect: Rect2, gp_summary: String = "") -> void:
|
||||
_dim.color = Color(COL_WARM, 0.28)
|
||||
_verdict.text = verdict_line
|
||||
_verdict.add_theme_color_override("font_color", UiKit.INK_ON_GOLD)
|
||||
_gp_line.text = gp_summary
|
||||
_gp_line.visible = gp_summary != ""
|
||||
_btn.text = continue_label
|
||||
_board_rect = board_rect
|
||||
visible = true
|
||||
@@ -71,7 +81,7 @@ func open_defeat(verdict_line: String, continue_label: String, board_rect: Rect2
|
||||
_dim.color = Color(COL_DUSK, 0.45)
|
||||
_verdict.text = verdict_line
|
||||
_verdict.add_theme_color_override("font_color", COL_WARM)
|
||||
_btn.text = continue_label
|
||||
_gp_line.visible = false # GP 总量+增量归胜利总结结算(OQ14)——失败侧不呈现
|
||||
_board_rect = board_rect
|
||||
visible = true
|
||||
_seal.visible = false
|
||||
|
||||
@@ -22,6 +22,18 @@ var contact_corners: Array = [] # 斜向共享角码 0=NW/1=NE/2=SW/3=SE(
|
||||
var contact_col := Color(0, 0, 0, 0) # 当前呼吸色(alpha 0 = 不画)
|
||||
var plate := false # 数字色牌开关(BoardView paint 按已开格 glyph 写入——原则二)
|
||||
var outline_sides: Array = [] # 墙格地面朝向描边码 0=N/1=S/2=W/3=E(轮廓加强批——程序侧结构件)
|
||||
# ── 高墙感 D 投影档(2026-09-28 挑档落码——研究件四变体用户裁 D=C+地面投影,ITB 同款高度线索)──
|
||||
# 南立面 = 墙格南缘内侧「口沿高光线 + 双段暗带」(邻地面格才画,墙群内聚);地面投影 = 北邻墙格
|
||||
# 在本已开格北缘投渐隐暗带。参数按研究件 64px 拼版实测比例换算(立面双段 0.16/0.25 格边、投影带
|
||||
# 0.22 格边;坐标推导留档 commit)。素材缺位程序化回退同样服役(结构归程序——art-bible §8.5)。
|
||||
var facade_south := false: # 南立面开关(BoardView paint 按「南邻已开格」写入)
|
||||
set(v):
|
||||
facade_south = v
|
||||
queue_redraw()
|
||||
var proj_band := 0.0: # 地面投影带宽(像素,0 = 不画——BoardView 按北邻墙判定写入)
|
||||
set(v):
|
||||
proj_band = v
|
||||
queue_redraw()
|
||||
var glyph := "": # 文字位——数字/雷/旗/人/?(现行视觉旗与数字互斥,同位承载)
|
||||
set(v):
|
||||
glyph = v
|
||||
@@ -51,6 +63,16 @@ var bevel := 0.0: # 顶面倒角强度(0=关——立体原
|
||||
set(v):
|
||||
bevel = v
|
||||
queue_redraw()
|
||||
# ── D 投影档绘制常量(研究件实测取色——立面高光/双段暗带/地面投影;黑罩 alpha 叠加实测等效)──
|
||||
const FACADE_HIGHLIGHT_PX := 2.0 # 口沿高光线厚(像素)
|
||||
const FACADE_NEAR_FRAC := 0.16 # 立面近段高(格边比——研究件 10/64)
|
||||
const FACADE_FAR_FRAC := 0.25 # 立面远段高(格边比——研究件 16/64)
|
||||
const FACADE_HIGHLIGHT_COL := Color8(196, 172, 132) # 口沿高光(研究件取色暖米白)
|
||||
const FACADE_NEAR_COL := Color(0, 0, 0, 0.60) # 立面近段黑罩(墙顶 132→52 实测 α≈0.61)
|
||||
const FACADE_FAR_COL := Color(0, 0, 0, 0.82) # 立面远段黑罩(墙顶 132→24 实测 α≈0.82)
|
||||
const PROJECTION_NEAR_COL := Color(0, 0, 0, 0.28) # 投影贴墙段(地面 66→46 实测 α≈0.28)
|
||||
const PROJECTION_MID_COL := Color(0, 0, 0, 0.16) # 投影中段(渐隐)
|
||||
const PROJECTION_EDGE_COL := Color(0, 0, 0, 0.06) # 投影远段(渐隐收边)
|
||||
# ── 素材槽位(本批全 null = 程序化占位服役;art-bible §8.2 格面 32×32 暂定档)──
|
||||
var tex_base: Texture2D = null # ① 砖面底
|
||||
var tex_mutation: Texture2D = null # ② 变异纹带
|
||||
@@ -79,6 +101,8 @@ func _draw() -> void:
|
||||
2: or_ = Rect2(0, 0, ow, edge)
|
||||
3: or_ = Rect2(edge - ow, 0, ow, edge)
|
||||
draw_rect(or_, oc, true)
|
||||
_draw_south_facade()
|
||||
_draw_north_projection()
|
||||
# 瓦片态描边覆盖层(高墙瓦片接线批 2026-09-26):tex_base 在位时 BoardView 传入的 bg_sb
|
||||
# 已降级为「透明底 + 描边」载体——呼吸微光/主角蓝框画在瓦片之上(art-bible §1.2 叠层序
|
||||
# 不变:底 < 纹带 < 数字 < 旗 < 焦点高亮);素材缺位回退走 elif 程序化原样,零观感变化
|
||||
@@ -89,6 +113,7 @@ func _draw() -> void:
|
||||
# 带宽 4px 贴格边内侧(坐标推导:N=[0,0,edge,4] S=[0,edge-4,edge,4] W=[0,0,4,edge] E=[edge-4,0,4,edge];
|
||||
# 半条边=沿邻边自角点起 edge/2;角点圆 r=6 贴角点圆心,近白芯 r=2.5 提亮)
|
||||
if contact_col.a > 0.0:
|
||||
_draw_north_projection() # 平色回退+微光并发的英雄格补画(瓦片路径已画——同像素重复无观感差)
|
||||
var band := 4.0
|
||||
var half := edge * 0.5
|
||||
for b in contact_bands:
|
||||
@@ -124,6 +149,8 @@ func _draw() -> void:
|
||||
draw_rect(Rect2(edge - band, pt.y if int(cc) == 1 else pt.y - half, band, half), contact_col, true)
|
||||
elif bg_sb != null:
|
||||
draw_style_box(bg_sb, rect)
|
||||
_draw_south_facade() # 平色回退路径——D 投影档两件同样服役(结构归程序)
|
||||
_draw_north_projection()
|
||||
# 顶面倒角(立体原型批:光源左上——上/左内沿受光 2px、下/右内沿背光 2px;内缩 2px 让
|
||||
# 呼吸微光/主角蓝边框保持最外沿可见;强度随档位)
|
||||
if bevel > 0.0:
|
||||
@@ -170,6 +197,33 @@ func _draw() -> void:
|
||||
draw_rect(Rect2(1, 1, edge - 2.0, edge - 2.0), Color(hl_color, 0.55), false, 2.0) # 2px 内描边(旧 hl Panel 等价)
|
||||
|
||||
|
||||
# 南立面(D 档件一——墙格南缘内侧,自底边向上:双段暗带在下、口沿高光线贴其上沿):
|
||||
# 坐标推导(自格底边起算):远段=[edge−far, edge],近段=[edge−far−near, edge−far],
|
||||
# 高光线=[edge−far−near−hl, edge−far−near]——与研究件「墙顶面→高光→暗段→更暗段→格界」下行序一致
|
||||
func _draw_south_facade() -> void:
|
||||
if not facade_south:
|
||||
return
|
||||
var far_h := edge * FACADE_FAR_FRAC
|
||||
var near_h := edge * FACADE_NEAR_FRAC
|
||||
var y_far := edge - far_h
|
||||
var y_near := y_far - near_h
|
||||
var y_hl := y_near - FACADE_HIGHLIGHT_PX
|
||||
draw_rect(Rect2(0.0, y_hl, edge, FACADE_HIGHLIGHT_PX), FACADE_HIGHLIGHT_COL, true)
|
||||
draw_rect(Rect2(0.0, y_near, edge, near_h), FACADE_NEAR_COL, true)
|
||||
draw_rect(Rect2(0.0, y_far, edge, far_h), FACADE_FAR_COL, true)
|
||||
|
||||
|
||||
# 地面投影(D 档件二——北邻墙格在本已开格北缘投渐隐暗带,自北缘向南三段衰减):
|
||||
# 坐标推导:三等分 proj_band,α 0.28/0.16/0.06(研究件实测贴墙 α≈0.28、带末渐融入地面)
|
||||
func _draw_north_projection() -> void:
|
||||
if proj_band <= 0.0:
|
||||
return
|
||||
var seg := proj_band / 3.0
|
||||
draw_rect(Rect2(0.0, 0.0, edge, seg), PROJECTION_NEAR_COL, true)
|
||||
draw_rect(Rect2(0.0, seg, edge, seg), PROJECTION_MID_COL, true)
|
||||
draw_rect(Rect2(0.0, seg * 2.0, edge, seg), PROJECTION_EDGE_COL, true)
|
||||
|
||||
|
||||
# 文字位绘制:水平居中 + 垂直居中(基线 = (格高 − 字高)/2 + 上伸部——旧 Button 居中对齐等价)
|
||||
func _draw_glyph(g: String, col: Color, fs: int) -> void:
|
||||
var f := font if font != null else ThemeDB.fallback_font
|
||||
|
||||
@@ -54,6 +54,9 @@ const SEP := 2 # 格间距(旧 GridContainer v/
|
||||
# §1.4 注记待 art-bible 并行批落籍后补写)
|
||||
const DEPTH_PX := 8.0 # 悬崖厚度(像素)
|
||||
const DEPTH_BEVEL := 0.6 # 顶面倒角强度
|
||||
# 高墙感 D 投影档(2026-09-28 挑档落码):地面投影带宽 = 格边比(研究件 64px 拼版实测 14/64≈0.22;
|
||||
# 立面/投影色参数在 CellUnit 常量——绘制侧单点)
|
||||
const WALL_PROJECTION_FRAC := 0.22
|
||||
|
||||
# 英雄单位演出参数(棋盘小人贴图批 2026-09-25——计划书 §3.B/C 时长档)
|
||||
const HERO_FRAME_PX := 32.0 # 英雄帧画布边长(素材 32×32 锁定——缩放比分母)
|
||||
@@ -76,23 +79,13 @@ const HERO_SHADOW_PX := Vector2(32, 12) # 脚下椭圆影屏幕尺寸(Godot
|
||||
# 路径常量落本文件单点(ui_kit.gd 由并行批占用,任务书 §2.5 禁入)。词表 =
|
||||
# design/research/board-tile-vocab-plan-a-2026-09-26.md(16 唯一墙瓦片 + 5 地面变体;
|
||||
# 东/东北/东南/end_e/in_ne/in_se 六镜像件运行时 flip_x 合成,不出文件——词表 §二)。
|
||||
const TEX_WALL_CENTER := "res://assets/art/board/terrain_wall_center_dungeon.png"
|
||||
const TEX_WALL_CENTER_CRACK := "res://assets/art/board/terrain_wall_center_crack_dungeon.png"
|
||||
const TEX_WALL_CENTER_SPECKLE := "res://assets/art/board/terrain_wall_center_speckle_dungeon.png"
|
||||
const TEX_WALL_N := "res://assets/art/board/terrain_wall_n_dungeon.png"
|
||||
const TEX_WALL_S := "res://assets/art/board/terrain_wall_s_dungeon.png"
|
||||
const TEX_WALL_W := "res://assets/art/board/terrain_wall_w_dungeon.png" # wall_e = 运行时镜像
|
||||
const TEX_WALL_NW := "res://assets/art/board/terrain_wall_nw_dungeon.png" # wall_ne = 运行时镜像
|
||||
const TEX_WALL_SW := "res://assets/art/board/terrain_wall_sw_dungeon.png" # wall_se = 运行时镜像
|
||||
const TEX_WALL_HRUN := "res://assets/art/board/terrain_wall_hrun_dungeon.png"
|
||||
const TEX_WALL_VRUN := "res://assets/art/board/terrain_wall_vrun_dungeon.png"
|
||||
const TEX_WALL_END_N := "res://assets/art/board/terrain_wall_end_n_dungeon.png"
|
||||
const TEX_WALL_END_S := "res://assets/art/board/terrain_wall_end_s_dungeon.png"
|
||||
const TEX_WALL_END_W := "res://assets/art/board/terrain_wall_end_w_dungeon.png" # wall_end_e = 运行时镜像
|
||||
const TEX_WALL_LONE := "res://assets/art/board/terrain_wall_lone_dungeon.png"
|
||||
const TEX_WALL_IN_NW := "res://assets/art/board/terrain_wall_in_nw_dungeon.png" # wall_in_ne = 运行时镜像
|
||||
const TEX_WALL_IN_SW := "res://assets/art/board/terrain_wall_in_sw_dungeon.png" # wall_in_se = 运行时镜像
|
||||
const TEX_FLOOR_FMT := "res://assets/art/board/terrain_floor_dungeon_%d.png" # 1..5 变体
|
||||
# 换色批(T6 2026-09-28):路径按 board_type 后缀参数化——terrain_<件名>_<board_type>.png;
|
||||
# 词表 §一 16 唯一墙瓦片名单单点在本常量数组。underdark/deep_underdark 两套 =
|
||||
# tools/recolor_terrain_tiles.py 程序换色产(台账 164 换色遗留清偿);任一套缺位 → 整批回落
|
||||
# dungeon 套(禁同板混渲染);dungeon 套亦缺位 → 程序化平色回退(fresh checkout 冒烟不炸)。
|
||||
const TILE_WALL_NAMES := ["wall_center", "wall_center_crack", "wall_center_speckle", "wall_n", "wall_s", "wall_w", "wall_nw", "wall_sw", "wall_hrun", "wall_vrun", "wall_end_n", "wall_end_s", "wall_end_w", "wall_lone", "wall_in_nw", "wall_in_sw"]
|
||||
const TILE_WALL_FMT := "res://assets/art/board/terrain_%s_%s.png" # %s=件名 %s=board_type
|
||||
const TILE_FLOOR_FMT := "res://assets/art/board/terrain_floor_%s_%d.png" # %s=board_type 1..%d 变体
|
||||
const TILE_FLOOR_VARIANTS := 5 # 地面瓦片变体数(词表 §一 floor_1..5)
|
||||
|
||||
var _wrap: CenterContainer
|
||||
@@ -131,6 +124,7 @@ var _hero_dig_tw: Tween
|
||||
var _tiles := {} # 词表键 → Texture2D(镜像件已预翻转;装载见 _load_board_tiles)
|
||||
var _floor_tiles: Array[Texture2D] = [] # 地面瓦片五变体(下标 0..4 = floor_1..5)
|
||||
var _tiles_ok := false # false = 素材缺位/未装载——bg_sb 程序化平色原路径
|
||||
var _tiles_board_type := "dungeon" # 当前装载瓦片套的 board_type(rebuild 按板切换——T6 换色批)
|
||||
|
||||
var cell_size: float: # 只读属性(ui_layout_probe 断言面——旧私有名 _cell 的契约化出口)
|
||||
get:
|
||||
@@ -252,6 +246,11 @@ func rebuild(board: BoardData, minesweeper) -> void:
|
||||
_r0[c] = true
|
||||
if _board == null:
|
||||
return
|
||||
# 瓦片套随板图型切换(T6 换色批——board_gen 词表 v1.1 dungeon/underdark/deep_underdark;
|
||||
# 同图型重复 rebuild 零重载,_tiles_board_type 守卫)
|
||||
var bt: String = _board.board_type if _board.board_type != "" else "dungeon"
|
||||
if bt != _tiles_board_type:
|
||||
_load_board_tiles(bt)
|
||||
var stride := _cell + float(SEP)
|
||||
for y in _board.height:
|
||||
for x in _board.width:
|
||||
@@ -683,12 +682,28 @@ func _paint_cell(c: Vector2i, hero: Vector2i) -> void:
|
||||
u.glyph_color = digit_col
|
||||
u.plate = revealed and glyph != "" # 数字色牌(原则二——已开格字形全垫牌;旗在未开格不垫)
|
||||
u.outline_sides = []
|
||||
if _tiles_ok and not revealed:
|
||||
u.facade_south = false
|
||||
u.proj_band = 0.0
|
||||
var south_n: Vector2i = c + Vector2i(0, 1)
|
||||
var north_n: Vector2i = c + Vector2i(0, -1)
|
||||
var south_in: bool = south_n.x >= 0 and south_n.y >= 0 and south_n.x < _board.width and south_n.y < _board.height
|
||||
var north_in: bool = north_n.x >= 0 and north_n.y >= 0 and north_n.x < _board.width and north_n.y < _board.height
|
||||
if not revealed:
|
||||
# 高墙感 D 投影档(2026-09-28 挑档落码):南邻地面格 → 墙格南缘立「口沿高光+双段暗带」立面
|
||||
#(邻地面才画——墙-墙内界保墙群内聚,轮廓加强批同口径);板外=墙不画
|
||||
u.facade_south = south_in and _minesweeper.is_revealed(south_n)
|
||||
# 轮廓加强:墙格四邻中已开(地面)格朝向记描边码(板外=墙不描——保墙群外轮廓只在墙/地交界)
|
||||
for d in [Vector2i(0, -1), Vector2i(0, 1), Vector2i(-1, 0), Vector2i(1, 0)]:
|
||||
var n: Vector2i = c + d
|
||||
if n.x >= 0 and n.y >= 0 and n.x < _board.width and n.y < _board.height and _minesweeper.is_revealed(n):
|
||||
u.outline_sides.append(0 if d.y < 0 else (1 if d.y > 0 else (2 if d.x < 0 else 3)))
|
||||
var code := 0 if d.y < 0 else (1 if d.y > 0 else (2 if d.x < 0 else 3))
|
||||
if code == 1 and u.facade_south:
|
||||
continue # 南缘由立面件承载(高光+双段暗带)——2px 描边让位防三重叠黑
|
||||
u.outline_sides.append(code)
|
||||
else:
|
||||
# 地面投影:北邻墙格在本已开格北缘投渐隐暗带(ITB 同款高度线索——带宽按格边比例常量)
|
||||
if north_in and not _minesweeper.is_revealed(north_n):
|
||||
u.proj_band = u.edge * WALL_PROJECTION_FRAC
|
||||
u.badge_glyph = _update_badge(c)
|
||||
u.queue_redraw()
|
||||
|
||||
@@ -755,33 +770,37 @@ func _reset_breath() -> void:
|
||||
# 瓦片整批守卫装载(_init 单次调用):21 枚全成才启用——任一缺位按整批缺位处理(禁同板混渲染;
|
||||
# fresh checkout 无素材目录 → 静默全回退)。缺位不 push_error——可选素材缺位是预期路径非故障
|
||||
# (英雄批 load_tex 纪律同款,防冒烟刷屏)。镜像件运行时 flip_x 合成,不出文件。
|
||||
func _load_board_tiles() -> void:
|
||||
func _load_board_tiles(board_type: String = "dungeon") -> void:
|
||||
if board_type == "":
|
||||
board_type = "dungeon"
|
||||
var t := {
|
||||
"wall_center": UiKit.load_tex(TEX_WALL_CENTER),
|
||||
"wall_center_crack": UiKit.load_tex(TEX_WALL_CENTER_CRACK),
|
||||
"wall_center_speckle": UiKit.load_tex(TEX_WALL_CENTER_SPECKLE),
|
||||
"wall_n": UiKit.load_tex(TEX_WALL_N),
|
||||
"wall_s": UiKit.load_tex(TEX_WALL_S),
|
||||
"wall_w": UiKit.load_tex(TEX_WALL_W),
|
||||
"wall_nw": UiKit.load_tex(TEX_WALL_NW),
|
||||
"wall_sw": UiKit.load_tex(TEX_WALL_SW),
|
||||
"wall_hrun": UiKit.load_tex(TEX_WALL_HRUN),
|
||||
"wall_vrun": UiKit.load_tex(TEX_WALL_VRUN),
|
||||
"wall_end_n": UiKit.load_tex(TEX_WALL_END_N),
|
||||
"wall_end_s": UiKit.load_tex(TEX_WALL_END_S),
|
||||
"wall_end_w": UiKit.load_tex(TEX_WALL_END_W),
|
||||
"wall_lone": UiKit.load_tex(TEX_WALL_LONE),
|
||||
"wall_in_nw": UiKit.load_tex(TEX_WALL_IN_NW),
|
||||
"wall_in_sw": UiKit.load_tex(TEX_WALL_IN_SW),
|
||||
"wall_center": UiKit.load_tex(TILE_WALL_FMT % ["wall_center", board_type]),
|
||||
"wall_center_crack": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_crack", board_type]),
|
||||
"wall_center_speckle": UiKit.load_tex(TILE_WALL_FMT % ["wall_center_speckle", board_type]),
|
||||
"wall_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_n", board_type]),
|
||||
"wall_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_s", board_type]),
|
||||
"wall_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_w", board_type]),
|
||||
"wall_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_nw", board_type]),
|
||||
"wall_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_sw", board_type]),
|
||||
"wall_hrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_hrun", board_type]),
|
||||
"wall_vrun": UiKit.load_tex(TILE_WALL_FMT % ["wall_vrun", board_type]),
|
||||
"wall_end_n": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_n", board_type]),
|
||||
"wall_end_s": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_s", board_type]),
|
||||
"wall_end_w": UiKit.load_tex(TILE_WALL_FMT % ["wall_end_w", board_type]),
|
||||
"wall_lone": UiKit.load_tex(TILE_WALL_FMT % ["wall_lone", board_type]),
|
||||
"wall_in_nw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_nw", board_type]),
|
||||
"wall_in_sw": UiKit.load_tex(TILE_WALL_FMT % ["wall_in_sw", board_type]),
|
||||
}
|
||||
var floors: Array[Texture2D] = []
|
||||
for i in range(1, TILE_FLOOR_VARIANTS + 1):
|
||||
floors.append(UiKit.load_tex(TEX_FLOOR_FMT % i))
|
||||
floors.append(UiKit.load_tex(TILE_FLOOR_FMT % [board_type, i]))
|
||||
for k in t:
|
||||
if t[k] == null:
|
||||
_request_dungeon_fallback(board_type)
|
||||
return
|
||||
for tex in floors:
|
||||
if tex == null:
|
||||
_request_dungeon_fallback(board_type)
|
||||
return
|
||||
# 六镜像件(词表 §二:wall_e←wall_w / wall_ne←wall_nw / wall_se←wall_sw /
|
||||
# wall_end_e←wall_end_w / wall_in_ne←wall_in_nw / wall_in_se←wall_in_sw;只水平翻转禁旋转
|
||||
@@ -794,9 +813,17 @@ func _load_board_tiles() -> void:
|
||||
t["wall_in_se"] = _flip_tex(t["wall_in_sw"] as Texture2D)
|
||||
_tiles = t
|
||||
_floor_tiles = floors
|
||||
_tiles_board_type = board_type
|
||||
_tiles_ok = true
|
||||
|
||||
|
||||
# 整批回落(T6 换色批):请求套缺位 → 回落 dungeon 套(禁同板混渲染);dungeon 本身缺位 =
|
||||
# 维持程序化平色回退原语义(_tiles_ok 恒 false,缺位不刷屏纪律不变)
|
||||
func _request_dungeon_fallback(requested: String) -> void:
|
||||
if requested != "dungeon":
|
||||
_load_board_tiles("dungeon")
|
||||
|
||||
|
||||
# 水平镜像贴图运行时合成(flip_h 语义):Image 级逐位翻转后重建 ImageTexture——CellUnit 零改动。
|
||||
# VRAM 压缩源先解压(flip_x 不支持压缩格式);32×32 原尺寸逐位翻转,无 ±1px 取整问题
|
||||
static func _flip_tex(tex: Texture2D) -> Texture2D:
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||||
# Date: 2026-09-17(分支节点图批——关卡节点地图 v1.0 图版选路呈现)
|
||||
# 节点图选路覆盖层(模态):兑付确认后由组合根打开——整幅节点图(方块地牢观感:节点 = 地牢房间
|
||||
# 方块、连线 = 通道)+ 当前位置高亮 + 可选下一跳亮边 + 候选按钮。零自动推进的玩家停点语义:
|
||||
# 唯一前进通道 = 点候选按钮 → route_chosen(node_id) → 组合根转 runner.pick_next_node。
|
||||
# 高内聚边界:只读 runner 成文出口(nodes()/visited_ids()/node_index/next_candidate_ids())做渲染,
|
||||
# 无任何写面;图形为程序化占位质量(占位纪律)。
|
||||
# 放弃入口(2026-09-21 用户拍板「放弃接口也要有」——台账 119;推翻 冒险结构.md「MVP 无界面入口」
|
||||
# 后置条款):选路停点 = 天然非战斗期(接口契约:战斗中放弃一律拒绝),按钮常驻本层底部;
|
||||
# 可见性归组合根(只在冒险模式开——放弃的语义对象是"整局冒险";调试两层无节点图天然不可达)。
|
||||
# 2026-09-28 关卡画面实装批(M-1 迭代 1 T4——level-map.md 三拍板落地):
|
||||
# ① 三停点标题定稿(布局决策 1——清偿台账 143:「继续前进(战斗房)」入口措辞矛盾退役)
|
||||
# ② 图上点击选路唯一通道(布局决策 2)——候选按钮整列退役;候选节点亮金描边 = 可点目标,
|
||||
# 悬停预承诺(描边加粗+指针)后点击提交;press_candidate 探针入口改走图上命中同路径
|
||||
# ③ 放弃冒险二次确认(布局决策 3)——首击转确认态(文案+危险红脉冲约 3 秒超时回落),二击提交
|
||||
# ④ 顶栏语境:关卡名(level_row.display_name)+ GP 常驻徽章(2026-09-26 二轮拍板去向落地面)
|
||||
# ⑤ 底部状态栏(布局决策 4):生命/属性/本关加成三组常读(零加成隐藏),放弃按钮右端异位
|
||||
# 节点图选路覆盖层(模态):兑付确认后由组合根打开。零自动推进的玩家停点语义:唯一前进通道 =
|
||||
# 图上点候选节点 → route_chosen(node_id) → 组合根转 runner.pick_next_node。
|
||||
# 高内聚边界:只读 runner 成文出口(nodes()/visited_ids()/node_index/next_candidate_ids()/level_row/
|
||||
# hero_hp/gp/effective_*/bonus)做渲染,无任何写面;图形为程序化占位质量(占位纪律)。
|
||||
# 放弃入口(2026-09-21 用户拍板「放弃接口也要有」——台账 119):选路停点 = 天然非战斗期(接口契约:
|
||||
# 战斗中放弃一律拒绝),按钮常驻底栏右端;可见性归组合根(只在冒险模式开——放弃的语义对象是
|
||||
# "整局冒险";调试两层无节点图天然不可达)。
|
||||
class_name RouteOverlay
|
||||
extends Control
|
||||
|
||||
@@ -20,12 +26,30 @@ const COL_ROOM_FUTURE := UiKit.DARK_UPCOMING # 未达——暗
|
||||
const COL_PASSAGE := UiKit.STONE # 通道——石灰
|
||||
const COL_PASSAGE_LIT := UiKit.GOLD_LIT # 候选通道——亮金
|
||||
const COL_BOSS_BORDER := UiKit.BOSS_PURPLE # Boss 边框——紫赤(色相独占本色;旗朱退役 2026-09-26)
|
||||
const COL_ABANDON := UiKit.RED_MINE # 破坏性动作红(危险红族)
|
||||
const NODE_LABELS := {"battle": "战", "reward": "赏", "rest": "疗", "boss": "王", "shop": "商"} # 商 = GP+商店房实施批 2026-09-21
|
||||
const TITLE_ENTRY := "出发——选择首个节点" # 入口停点(布局决策 1——台账 143 遗留清偿)
|
||||
const TITLE_SINGLE := "继续前进" # 单链停点
|
||||
const TITLE_FORK := "选择下一节点" # 岔路停点
|
||||
const NODE_HALF := Vector2(26, 18) # 节点方块半宽高(52×36 占位画布参数——样张批终稿)
|
||||
const HIT_PAD := 6.0 # 图上命中外扩(指宽容差)
|
||||
const CONFIRM_WINDOW_S := 3.0 # 放弃确认态窗口(秒——超时回落)
|
||||
|
||||
var _runner = null
|
||||
var _btn_box: VBoxContainer
|
||||
var _title: Label
|
||||
var _canvas: Control
|
||||
var _level_label: Label
|
||||
var _gp_value: Label
|
||||
var _hp_value: Label
|
||||
var _hp_bar: Control
|
||||
var _attr_value: Label
|
||||
var _bonus_row: HBoxContainer
|
||||
var _bonus_value: Label
|
||||
var _cand_ids: Array = [] # 本停点候选 id 集(图上命中合法集)
|
||||
var _hover_id := -1 # 悬停预承诺候选(-1 = 无)
|
||||
var abandon_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开)
|
||||
var _abandon_armed := false # 二次确认态(首击置位、二击提交、超时/重开回落)
|
||||
var _confirm_left := 0.0 # 确认态剩余秒数(_process 倒计时+脉冲驱动)
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -39,77 +63,244 @@ func _init() -> void:
|
||||
var box := VBoxContainer.new()
|
||||
box.add_theme_constant_override("separation", 10)
|
||||
center.add_child(box)
|
||||
_title = UiKit.label("选择下一跳", 18, true)
|
||||
# 顶栏语境(spec Layout:左关卡名 / 右 GP 常驻徽章——直角细线数据框)
|
||||
var top := HBoxContainer.new()
|
||||
top.add_theme_constant_override("separation", 12)
|
||||
box.add_child(top)
|
||||
_level_label = UiKit.label("", 16, true)
|
||||
_level_label.add_theme_color_override("font_color", UiKit.WARM)
|
||||
_level_label.custom_minimum_size.x = 420
|
||||
top.add_child(_level_label)
|
||||
var gp_box := PanelContainer.new()
|
||||
gp_box.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1))
|
||||
top.add_child(gp_box)
|
||||
var gp_row := HBoxContainer.new()
|
||||
gp_row.add_theme_constant_override("separation", 6)
|
||||
gp_box.add_child(gp_row)
|
||||
_gp_value = UiKit.label("", 16, true)
|
||||
_gp_value.add_theme_color_override("font_color", UiKit.GOLD)
|
||||
gp_row.add_child(_gp_value)
|
||||
_title = UiKit.label(TITLE_FORK, 18, true)
|
||||
_title.add_theme_color_override("font_color", UiKit.WARM)
|
||||
_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
box.add_child(_title)
|
||||
var canvas := Control.new()
|
||||
canvas.custom_minimum_size = Vector2(620, 240)
|
||||
canvas.draw.connect(_draw_graph.bind(canvas))
|
||||
box.add_child(canvas)
|
||||
_btn_box = VBoxContainer.new()
|
||||
_btn_box.add_theme_constant_override("separation", 6)
|
||||
box.add_child(_btn_box)
|
||||
abandon_btn = UiKit.button("🏳 放弃冒险(同失败结算——已获大奖励保留)")
|
||||
abandon_btn.add_theme_color_override("font_color", UiKit.RED_MINE) # 破坏性动作红(危险红族)
|
||||
abandon_btn.pressed.connect(_emit_abandon)
|
||||
# 节点图画布(图上点击 = 唯一提交通道——布局决策 2;mouse_filter STOP 接悬停/点击)
|
||||
_canvas = Control.new()
|
||||
_canvas.custom_minimum_size = Vector2(620, 240)
|
||||
_canvas.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_canvas.draw.connect(_draw_graph.bind(_canvas))
|
||||
_canvas.gui_input.connect(_on_canvas_input)
|
||||
box.add_child(_canvas)
|
||||
# 底部状态栏(布局决策 4:生命/属性/本关加成三组 + 放弃按钮右端异位)
|
||||
var bottom := HBoxContainer.new()
|
||||
bottom.add_theme_constant_override("separation", 10)
|
||||
box.add_child(bottom)
|
||||
bottom.add_child(_status_box("生命", true))
|
||||
bottom.add_child(_status_box("属性", false))
|
||||
# 本关加成框(零加成隐藏——spec 布局决策 4)
|
||||
_bonus_row = HBoxContainer.new()
|
||||
_bonus_row.add_theme_constant_override("separation", 10)
|
||||
var bonus_box := PanelContainer.new()
|
||||
bonus_box.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1))
|
||||
_bonus_row.add_child(bonus_box)
|
||||
var bonus_inner := VBoxContainer.new()
|
||||
bonus_box.add_child(bonus_inner)
|
||||
bonus_inner.add_child(UiKit.label("本关加成", 12, false))
|
||||
_bonus_value = UiKit.label("", 15, true)
|
||||
bonus_inner.add_child(_bonus_value)
|
||||
bottom.add_child(_bonus_row)
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
bottom.add_child(spacer)
|
||||
abandon_btn = UiKit.button("放弃冒险(同失败结算——已获大奖励保留)")
|
||||
abandon_btn.add_theme_color_override("font_color", COL_ABANDON) # 破坏性动作红(危险红族)
|
||||
abandon_btn.pressed.connect(_on_abandon_pressed)
|
||||
abandon_btn.visible = false
|
||||
box.add_child(abandon_btn)
|
||||
bottom.add_child(abandon_btn)
|
||||
visible = false
|
||||
|
||||
|
||||
# 状态数据框工厂(直角细线数据框——pattern 5 族;hp_box = 带迷你血条)
|
||||
func _status_box(caption: String, with_bar: bool) -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", UiKit.panel_flat(UiKit.DARK_UPCOMING, UiKit.PANEL_BORDER, 0, 1))
|
||||
var inner := VBoxContainer.new()
|
||||
panel.add_child(inner)
|
||||
inner.add_child(UiKit.label(caption, 12, false))
|
||||
if with_bar:
|
||||
_hp_bar = Control.new()
|
||||
_hp_bar.custom_minimum_size = Vector2(120, 6)
|
||||
_hp_bar.draw.connect(_draw_hp_bar.bind(_hp_bar))
|
||||
inner.add_child(_hp_bar)
|
||||
var value := UiKit.label("", 15, true)
|
||||
inner.add_child(value)
|
||||
if caption == "生命":
|
||||
_hp_value = value
|
||||
else:
|
||||
_attr_value = value
|
||||
return panel
|
||||
|
||||
|
||||
func _draw_hp_bar(bar: Control) -> void:
|
||||
# 迷你血条(占位:暗底+金填充——比例 = hero_hp / effective_max_hp)
|
||||
if _runner == null:
|
||||
return
|
||||
var max_hp := maxi(1, _runner.effective_max_hp())
|
||||
var ratio := clampf(float(_runner.hero_hp) / float(max_hp), 0.0, 1.0)
|
||||
var full := Rect2(Vector2.ZERO, bar.size)
|
||||
bar.draw_rect(full, UiKit.DARK_UPCOMING)
|
||||
if ratio > 0.0:
|
||||
bar.draw_rect(Rect2(Vector2.ZERO, Vector2(bar.size.x * ratio, bar.size.y)), UiKit.GOLD)
|
||||
|
||||
|
||||
# 放弃入口可见性(组合根在冒险启动时开、默认关——语义对象 = 整局冒险)
|
||||
func set_abandon_available(v: bool) -> void:
|
||||
abandon_btn.visible = v
|
||||
|
||||
|
||||
func _emit_abandon() -> void:
|
||||
# 放弃二次确认(布局决策 3):首击转确认态(文案+危险红脉冲约 3 秒超时回落),二击才提交。
|
||||
# 单击不提交——ui_probe 战斗中 pressed.emit() 单击路径保持「未受理」语义(战斗中拒绝归组合根)。
|
||||
func _on_abandon_pressed() -> void:
|
||||
if not _abandon_armed:
|
||||
_abandon_armed = true
|
||||
_confirm_left = CONFIRM_WINDOW_S
|
||||
abandon_btn.text = "再点一次确认放弃——不可逆"
|
||||
return
|
||||
_reset_abandon_confirm()
|
||||
abandon_requested.emit()
|
||||
|
||||
|
||||
# 打开选路口(cands = 候选节点引用数组;runner 只读用于整图渲染)
|
||||
func _reset_abandon_confirm() -> void:
|
||||
_abandon_armed = false
|
||||
_confirm_left = 0.0
|
||||
abandon_btn.modulate = Color(1, 1, 1, 1)
|
||||
abandon_btn.text = "放弃冒险(同失败结算——已获大奖励保留)"
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _abandon_armed:
|
||||
return
|
||||
_confirm_left -= delta
|
||||
if _confirm_left <= 0.0:
|
||||
_reset_abandon_confirm()
|
||||
return
|
||||
# 危险红边框脉冲(单段呼吸约 1Hz——闪烁基线 ≤3 次/秒内;减动效 = 静态半透不闪)
|
||||
if UiKit.motion_scale < 1.0:
|
||||
abandon_btn.modulate = Color(1, 1, 1, 0.75)
|
||||
else:
|
||||
var k := 0.5 + 0.5 * sin(_confirm_left * TAU)
|
||||
abandon_btn.modulate = Color(1, 1, 1, 0.65 + 0.35 * k)
|
||||
|
||||
|
||||
# 打开选路口(cands = 候选节点引用数组;runner 只读用于整图渲染与状态栏刷新)
|
||||
func open_choices(cands: Array, runner) -> void:
|
||||
_runner = runner
|
||||
for child in _btn_box.get_children():
|
||||
child.queue_free()
|
||||
_title.text = "选择下一跳(岔路口)" if cands.size() > 1 else "继续前进"
|
||||
_hover_id = -1
|
||||
_reset_abandon_confirm()
|
||||
var visited: Array = []
|
||||
if runner != null:
|
||||
visited = runner.visited_ids()
|
||||
if cands.size() > 1:
|
||||
_title.text = TITLE_FORK
|
||||
elif visited.is_empty():
|
||||
_title.text = TITLE_ENTRY # 入口停点(尚未走出第一步——台账 143 措辞矛盾清偿)
|
||||
else:
|
||||
_title.text = TITLE_SINGLE
|
||||
_cand_ids = []
|
||||
for cand in cands:
|
||||
var nid := _id_of(cand)
|
||||
var t := String(cand.get("type", ""))
|
||||
var label := "节点 %d · %s" % [nid, _type_name(t)] if cands.size() > 1 else "继续前进(%s)" % _type_name(t)
|
||||
var btn := UiKit.button(label)
|
||||
btn.pressed.connect(_emit_choice.bind(nid))
|
||||
_btn_box.add_child(btn)
|
||||
_cand_ids.append(_id_of(cand))
|
||||
_refresh_status()
|
||||
visible = true
|
||||
|
||||
|
||||
# 探针复用:按候选序号点击(真实按钮路径)
|
||||
func press_candidate(index: int) -> void:
|
||||
var btns: Array = _btn_box.get_children()
|
||||
if index >= 0 and index < btns.size():
|
||||
btns[index].pressed.emit()
|
||||
# 状态栏刷新(命令式纪律——组合根打开本屏/兑付确认回执后重查;零推送信号订阅)
|
||||
func _refresh_status() -> void:
|
||||
if _runner == null:
|
||||
return
|
||||
_level_label.text = String(_runner.level_row.get("display_name", ""))
|
||||
_gp_value.text = "◈ %d" % int(_runner.gp)
|
||||
var max_hp: int = _runner.effective_max_hp()
|
||||
_hp_value.text = "HP %d/%d" % [int(_runner.hero_hp), int(max_hp)]
|
||||
_hp_bar.queue_redraw()
|
||||
var atk_bonus := int(_runner.bonus.get("atk", 0))
|
||||
var ac_bonus := int(_runner.bonus.get("armor_class", 0))
|
||||
var atk_txt := "%d" % _runner.effective_atk()
|
||||
if atk_bonus > 0:
|
||||
atk_txt += " +%d" % atk_bonus # 加成角标(ASCII 加号——防像素字体缺 ⁺ 字形出豆腐块)
|
||||
var ac_txt := "%d" % _runner.effective_ac()
|
||||
if ac_bonus > 0:
|
||||
ac_txt += " +%d" % ac_bonus
|
||||
_attr_value.text = "攻击 %s · 护甲 AC %s" % [atk_txt, ac_txt]
|
||||
var hp_bonus := int(_runner.bonus.get("max_hp", 0))
|
||||
var parts: Array = []
|
||||
if atk_bonus > 0:
|
||||
parts.append("攻 +%d" % atk_bonus)
|
||||
if hp_bonus > 0:
|
||||
parts.append("上限 +%d" % hp_bonus)
|
||||
if ac_bonus > 0:
|
||||
parts.append("AC +%d" % ac_bonus)
|
||||
_bonus_value.text = " · ".join(parts)
|
||||
_bonus_row.visible = not parts.is_empty()
|
||||
|
||||
|
||||
func _emit_choice(nid: int) -> void:
|
||||
# ---- 图上命中(布局决策 2——唯一提交通道;探针 press_candidate 同路径) ----
|
||||
|
||||
func _on_canvas_input(ev: InputEvent) -> void:
|
||||
if _runner == null or not visible:
|
||||
return
|
||||
if ev is InputEventMouseMotion:
|
||||
var nid := _candidate_at(_canvas.get_local_mouse_position())
|
||||
if nid != _hover_id:
|
||||
_hover_id = nid
|
||||
_canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW
|
||||
_canvas.queue_redraw()
|
||||
elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT and ev.pressed:
|
||||
var nid2 := _candidate_at(_canvas.get_local_mouse_position())
|
||||
if nid2 >= 0:
|
||||
_submit_choice(nid2)
|
||||
|
||||
|
||||
# 候选命中:位置 = 布局公式(与 _draw_graph 同源),矩形 = 节点方块 + 外扩容差;非候选格不响应
|
||||
func _candidate_at(local_pos: Vector2) -> int:
|
||||
if _runner == null:
|
||||
return -1
|
||||
var nodes: Array = _runner.nodes()
|
||||
var pos := _layout(nodes, _canvas)
|
||||
for nid in _cand_ids:
|
||||
var node := _node_by_id(nid, nodes)
|
||||
if node.is_empty():
|
||||
continue
|
||||
var np: Vector2 = pos[node]
|
||||
var rect := Rect2(np - NODE_HALF - Vector2(HIT_PAD, HIT_PAD), (NODE_HALF + Vector2(HIT_PAD, HIT_PAD)) * 2.0)
|
||||
if rect.has_point(local_pos):
|
||||
return nid
|
||||
return -1
|
||||
|
||||
|
||||
func _submit_choice(nid: int) -> void:
|
||||
visible = false
|
||||
route_chosen.emit(nid)
|
||||
|
||||
|
||||
# 探针复用:按候选序号走图上命中同路径(候选节点中心坐标 → 命中检测 → 提交——布局决策 2 探针面改造)
|
||||
func press_candidate(index: int) -> void:
|
||||
if index < 0 or index >= _cand_ids.size() or _runner == null:
|
||||
return
|
||||
var nodes: Array = _runner.nodes()
|
||||
var node := _node_by_id(int(_cand_ids[index]), nodes)
|
||||
if node.is_empty():
|
||||
return
|
||||
var pos: Vector2 = _layout(nodes, _canvas)[node]
|
||||
var nid := _candidate_at(pos) # 节点中心必命中自身(命中面含节点方块)
|
||||
if nid >= 0:
|
||||
_submit_choice(nid)
|
||||
|
||||
|
||||
func _id_of(node: Dictionary) -> int:
|
||||
# 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth)
|
||||
return _runner.node_id_of(node)
|
||||
|
||||
|
||||
func _type_name(t: String) -> String:
|
||||
match t:
|
||||
"battle": return "战斗房"
|
||||
"reward": return "奖励点"
|
||||
"rest": return "恢复点"
|
||||
"boss": return "Boss 房"
|
||||
"shop": return "商店房"
|
||||
return t
|
||||
|
||||
|
||||
# ---- 整图自绘(程序化占位——分层布局:层 = 入口 BFS 深度,同层垂直排布)----
|
||||
|
||||
func _draw_graph(canvas: Control) -> void:
|
||||
@@ -121,7 +312,7 @@ func _draw_graph(canvas: Control) -> void:
|
||||
# 当前节点 id(单链=下标 / 图=编号)——visited_ids() 已按编号空间解析
|
||||
var visited: Array = _runner.visited_ids()
|
||||
var cand_ids: Array = _runner.next_candidate_ids()
|
||||
# 连线(通道)先行画(方块覆盖其上)
|
||||
# 连线(通道)先行画(方块覆盖其上);悬停预承诺 = 所属通道再增亮(加粗)
|
||||
for node in nodes:
|
||||
var from: Vector2 = pos[node]
|
||||
for tid in _out_ids(node, nodes):
|
||||
@@ -130,8 +321,9 @@ func _draw_graph(canvas: Control) -> void:
|
||||
continue
|
||||
var to: Vector2 = pos[target]
|
||||
var lit: bool = _id_of(node) == current_id and cand_ids.has(tid)
|
||||
var hovered: bool = lit and _hover_id == tid
|
||||
var col := COL_PASSAGE_LIT if lit else COL_PASSAGE
|
||||
var width := 3.0 if lit else 1.5
|
||||
var width := 4.0 if hovered else (3.0 if lit else 1.5)
|
||||
_draw_passage(canvas, from, to, col, width)
|
||||
# 房间方块
|
||||
for node in nodes:
|
||||
@@ -143,13 +335,14 @@ func _draw_graph(canvas: Control) -> void:
|
||||
col = COL_ROOM_DONE
|
||||
if nid == current_id:
|
||||
col = COL_ROOM_CURRENT
|
||||
var rect := Rect2(p - Vector2(26, 18), Vector2(52, 36))
|
||||
var rect := Rect2(p - NODE_HALF, NODE_HALF * 2.0)
|
||||
canvas.draw_rect(rect, col)
|
||||
if t == "boss":
|
||||
canvas.draw_rect(rect, COL_BOSS_BORDER, false, 2.0)
|
||||
elif cand_ids.has(nid):
|
||||
canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 2.0)
|
||||
var font := ThemeDB.fallback_font
|
||||
# 悬停预承诺 = 亮金描边加粗(4px),常态候选 = 亮金描边 2px(spec States 悬停预承诺行)
|
||||
canvas.draw_rect(rect, COL_PASSAGE_LIT, false, 4.0 if _hover_id == nid else 2.0)
|
||||
var font := get_theme_default_font()
|
||||
var label := String(NODE_LABELS.get(t, "?"))
|
||||
canvas.draw_string(font, p + Vector2(-6, 2), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, UiKit.INK_ROUTE)
|
||||
canvas.draw_string(font, p + Vector2(10, 14), str(nid), HORIZONTAL_ALIGNMENT_LEFT, -1, 10, UiKit.WARM_ROUTE_SUB)
|
||||
@@ -216,8 +409,8 @@ func _node_by_id(nid: int, nodes: Array) -> Dictionary:
|
||||
|
||||
# 通道画法:房间方块边缘到边缘的水平-折线(占位观感——地牢通道)
|
||||
func _draw_passage(canvas: Control, from: Vector2, to: Vector2, col: Color, width: float) -> void:
|
||||
var a := from + Vector2(26, 0)
|
||||
var b := to - Vector2(26, 0)
|
||||
var a := from + Vector2(NODE_HALF.x, 0)
|
||||
var b := to - Vector2(NODE_HALF.x, 0)
|
||||
if b.x < a.x:
|
||||
return
|
||||
var mid_x := (a.x + b.x) / 2.0
|
||||
|
||||
@@ -106,6 +106,17 @@ static func load_tex(path: String) -> Texture2D:
|
||||
return null
|
||||
|
||||
|
||||
# CJK 随包字体(2026-09-27 用户拍板 Fusion Pixel OFL——CJK 覆盖+授权干净;命名遵 art-bible §8.3
|
||||
# 全小写 snake_case 域前缀)。守卫装载同 load_tex 纪律:缺位返回 null(引擎回退字体接手,不炸)。
|
||||
const FONT_UI := "res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf"
|
||||
|
||||
static func load_font(path: String) -> Font:
|
||||
if ResourceLoader.exists(path):
|
||||
var res: Resource = load(path)
|
||||
return res as Font
|
||||
return null
|
||||
|
||||
|
||||
# ── StyleBox 工厂 ──
|
||||
# margin = (左, 上, 右, 下);border_w = 0 即无边框(根底/遮罩用)
|
||||
static func panel_flat(bg: Color, border: Color, corner: int = 0, border_w: int = 1, margin: Vector4 = Vector4.ZERO) -> StyleBoxFlat:
|
||||
|
||||
@@ -26,6 +26,10 @@ window/size/viewport_height=800
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/aspect="expand"
|
||||
|
||||
[gui]
|
||||
|
||||
theme/custom_font="res://assets/fonts/ui_font_fusion_pixel_zh_hans.ttf"
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
|
||||
@@ -88,7 +88,7 @@ func _ready() -> void:
|
||||
var t1 := Time.get_ticks_msec()
|
||||
main._start_adventure_from_menu()
|
||||
await get_tree().process_frame
|
||||
# 停点前移批 2026-09-23:关卡开始 = 入口选路停点——真实按钮路径选入口候选进首战
|
||||
# 停点前移批 2026-09-23:关卡开始 = 入口选路停点——press_candidate 图上命中路径选入口候选进首战(候选按钮退役 2026-09-28)
|
||||
#(首格可点 AC 口径随之含入口选路一步:菜单点击 → 停点呈现 → pick → 棋盘安装)
|
||||
if main.route_overlay.visible:
|
||||
main.route_overlay.press_candidate(0)
|
||||
@@ -114,7 +114,7 @@ func _ready() -> void:
|
||||
_flags_placed.clear()
|
||||
continue
|
||||
if main.route_overlay.visible:
|
||||
# 节点图选路口(零自动推进停点)——真实按钮路径交替选候选(岔路使用行为)
|
||||
# 节点图选路口(零自动推进停点)——图上命中路径交替选候选(岔路使用行为;布局决策 2 改造)
|
||||
main.route_overlay.press_candidate(_route_clicks % 2)
|
||||
_route_clicks += 1
|
||||
_flags_placed.clear()
|
||||
|
||||
@@ -62,7 +62,7 @@ func _ready() -> void:
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
# ---- 入口选路停点(停点前移批 2026-09-23:关卡开始即出节点图——level_started 后、首战前弹选路层;
|
||||
# 真实按钮路径 pick 入口单候选进首战)----
|
||||
# press_candidate 图上命中路径 pick 入口单候选进首战——候选按钮退役 2026-09-28)----
|
||||
if not main.route_overlay.visible:
|
||||
problems.append("关卡开始未弹入口选路停点(route_overlay 应可见)")
|
||||
elif main.runner == null or not main.runner.awaiting_choice() \
|
||||
|
||||
@@ -32,6 +32,8 @@ godot --headless --path . res://tests/probes/<probe>.tscn
|
||||
timeout 120s godot --headless --script tests/run_all.gd
|
||||
```
|
||||
|
||||
> 单元/契约套件状态(单元测试与契约层批 2026-09-28):`_suites()` manifest 已登记实跑七套件——data_config(ConfigDB 加载/进度坐标 ceil 解析/fatal 退出码/统一随机流)、battle_settlement(DamageMath 纯函数/DamageController 伤害单据五配方)、contracts(系统间接口契约断言);致命类独立进程 `expect=1`。期望值一律由表读值推导(零玩法数值字面量)、随机只经 RngService 统一流。
|
||||
|
||||
**协议要点**(D2 冒烟协议 + ADR-0004):
|
||||
- 冒烟脚本 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)`
|
||||
- `--script` 模式**不加载 autoload 且不注册全局标识符**(裸引用 `ConfigDB` = parse error)——ConfigDB/RngService 由脚本显式实例化,core 一律构造注入
|
||||
|
||||
+31
-16
@@ -1,13 +1,15 @@
|
||||
# tests/run_all.gd — CI 冒烟统一入口(ADR-0004 双载体的逻辑冒烟调用点)
|
||||
# 用法:godot --headless --script tests/run_all.gd (CI 侧包装外部超时:timeout 120s …)
|
||||
# 协议:套件清单逐条"套件 → 期望退出码"(D2 AC-2b manifest);致命类独立进程。
|
||||
# 现状:正式 Godot 工程尚未在根目录创建(代码在 game/,
|
||||
# 其 smoke/smoke_all.gd 96/96 为参考实现)——本入口当前仅验证运行协议自身;
|
||||
# 正式工程落地后,把套件清单登记进 _suites()。
|
||||
# tests/run_all.gd — 单元测试统一入口(ADR-0004 双载体的逻辑测试调用点,套件 manifest 协议 D2 AC-2b)
|
||||
# 用法:godot --headless --path game --script res://tests/run_all.gd (CI 侧包装外部超时:timeout 120s …)
|
||||
# 协议:套件清单逐条"套件 → 期望退出码";致命类独立进程(expect=1)。
|
||||
# 执行形态(单元测试与契约层批 2026-09-28):逐套件起独立 Godot 子进程实跑
|
||||
#(OS.get_executable_path() 解析本引擎二进制——--script 模式无 autoload/全局标识符,
|
||||
# 每个测试文件自带显式实例化夹具,见 tests/README 协议要点)。
|
||||
# 场景探针(.tscn 载体)由 CI/轻闸单独步骤调用,不经本入口。
|
||||
extends SceneTree
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
print("[run_all] formal smoke entry — protocol check")
|
||||
print("[run_all] unit/contract suite entry — %d suites" % _suites().size())
|
||||
if DisplayServer.get_name() != "headless":
|
||||
push_warning("[run_all] 建议以 --headless 运行(D2 冒烟协议)")
|
||||
var failures := 0
|
||||
@@ -20,22 +22,35 @@ func _init() -> void:
|
||||
print("[run_all] all green (%d suites)" % _suites().size())
|
||||
quit(0)
|
||||
|
||||
# 返回 Array of Dictionary:{ "path": String, "expect": int }
|
||||
# 正式工程落地后在此登记;探针场景(.tscn 载体)由 CI 单独步骤调用,不经本入口。
|
||||
|
||||
# 套件清单:{ "path": String, "expect": int };致命类(期望非 0)独立进程断言退出码。
|
||||
func _suites() -> Array:
|
||||
return [
|
||||
# {"path": "res://tests/unit/example_test.gd", "expect": 0},
|
||||
# {"path": "res://tests/unit/reward_economy/reward_roll_test.gd", "expect": 0},
|
||||
# 致命类(独立进程,期望非 0):
|
||||
# {"path": "res://tests/unit/d2/fixture_missing_table_test.gd", "expect": 1},
|
||||
{"path": "res://tests/unit/example_test.gd", "expect": 0},
|
||||
{"path": "res://tests/unit/data_config/config_db_test.gd", "expect": 0},
|
||||
{"path": "res://tests/unit/data_config/config_db_fatal_oob_test.gd", "expect": 1},
|
||||
{"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0},
|
||||
{"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0},
|
||||
{"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0},
|
||||
{"path": "res://tests/unit/contracts/system_contracts_test.gd", "expect": 0},
|
||||
{"path": "res://tests/unit/presentation/ui_font_asset_test.gd", "expect": 0},
|
||||
{"path": "res://tests/unit/presentation/terrain_tile_sets_test.gd", "expect": 0},
|
||||
]
|
||||
|
||||
|
||||
func _run_suite(suite: Dictionary) -> int:
|
||||
# SceneTree 脚本内不能直接以 OS.execute 递归调 godot 的成熟形态留待正式工程
|
||||
# (需解析 godot 二进制路径);当前协议检查阶段只断言清单形态合法。
|
||||
var path: String = suite.get("path", "")
|
||||
var expect: int = int(suite.get("expect", 0))
|
||||
if path.is_empty():
|
||||
push_error("[run_all] suite missing path")
|
||||
return 1
|
||||
print("[run_all] registered: %s (expect=%d)" % [path, suite.get("expect", 0)])
|
||||
var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://"), "--script", path])
|
||||
var out := PackedStringArray()
|
||||
var code: int = OS.execute(OS.get_executable_path(), args, out, true, false)
|
||||
var text := "\n".join(out)
|
||||
print(text)
|
||||
if code != expect:
|
||||
push_error("[run_all] FAIL %s — 退出码 %d ≠ 期望 %d" % [path, code, expect])
|
||||
return 1
|
||||
print("[run_all] PASS %s (exit=%d)" % [path, code])
|
||||
return 0
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user