Merge branch 'master' into qa-harness

# Conflicts:
#	game/probe/probe_ui_util.gd
This commit is contained in:
DKChan committed 2026-11-06 01:52:17 +08:00
commit 886368684c
49 files changed
+1777 -584

No files matched your search

+1 -1
View File
@@ -79,7 +79,7 @@ func try_spend(action_kind: String) -> bool:
# 排雷玩法 挖掘专用:chain_size = 连锁附加格数(不含首格);clears_map = 本次动作后全图安全格开完。
# 深挖钻头付费开掘(dig_paid counter_spend 订阅施加面):按序消耗族——AP 富余(≥基础+对价)时本动作付全价并积本轮付费计数(效果引擎
# 付费开掘(dig_paid counter_spend 订阅施加面):按序消耗族——AP 富余(≥基础+对价)时本动作付全价并积本轮付费计数(效果引擎
# 计数器=授予段追加产出配额取数面);AP 不足全价时 fallback 降级只付基础 1 照常开格、不积计数
# ——持有钻头永不锁死普通挖格(散件保底 B-2)。
func request_dig(chain_size: int, clears_map: bool) -> bool:
+2 -2
View File
@@ -255,7 +255,7 @@ func begin_battle(enc: Dictionary) -> bool:
_begin_battle_fx_state(enc)
_begin_battle_attune_params(enc)
_begin_battle_preview_economy(enc)
# 工兵晨令=round_start grant_ap(mode:queue) 订阅行承载(when round_index ≤ effective_rounds
# 晨间 AP=round_start grant_ap(mode:queue) 订阅行承载(when round_index ≤ effective_rounds
# value_from 定影);round_start 发射先于行动经济轮切换=树规则 queue 型当轮入队当轮兑现
# (先手压制=首轮 AP+1 语义锚);计数器 round_start 重置点同点生效
round_index = 1
@@ -535,7 +535,7 @@ func _exit_distribution() -> void:
if not _install_and_reset():
_fatal("换图编排失败")
return
# 工兵晨令续队直调拆除(内容数据驱动扩展批 T4——round_start 订阅行承载,见 begin_battle 注)
# 晨间 AP 续队直调拆除(内容数据驱动扩展批 T4——round_start 订阅行承载,见 begin_battle 注)
action_economy.notify_settlement_done(mode)
# round_start 发射先于行动经济轮切换(queue 型 grant_ap 当轮入队当轮兑现——与首轮同口径)
round_index += 1
+19 -66
View File
@@ -106,7 +106,7 @@ func _per_value(name: String, payload: Dictionary) -> int:
return 0
# 巢穴控制数(fact nest_count 取数面——敌方名下 weaken 实例总数,猎巢人订阅面;
# 巢穴控制数(fact nest_count 取数面——敌方名下 weaken 实例总数,nest_count 订阅消费面;
# 个体化后削弱为累加行、每体各持一条——总数 = 被削弱个体数)
func _nest_count() -> int:
var n := 0
@@ -135,7 +135,7 @@ func submit(t: Dictionary) -> int:
# attack_commit 发射点(我方伤害单据开立入口;
# 命中挂单据 context 随行——施加面在 _resolve_hero_attack 管线算术前逐族施加,开单时点=主角攻击
# 单恒为段内首单(结账现场与开单现场一致)。载荷 atk/target/matchup + 挖掘 streak 事实
# (执拗订阅面)+巢穴控制数〔猎巢人〕)
# (开格 streak 订阅面)+巢穴控制数〔nest_count 订阅面〕)
# spec 束必载键(缺载 assert——fail-fast):channel/base_value/target_rule/ri;
# 可选键缺省=原形缺省:origin_cell=(-1,-1)/attack_type=""/rider=0/highlight=""/context={}。
# 返回单据字典九栏目+requires/source_unit 逐字不变(契约 8)。
@@ -247,9 +247,9 @@ func _resolve(t: Dictionary) -> void:
# 主角先手攻击:掷点 → 折扣/暴击 → 克制点数 → hero_offense×enemy_defense 池乘区 → 下限 → 交付
# (attack_commit/clamp_check/armor_roll 订阅施加面:命中=开单入口随单
# context 携带,按固定算序逐族施加——命中序=数据表声明序,确定性)。
# context 携带,按固定算序逐族施加——命中序=数据表声明序,确定性;掷点折扣读数→克制点数两拍)。
# 四步分段:命中面(_hero_attack_ctx,目标/护甲/
# 掷点/按序改写族四拍/克制点数) → 基值装配(_hero_attack_base_value,加法族/计数器施加/倍乘) →
# 掷点/掷点折扣读数/克制点数) → 基值装配(_hero_attack_base_value,加法族/计数器施加) →
# 掷点兑现(_hero_attack_redeem,rolled/池乘/下限/rider/落地/击杀发射) → 交付(_hero_attack_deliver,
# 状态施加/标签/明细行)。步骤间经 ctx/redemption 字典穿线——算序/取值固定(🔴 逐位一致契约)。
func _resolve_hero_attack(t: Dictionary) -> void:
@@ -261,7 +261,7 @@ func _resolve_hero_attack(t: Dictionary) -> void:
_hero_attack_deliver(t, ctx, redemption)
# 命中面(目标/护甲克制 matchup/armor_roll 发射→按序改写族四拍→克制点数读值+订阅改写;
# 命中面(目标/护甲克制 matchup/armor_roll 发射→掷点折扣读数→克制点数读值+订阅改写;
# 后两步各由 _ctx_rewrites/_ctx_clamp_delta 承载)。返回穿线 ctx(空=目标不可达)。
# clamp_check 只在 _ctx_rewrites 内发射一次——_ctx_clamp_delta 消费同批命中(禁重复发射)。
func _hero_attack_ctx(t: Dictionary) -> Dictionary:
@@ -278,54 +278,25 @@ func _hero_attack_ctx(t: Dictionary) -> Dictionary:
return {"target": target, "armor": armor, "atk_type": atk_type,
"ac_hits": (t.context.get("fx_attack_hits", []) as Array), "ac_payload": ac_payload,
"roll_read": rewrites["roll_read"], "threshold": rewrites["threshold"],
"crit": rewrites["crit"], "discount": rewrites["discount"],
"spike_ready": rewrites["spike_ready"], "cdelta": cdelta}
"crit": rewrites["crit"], "discount": rewrites["discount"], "cdelta": cdelta}
# 按序改写族四拍+掷点快照(armor_roll 发射点在此):第一拍矿工重锤命中交换(正负双修原生形态:
# 命中判定减成 trade_hit 点〔更易命中〕换攻击增伤 trade_atk%——高 AC 负期望/低 AC 正期望;
# 改写位先于掷点折扣读数)→ 第二拍专注免掷骰恒定折扣(方差买断成期望——稳定器人格;roll 记 0 归因)
# → 第三拍满层必中(spike ready 时 discount 恒全额、暴击面走倍乘不加掷)→ 第四拍猎弱本能对轻甲敌折扣档改善 value 档
# (对冲 pierce 列 L1 −1 开局罚;改善 = 折扣表向全额方向移档,nat 最大面/已全额不重复)。
# clamp_check 同在此发射(唯一发射点——命中随返回值穿给 _ctx_clamp_delta)。
# 掷点折扣读数+克制点数命中批(armor_roll 发射点在此):一拍护甲掷骰折扣读数(掷点对照
# 防御阈值定折扣档——阈值=防御 AC+攻击加值,nat 面沿折扣表)→ 二拍 clamp_check 发射
# (唯一发射点——命中随返回值穿给 _ctx_clamp_delta)。
func _ctx_rewrites(t: Dictionary, target: Dictionary, armor: String, matchup: String) -> Dictionary:
var ac_hits: Array[Variant] = (t.context.get("fx_attack_hits", []) as Array)
var roll_read: Dictionary[Variant, Variant] = _armor_roll()
var threshold: int = defender_ac(target) + int(db.combat.settlement.damage_roll.attack_bonus)
# armor_roll 发射点(敌护甲掷骰判定;免掷恒折扣订阅面)
var ar_hits := _fx_hits("armor_roll", {"target_ac": threshold, "roll": int(roll_read.roll)})
var trade_hit := 0
for h in ac_hits:
if String(h.get("kind", "")) == "modify_ticket" and int(h.get("trade_hit", 0)) > 0:
trade_hit += int(h.trade_hit)
if trade_hit > 0:
threshold = maxi(1, threshold - trade_hit)
# armor_roll 发射点(敌护甲掷骰判定——事件在册、现役零订阅行,发射面保留)
_fx_hits("armor_roll", {"target_ac": threshold, "roll": int(roll_read.roll)})
var crit: bool = bool(roll_read.crit)
var discount: int = _roll_discount(int(roll_read.roll), threshold)
for h in ar_hits:
if String(h.get("kind", "")) == "modify_ticket" and String(h.get("mode", "")) == "armor_rewrite":
discount = int(h.get("value", 0))
crit = false
roll_read["roll"] = 0
var spike_ready := false
for h in ac_hits:
if String(h.get("kind", "")) == "modify_ticket" and String(h.get("mode", "")) == "spike":
spike_ready = true
discount = 100
var clamp_hits := _fx_hits("clamp_check", {"matchup": matchup})
var shift := 0
for h in clamp_hits:
if String(h.get("kind", "")) != "modify_ticket":
continue
if String(h.get("mode", "")) == "discount_shift":
shift += int(h.get("value", 0))
if shift > 0 and armor == "light" and discount < 100:
discount = _discount_shift_up(discount, shift)
return {"roll_read": roll_read, "threshold": threshold, "crit": crit,
"discount": discount, "spike_ready": spike_ready, "clamp_hits": clamp_hits}
"discount": discount, "clamp_hits": clamp_hits}
# 克制点数读值+订阅改写(魔网织者/湛蓝誓约/猎手刺剑 clamp_check modify delta——克制矩阵查表值
# 克制点数读值+订阅改写(clamp_check 带模式行 modify delta——克制矩阵查表值
# 后的直调;hero_attack 与 board_typed 两消费面同享)。命中批由 _ctx_rewrites 单次发射后传入。
# 淬毒刃纹(行 triggers clamp_check
# modify(mode:clamp_bonus) 订阅命中并入本处 delta 合计;mode 路由=主角攻击面专享,
@@ -338,8 +309,8 @@ func _ctx_clamp_delta(atk_type: String, armor: String, clamp_hits: Array) -> int
return cdelta
# 基值装配(基值+单位标记加伤+矿工重锤增伤面+加法族(attack_bonus/
# value_counter 层值)+计数器施加(attack_commit 用后清零行——命中序=声明序)+spike 倍乘。
# 基值装配(基值+单位标记加伤+加法族(attack_bonus/
# value_counter 层值)+计数器施加(attack_commit 用后清零行——命中序=声明序)。
func _hero_attack_base_value(t: Dictionary, ctx: Dictionary) -> float:
var ac_hits: Array[Variant] = ctx["ac_hits"]
var ac_payload: Dictionary[Variant, Variant] = ctx["ac_payload"]
@@ -347,10 +318,6 @@ func _hero_attack_base_value(t: Dictionary, ctx: Dictionary) -> float:
# 单位标记加伤(处刑台 target_attack_bonus 挂单位字典 fx_atk_bonus 键;
# 阈值沿发射点已写入,本处通用消费——对该敌伤害加算)
base_value += float(int((ctx["target"] as Dictionary).get("fx_atk_bonus", 0)))
# 按序改写族第五拍:矿工重锤增伤面(交换的正向侧)
for h in ac_hits:
if String(h.get("kind", "")) == "modify_ticket" and int(h.get("trade_atk", 0)) > 0:
base_value *= 1.0 + float(int(h.trade_atk)) / 100.0
# 加法族:attack_bonus per 计数器层值(层数×value 加算)。
# value_counter 扩展〔疼痛记忆/弃图兑换/陷阱本能——rider 值=计数器账面,
# 值读于施加时点=命中序先行 rider 后清零行〕
@@ -365,11 +332,6 @@ func _hero_attack_base_value(t: Dictionary, ctx: Dictionary) -> float:
if String(h.get("kind", "")) == "counter_add":
var set_v = h.get("set", null)
_port.counter_add(String(h.get("counter_id", "")), int(h.get("value", 0)) if set_v == null else int(set_v), set_v != null)
# 任意真族:modify_ticket mode=spike 满层倍乘(强制必中已在折扣拍承载)
if ctx["spike_ready"]:
for h in ac_hits:
if String(h.get("kind", "")) == "modify_ticket" and String(h.get("mode", "")) == "spike":
base_value *= float(int(h.get("value", 1)))
return base_value
@@ -391,7 +353,7 @@ func _hero_attack_redeem(t: Dictionary, ctx: Dictionary, base_value: float) -> D
var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_offense", StatusContainer.OWNER_HERO), clamped_pool("enemy_defense", StatusContainer.owner_key_for(target)))
var landed: int = maxi(int(db.combat.settlement.damage_roll.damage_floor), pooled)
# 加法族:英雄侧 rider(enemy_skill rider 对称位补齐口径)——后置必中附加,独立于掷点与乘区
# (奥术余烬「普攻命中后恒定结算」语义);猎巢人巢控光环(per nest_count)/矿工的执拗开格加成
# (普攻命中后恒定结算语义);巢控光环(per nest_count)/开格 streak 加成
# (per dig_streak、cap 封顶)同通道合计;档一质变 rider+1 / 档二(crit 时)rider 乘暴击倍率。
# rider 不改变下限语义(landed 含 rider 后不再过 floor)。
var hero_rider := int(_fact_or("rider_tier_bonus") or 0) # or 0=未绑缺省与 int(null) 缺省同值(int() 构造拒 null——端口缺省 null 面的空值合并)
@@ -421,7 +383,7 @@ func _kill_effects(t: Dictionary, target: Dictionary) -> void:
_port.reveal_effect() # 斩首学者——击杀揭示 1 格(端口路由宿主入队,授予段末兑付)
# 交付:即时执行族(碎甲震击打重甲附加「护甲裂解」——复用 off_guard,零新状态行;
# 交付:即时执行族(重甲附加「护甲裂解」——复用 off_guard,零新状态行;
# apply_effect 域闭/重复授予由状态容器自行守卫)/标签装配/明细行发射/兑现登记。
func _hero_attack_deliver(t: Dictionary, ctx: Dictionary, redemption: Dictionary) -> void:
var payload: Dictionary[Variant, Variant] = redemption["payload"]
@@ -524,11 +486,11 @@ func _resolve_board_typed(t: Dictionary) -> void:
var armor_b: String = String(target.get("armor_type", "light"))
var atk_b: String = String(t.attack_type)
# clamp_check 发射+施加(克制判定修正点棋盘面;与 hero_attack 同表同点:
# modify delta 订阅改写合计进克制点数;discount_shift 档位面属主角攻击专享,本面不施加)
# modify delta 订阅改写合计进克制点数;带模式行只施放于主角攻击面,本面不施加)
var cdelta: int = _counter_delta(atk_b, armor_b)
if atk_b != "":
for h in _fx_hits("clamp_check", {"matchup": atk_b + "_vs_" + armor_b}):
# 无模式 delta 才入棋盘窗(mode 路由:clamp_bonus=主角攻击面专享、discount_shift=折扣档面)
# 无模式 delta 才入棋盘窗(mode 路由:带模式行=主角攻击面专享,棋盘窗只吃无模式 delta)
if String(h.get("kind", "")) == "modify_ticket" and h.has("delta") and String(h.get("mode", "")) == "":
cdelta += int(h.delta)
var dealt: int = DamageMath.counter_component(int(t.base_value), cdelta, int(db.combat.settlement.damage_roll.damage_floor))
@@ -619,15 +581,6 @@ func _counter_delta(attack_type: String, armor: String) -> int:
return int(row.get(armor, 0))
# 折扣档改善(猎弱本能——折扣表向全额方向移 value 档;查不到当前档 = 不动防御口径)
func _discount_shift_up(discount: int, shift: int) -> int:
var tbl: Array[Variant] = db.combat.settlement.damage_roll.roll_tier_discounts
var idx := tbl.find(discount)
if idx < 0:
return discount
return int(tbl[mini(tbl.size() - 1, idx + shift)])
# 池内加算 → clamp [-100,+100](池间乘算归 DamageMath.pool_damage 调用侧);
# 池按个体读(增益状态 v1.5):主角侧池传 OWNER_HERO,敌方侧池传被打者/施放者个体键——
# 乘区只合计该个体名下实例(B 敌的削弱不进 A 敌的账)
+6 -6
View File
@@ -27,7 +27,7 @@ const MINE_EVENTS := ["dig_reveal", "dig_paid", "seal_hit", "mine_hit", "seal_se
## bias_rewards/preview。需求卡片标题计 23 系笔误,落码按行内容全收——commit 留痕)
const EFFECT_KINDS := ["add_ticket_rider", "modify_ticket", "damage_mult", "open_ticket",
"attack_bonus", "yield_mult", "spawn_spell", "grant_material",
"grant_ap", "grant_gp", "bias_wallet", "shield", "spike", "apply_status", "counter_add",
"grant_ap", "grant_gp", "bias_wallet", "shield", "apply_status", "counter_add",
"counter_spend", "reveal_tiles", "remove_mines", "refresh_pick",
"bias_rewards", "preview",
# 六型效果(奇物.md 基础件表 27 件声明清单):
@@ -61,8 +61,8 @@ const FACTS := ["matchup", "is_first_dig_of_battle", "terrain_kind", "target_arm
"ap_exhausted_last", "hp_ratio", "amount", "ap_left", "pct",
"battle_index"] # 关卡域战斗序(1 基,enc 携带;开局姿态组「每关首轮窗」条件面)
## when 算子词汇(封闭——fact 查询面落法:mod_zero=计数器取模判满〔掘进蓄能〕、
## advantageous=克制矩阵正面判定〔淬毒刃纹〕、gte_ref/lt_ref=与另一计数器比较〔深挖钻头配额〕、
## when 算子词汇(封闭——fact 查询面落法:mod_zero=计数器取模判满、
## advantageous=克制矩阵正面判定〔淬毒刃纹〕、gte_ref/lt_ref=与另一计数器比较(计数器配额面)、
## ends_with=后缀匹配〔对某甲族的泛条件〕、mod_eq=取模余数判定〔双管镐每第 2 次——value=模数
## ref=余数;dispatch 预评估口径〔when 于命中前读计数器现值〕下「每第 N 次」系用 ref=N-1 表达〕。
## 均系映射表现状条件装进 fact 的查询面落法,白名单覆盖)
@@ -79,7 +79,7 @@ const WHEN_KEYS := ["fact", "op", "value", "id", "ref", "value_from"]
## axis=bonus_delta 加算区轴向)
const DO_KEYS := ["kind", "value", "value_from", "delta", "delta_from", "mult", "mult_from",
"cap", "cap_from", "cost_mult", "per", "divisor", "dtype", "mode", "channel", "counter_id",
"set", "status_id", "label", "fallback", "target_kinds", "trade_atk", "trade_hit",
"set", "status_id", "label", "fallback", "target_kinds",
"value_event", "value_counter", "axis"]
## 主动件分发通道(不经事件订阅——刷新/显影/移除是玩家主动动作;use_treasure 按 kind 分发)
@@ -154,7 +154,7 @@ func _push_tree_rule(rk: String, params: Dictionary[Variant, Variant]) -> void:
"do": {"kind": "shield", "value": int(params["shield"])}}, {})
"sense_sharp":
# 锋=踩中危险格伤害减半·每关限次——mine_hit 挂点现成(damage_mult 收集→halve_pct,
# 与惊巢反制同路径;伤害在结算战斗段末兑付,探索阶段零改动)
# 与挖掘惩罚减半同路径;伤害在结算战斗段末兑付,探索阶段零改动)
_push_trigger("tree", rk, {"on": "mine_hit",
"when": [{"fact": "counter", "id": "lvl_tree_sense_sharp_used", "op": "lt", "value": int(params["cap_per_level"])}],
"do": {"kind": "damage_mult", "value": int(params["halve_pct"])}}, {})
@@ -449,7 +449,7 @@ static func _vtr_from_refs(rid: String, tr: Dictionary, row: Dictionary, errs: A
## 加法族(数值相加,可交换):add_ticket_rider、attack_bonus、grant_material、grant_ap、
## counter_add、shield——发射点逐条累加收集器。
## 乘法族(收集后一次施加,正负分桶):yield_mult、damage_mult。
## 任意真族(任一命中即生效):spawn_spell、spike。
## 任意真族(任一命中即生效):spawn_spell。
## 按序改写族(收集→单点按声明序施加):modify_ticket 及一切改写窗口效果。
## 按序消耗族(按订阅序消耗共享资源,耗尽即止;可选 fallback):counter_spend。
## 即时执行族(每条命中各自执行一次,执行序=声明序):open_ticket、
+6 -6
View File
@@ -1,5 +1,5 @@
## 奖励授予件(自 battle_orchestrator 收编 grant 族):极性字典路由兑付(grant_routed——含封印溢价
## 与惊巢反制减半)、三种行应用(flat 四分支/percent/gp)、伤害单据提交面(submit——案C 暂存闸承载)、
## 与挖掘惩罚减半)、三种行应用(flat 四分支/percent/gp)、伤害单据提交面(submit——案C 暂存闸承载)、
## 清图奖励与身份授予(完美封印第二份)、战斗内 GP 缓冲 escrow、秘宝使用四步编排(use_treasure——
## 相位闸→校验→受理闸→逐 kind 应用→消耗)、揭示奖励兑付(reveal_redeem——fx_host flush 的兑付回调面)。
## 生命周期=随编排器(编排器 _init 同建同灭,每战新建)。
@@ -127,7 +127,7 @@ func use_treasure(handle: int, target: Vector2i) -> Dictionary:
return {"ok": true, "item_id": v.item_id, "resolved": v.resolved, "record": rec}
# 受理闸(treasure_used 发射+施加:counter_spend 订阅=掘进蓄能积点账本,积点不足拒绝零消耗。
# 受理闸(treasure_used 发射+施加:counter_spend 订阅=付费积点账本,积点不足拒绝零消耗。
# 零 item_id 特判;拒绝文案读装配集积点进度——宿主 attune 读投影)
func _charge_gate(v: Dictionary, target: Vector2i) -> Dictionary:
var tu_hits := fx_host.fx_emit("treasure_used", {"item_id": String(v.item_id), "target": target})
@@ -180,13 +180,13 @@ func _safe_dig_open(v: Dictionary) -> Dictionary:
return {}
# 爆破工兵包兑付(中立移除危险格:未揭示/无封印/是雷过滤归排雷玩法;
# 中立移除兑付(未揭示/无封印/是雷过滤归排雷玩法;
# 邻接数字冻结零重算、清图分母零涉(危险格不入分母);空集拒绝零消耗——
# consume 之前 return(拒绝面注记扩「③内零消耗分支」),拒绝 reason 可呈现)
func _cell_removal_open(v: Dictionary) -> Dictionary:
var blasted: Array[Variant] = fx_host.minesweeper.remove_mines_in(v.resolved)
if blasted.is_empty():
return {"ok": false, "reason": "爆破工兵包:3×3 区域内无可移除危险格(须未揭示且未封印)"}
return {"ok": false, "reason": "中立移除:3×3 区域内无可移除危险格(须未揭示且未封印)"}
return {}
@@ -218,12 +218,12 @@ func grant_routed(snap: Dictionary, polarity: String, att: Dictionary) -> bool:
row = row.duplicate()
row["power"] = int(round(int(row.power) * prem_pct / 100.0))
row["premium_pct"] = prem_pct
# 惊巢反制(减半系数=mine_hit damage_mult 订阅命中〔行 value 50=减 50%,取整 ceil〕;
# 挖掘惩罚减半(减半系数=mine_hit damage_mult 订阅命中〔行 value 50=减 50%,取整 ceil〕;
# duplicate 隔离——减半不回写极性字典)
if halve_pct > 0 and polarity == "negative" and source_class == "mine_hit":
row = row.duplicate()
var half: int = int(ceil(float(int(row.power)) * float(100 - halve_pct) / 100.0))
emit_cb.call({"type": "effect", "source_class": source_class, "origin_cell": cell}, ri, "惊巢反制——危险格挖掘惩罚减半(%d→%d)" % [int(row.power), half], 0, {"effect_id": "nest_counter"})
emit_cb.call({"type": "effect", "source_class": source_class, "origin_cell": cell}, ri, "挖掘惩罚减半(%d→%d)" % [int(row.power), half], 0, {"effect_id": "nest_halve"})
row.power = half
return _dispatch_row(row, att)
+4 -4
View File
@@ -1,4 +1,4 @@
## 掷格件:装配期格面资源摇号——池行装配、区间行 roll、权重合成(偏运硬币/board_skew 两路投影)。
## 掷格件:装配期格面资源摇号——池行装配、区间行 roll、权重合成(订阅加权/board_skew 两路投影)。
## 自 RewardEconomy 迁出掷格面(architecture v1.29 轴戊②);受理账面(grant/身份查询/快照/retire/
## polarity_row)留守 RewardEconomy——衔接面=格→奖励身份映射批束(本件产出、受理件接收)。
## 取号流声明:roll 每批恰开一 reward_roll 流(scope=每次 install 批不变),体序=normalize(行主序
@@ -38,12 +38,12 @@ func _init(bundle: Dictionary) -> void:
func roll(eligible: Array) -> Dictionary[Variant, Variant]:
var list: Array[Dictionary] = _normalize_eligible(eligible)
var stream: RandomNumberGenerator = rng.open_stream("reward_roll") # 恰一 scope 批(格批 + 清图批尾 + 区间行追加取号)
# reward_roll 发射归 BattleFxHost(偏运硬币 bias_rewards 订阅命中合计经注入查询口取回);
# reward_roll 发射归 BattleFxHost(bias_rewards 订阅命中合计经注入查询口取回);
# 零命中/未接线=0=原行集零扰动
var luck_bias := 0
if luck_bias_query.is_valid():
luck_bias = luck_bias_query.call(list.size())
var cell_rows: Array[Variant] = _biased_cell_rows(luck_bias) # cell 池正向行 weight>0(偏运硬币装配时稀有正向 kinds 权重 +N——池结构零触碰)
var cell_rows: Array[Variant] = _biased_cell_rows(luck_bias) # cell 池正向行 weight>0(订阅件装配时稀有正向 kinds 权重 +N——池结构零触碰)
var clear_rows: Array[Variant] = _pool_rows("clear_default")
# ① 身份批:每非封锁格恰一类型身份(雷安同池——同一行集同一权重分布)
var draft := {}
@@ -83,7 +83,7 @@ func _normalize_eligible(eligible: Array) -> Array[Dictionary]:
return list
# 偏运硬币权重投影(底稿 §3:只动池内权重、雷安仍同池同分布;luck_bias=0 逐位返回原行集——off 态零扰动)
# 订阅加权权重投影(底稿 §3:只动池内权重、雷安仍同池同分布;luck_bias=0 逐位返回原行集——off 态零扰动)
# 合成序:先经图级偏斜投影再叠件级加法——合成序锚=图级先、件级后
#(环境事实先定型、玩家构筑叠其上;两投影均为池内权重面,雷安同池与取号次数零变化)
func _biased_cell_rows(bias: int) -> Array:
+11 -11
View File
@@ -124,8 +124,8 @@ func _reveal_safe(evt: Dictionary, snap: Dictionary, ri: int) -> void:
# dig_reveal 施加面(序=计数器〔连击计数——乘区收集后执行=本格兑付用旧值〕→ 揭示入队〔地质学家/
# 跳格采掘 reveal_tiles 订阅面,授予段末统一掷格兑付〕→ 追加产出〔深挖钻头 v2 双份制:grant_material
# 命中=配额内追加第二份〕→ 奥术矢〔魔能共鸣——每战首次开安全格追加魔法矢;任意真族单次生效〕)
# 跳格采掘 reveal_tiles 订阅面,授予段末统一掷格兑付〕→ 追加产出〔双份制:grant_material
# 命中=配额内追加第二份〕→ 奥术矢(每战首次开安全格追加魔法矢;任意真族单次生效))
func _reveal_safe_apply(dr_hits: Array, evt: Dictionary, ri: int) -> void:
for h in dr_hits:
if String(h.get("kind", "")) == "counter_add":
@@ -163,7 +163,7 @@ func _reveal_mine(evt: Dictionary, snap: Dictionary, ri: int) -> void:
reward_grant.grant_routed(reward_grant.feat_yield_adjust(snap, false, 0, 0), "negative", {"source_class": "mine_hit", "cell": evt.cell, "ri": ri, "halve_pct": int(applied["halve_pct"])})
for h in mh_hits:
if String(h.get("kind", "")) == "open_ticket" and String(h.get("channel", "")) == "reflect":
var nct := damage.open_ticket(DamageController.KIND_BOARD_BLAST, "attune_nest_counter", "惊巢反制·反弹",
var nct := damage.open_ticket(DamageController.KIND_BOARD_BLAST, "attune_nest_halve", "反弹伤害",
{"channel": "board", "base_value": int(h.get("value", 0)), "target_rule": DamageController.TARGET_ALL_ALIVE,
"ri": ri, "origin_cell": evt.cell})
reward_grant.submit(nct, false)
@@ -229,7 +229,7 @@ func _terrain_batch(ri: int) -> void:
# 封印判定批+战封印军规开单段(封印流:seal_judged 批后统一开单:轮末每面封印命中对最前存活敌
# 结算 board_typed 必中伤害〔复用零新配方;「封印所在巢穴敌」简化为 default_target 最前存活敌——
# 台账披露待追认〕;完美布防=本轮封印批全中(hit>0 且 miss==0)时封印伤 ×mult——全对推理奖励)。
# 台账披露待追认〕;封印全中倍率=本轮 hit>0 且 miss==0 时封印伤 ×mult——全对推理奖励)。
# 伤害单据案C 暂存战斗段末兑付。
func _seal_batch(ri: int) -> void:
for evt in facts.seals:
@@ -237,7 +237,7 @@ func _seal_batch(ri: int) -> void:
_seal_strike_batch(ri)
# seal_settle 发射+施加(战封印 open_ticket + 完美布防 modify_ticket 改写窗口:封印伤基值×完美倍率后
# seal_settle 发射+施加(战封印 open_ticket + 封印全中倍率 modify_ticket 改写窗口:封印伤基值×完美倍率后
# 逐封印开单)
func _seal_strike_batch(ri: int) -> void:
var fs_hits := 0
@@ -262,7 +262,7 @@ func _seal_strike_batch(ri: int) -> void:
# 战封印军规基值决议(attack 通道 open_ticket 首命中取基值;seal_mult modify_ticket 连乘;
# 完美布防=miss==0 且 hit>0 时基值×倍率。空旗集=空字典〔不开单合法态〕)
# 封印全中倍率=miss==0 且 hit>0 时基值×倍率。空旗集=空字典〔不开单合法态〕)
func _seal_strike_value(fs_fx: Array, fs_hits: int, fs_misses: int) -> Dictionary:
var banner_hits: Array[Variant] = []
for h in fs_fx:
@@ -276,11 +276,11 @@ func _seal_strike_value(fs_fx: Array, fs_hits: int, fs_misses: int) -> Dictionar
if String(h.get("kind", "")) == "modify_ticket" and String(h.get("mode", "")) == "seal_mult":
seal_mult *= maxi(1, int(h.get("value", 1)))
if fs_misses == 0 and fs_hits > 0 and seal_mult > 1:
strike_value *= seal_mult # 完美布防:同轮全中封印伤倍增(全对推理奖励)
strike_value *= seal_mult # 封印全中:同轮全中封印伤倍增(全对推理奖励)
return {"value": strike_value}
# round_end 发射+施加(每轮结算收口:熔火之躯反打单开立〔open_ticket 即时执行〕+积层清零〔counter_add
# round_end 发射+施加(每轮结算收口:轮末反打单开立〔open_ticket 即时执行〕+积层清零〔counter_add
# set〕;停手侧 AP 已在 request_stop 记账,finish_round_stop 按转存率转存 RAP)+施加面:grant_gp〔爆破会计/收盘——escrow 战斗域
# 缓冲,随 gp_dropped 上抛〕/grant_ap mode:queue〔封印回响——返 1 AP 下轮,queue_ap_delta 通道与轮始
# 加成同面〕+段界收口(转存率入参、清图奖励、揭示兑付、段界结账)
@@ -348,7 +348,7 @@ func _judge_seal(evt: Dictionary, ri: int) -> void:
# 封印命中(资格以危险格实例为键去重——排雷玩法 诚实重复上报的对侧;seal_hit 发射序:
# 殉爆票据 → 施加面 → 雷区图谱额外兑付 → 基线正向兑付)
# 标中雷反打票据 → 施加面 → 标中雷额外兑付 → 基线正向兑付)
func _judge_seal_hit(evt: Dictionary, ri: int) -> void:
var cell: Vector2i = evt.cell
if facts.seal_consumed.has(cell):
@@ -359,7 +359,7 @@ func _judge_seal_hit(evt: Dictionary, ri: int) -> void:
"terrain_kind": minesweeper.harmful_adjacent(cell)})
_seal_burst_tickets(fh_hits, cell, ri)
_seal_hit_effects(fh_hits, cell, ri)
# 雷区图谱(封印流——标中雷额外获得一份格面资源:封印开始产出对标开格;
# 标中雷追加(封印流——标中雷额外获得一份格面资源:封印开始产出对标开格;
# 复用 grant 单点,额外兑付走同一 grant_routed 正向路由)
for h in fh_hits:
if String(h.get("kind", "")) == "grant_material":
@@ -370,7 +370,7 @@ func _judge_seal_hit(evt: Dictionary, ri: int) -> void:
reward_grant.grant_routed(snap, "positive", {"source_class": "seal_hit", "cell": cell, "ri": ri, "highlight": "seal_hit"})
# 标中殉爆票据(行 triggers open_ticket channel blast value_from magnitude——每标中一面封印全体存活敌
# 标中雷反打票据(行 triggers open_ticket channel blast value_from magnitude——每标中一面封印全体存活敌
# 吃 N 群伤,无类型走 blast 通道;封印爆裂〔专长〕同面消费)
func _seal_burst_tickets(fh_hits: Array, cell: Vector2i, ri: int) -> void:
for h in DamageController.hits_of(fh_hits, "open_ticket"):
+3 -2
View File
@@ -1,6 +1,7 @@
## 关卡节点图参数档 schema(节点图生成器核心批 卡① T1):五旋钮+战斗保底+岔口去路数的行式数据表。
## 键 = level_index(关卡行集单点),消费面经 ConfigDB.run.level_gen_params.row_by_level_index() 读取;生成器只吃参数档、不认关卡号(契约 6)。
## 数值全 provisional(定值批落终值);域校验沿关卡节点图生成.md v1.1 调参表已锁条款——层深 L1≥6 递增增幅变大、
## 数值全 provisional(定值批落终值);取值域与字段契约单点=本文件域校验(概念与设计意图指针=
## 关卡节点图生成.md v1.5 参数契约节)——层深 L1≥6 递增增幅变大、
## 配比四类合计 1、出口 L1 恒 1/L2-L3 1-3、岔口去路数恒 2/3、战斗保底≥0。
class_name LevelGenParams
extends Resource
@@ -66,7 +67,7 @@ func _validate_row_shape(row: Dictionary, L: int, key: String, layers_by_level:
var bd: float = float(row.get("branch_density", -1.0))
if bd < 0.0 or bd > 1.0:
errs.append({"category": "domain", "message": "%s 分支密度 %f 越域 [0,1]" % [key, bd]})
# 域并单点(关卡节点图生成.md v1.4 调参表——生成器侧域校验退役,唯此一处)
# 取值域单点=本文件域校验(概念与设计意图指针=关卡节点图生成.md v1.5 参数契约节)
var ftw: float = float(row.get("fork_third_way_pct", -1.0))
if ftw < 0.0 or ftw > 100.0:
errs.append({"category": "domain", "message": "%s fork_third_way_pct %f 越域 [0,100]" % [key, ftw]})
+2 -2
View File
@@ -17,7 +17,7 @@ const UNLOCK_COUNTER_KEYS := ["adventure_cleared", "boss_kill_count", "vision_us
const DIMENSIONS := ["info", "risk", "action", "plan", "counter"]
# enum 九 kind 全部条件触发/机制修正类;mine_reveal/ap_grant 语义不变。
# cell_removal = 爆破工兵包中立移除格;mine_hit_halve 系固定
# cell_removal = 中立移除危险格通道;mine_hit_halve 系固定
# 「减半」语义不消费 magnitude(反弹 N 由 reflect_damage 行 magnitude 承载)。
# 主动件 kind 词族(reveal_tiles/preview/grant_ap/remove_mines/
# refresh_pick——效果引擎 ACTIVE_KINDS 同名闭集);被动件不走 effect_kinds 词族
@@ -119,7 +119,7 @@ func _validate_row_head(row: Dictionary, iid: String) -> Array[Variant]:
## 订阅形态域:triggers 事件订阅行判据与行级触发词表校验(被动件形态;与 effect_kinds 互斥校验
## 面=passive 行(单形态——效果行为只走订阅);active_charged 行两形合法并存
## (effect_kinds=主动分发通道 + triggers=受理闸订阅,掘进蓄能先例))
## (effect_kinds=主动分发通道 + triggers=受理闸订阅,积点解锁先例))
func _validate_row_triggers(row: Dictionary, iid: String) -> Array[Variant]:
var errs: Array[Variant] = []
if row.has("triggers"):
+122 -20
View File
@@ -31,6 +31,8 @@ const SLOT_STEP_Y := 58 # 同层槽位纵向步距
const HIT_PAD := 6.0 # 图上命中外扩(指宽容差)
const CONFIRM_WINDOW_S := 3.0 # 放弃确认态窗口(秒——超时回落)
const COL_HP_BAR_BACK := UiKit.HP_BAR_BACK # 迷你血条深底(对暗面板可辨;值单点收 UiKit)
const DRAG_THRESHOLD := 8.0 # 拖动距离阈值(布局决策 5 provisional——超阈值进拖拽态,拖拽不触发选路)
const WHEEL_STEP := 60.0 # 滚轮横滚单档步进(provisional 实现期定值;方向映射=代拍板:下/右=向右前进向)
## 内部状态件(u2 子域拆件——契约 9 职责轴):_MapState=选路屏运行态与图绘制态(单一变化原因=
## 选路呈现)。layer_decor 域字典承载换层装饰三件(图标贴图/石纹面/tint 层号——装饰域归并形态)。
@@ -39,6 +41,7 @@ class _MapState extends RefCounted:
var runner = null
var title: Label
var canvas: Control
var viewport: LevelMapViewport # 卷轴视口承载件(布局决策 5——画布宿主/裁切/clamp/跟随/指引)
var level_label: Label
var gp_value: Label
var hp_value: Label
@@ -54,6 +57,8 @@ class _MapState extends RefCounted:
var _cand_ids: Array[int] = [] # 本停点候选 id 集(图上命中合法集;探针直读冻结——住宿主)
var _content_size := Vector2(620, 240) # 图内容包络(open_choices 按图拟合——画布 min 尺寸随它;探针直读冻结)
var _hover_id := -1 # 悬停预承诺候选(-1 = 无;探针直读冻结)
var _drag_active := false # 拖拽态(超阈值至抬杆——拖拽不触发选路,布局决策 5)
var _drag_press_pos := Vector2(-1, -1) # 拖距判定基准(按下点画布局部坐标;-1 = 无按下跟踪)
var abandon_btn: Button # 放弃入口(可见性归组合根 set_abandon_available——只在冒险模式开;探针直读冻结)
var status_badge: Button # 状态面板入口角标(顶栏 GP 徽右侧;连接归组合根;探针直读冻结)
var _layout_cache := {"nodes": [], "canvas_size": Vector2.ZERO, "pos": {}} # 布局缓存(_layout 纯函数于 (nodes, canvas)——键=nodes 内容等价+画布尺寸,图态换代或画布尺寸变即自然失效;探针直读冻结)
@@ -74,15 +79,15 @@ func _init() -> void:
base.add_child(tex)
_map.layer_decor.noise_rect = tex # 世界侧 tint 承载面(换层色板渐变挂点——§2.7 切片档)
base.add_child(UiKit.vignette_rect())
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
base.add_child(center)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 10)
center.add_child(box)
_build_top_bar(box)
_build_canvas(box)
_build_status_bar(box)
# 三段纵排页(布局决策 5 卷轴视口:顶栏/标题在上、图区视口扩张充盈、底栏在下——
# 视口为窗口屏为框,画布随图生长可超屏)
var page := VBoxContainer.new()
page.set_anchors_preset(Control.PRESET_FULL_RECT)
page.add_theme_constant_override("separation", 10)
base.add_child(page)
_build_top_bar(page)
_build_canvas(page)
_build_status_bar(page)
visible = false
@@ -122,14 +127,21 @@ func _build_top_bar(box: VBoxContainer) -> void:
box.add_child(_map.title)
## 节点图画布域(图上点击 = 唯一提交通道——布局决策 2;mouse_filter STOP 接悬停/点击)
## 节点图区(卷轴视口承载——布局决策 5):视口件为窗口(裁切/offset/clamp/跟随/指引),
## 画布为其子件;图上点击 = 唯一提交通道(布局决策 2),拖拽/滚轮判定留在画布 _gui_input
## (事件流同源——悬停/选路同源,视口件不做父子事件路由)
func _build_canvas(box: VBoxContainer) -> void:
_map.canvas = Control.new()
_map.canvas.custom_minimum_size = Vector2(620, 240)
_map.canvas.mouse_filter = Control.MOUSE_FILTER_STOP
_map.canvas.draw.connect(_draw_graph.bind(_map.canvas))
_map.canvas.gui_input.connect(_on_canvas_input)
box.add_child(_map.canvas)
_map.canvas.mouse_exited.connect(_on_canvas_mouse_exited)
_map.viewport = LevelMapViewport.new()
_map.viewport.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_map.viewport.size_flags_vertical = Control.SIZE_EXPAND_FILL
box.add_child(_map.viewport)
_map.viewport.setup(_map.canvas, _viewport_anchor, Vector2(1280, 800)) # 兜底尺寸=设计基座(实况窗口以布局为准)
## 底部状态栏域(合并单条:亮米面板一站读齐=头像+生命+属性+本关加成/流派件——demo ④;
@@ -297,6 +309,9 @@ func open_choices(cands: Array, runner: LevelRunner) -> void:
_fit_canvas(_map.runner.nodes())
_apply_level_tint(int(_map.runner.level_row.get("level_index", 0)))
visible = true
# 视口锚定(布局决策 5):入口停点(尚无当前节点)=首锚即达;其余停点/兑付回图=平滑跟随
# (停点切换与兑付确认回图——减动效开由视口件跳终态)
_map.viewport.reanchor(_map.runner.current_node_id() >= 0)
if not was_visible:
# 独立屏淡入(spec Transitions:约 200ms;alpha 淡入非明度变化——art-bible §2 明度恒定)
modulate.a = 0.0
@@ -323,6 +338,7 @@ func _fit_canvas(nodes: Array) -> void:
SLOT_BASE_Y + maxslot * SLOT_STEP_Y + NODE_HALF.y + 24.0) # +24 = 类型文字标签行
_content_size = _content_size.max(Vector2(620, 256)) # 下限(抬高容纳标签行)
_map.canvas.custom_minimum_size = _content_size
_map.canvas.size = _content_size # 同帧收敛(视口锚点/包络数学与 _layout 同源读画布尺寸——容器延迟收敛不等锚点)
# 换层色板渐变挂点(art-bible §2.7 下探过渡切片档——关卡结算确认→下一关→独立屏淡入链路上的
@@ -356,20 +372,70 @@ func apply_status(payload: Dictionary) -> void:
# ---- 图上命中(布局决策 2——唯一提交通道;探针 press_candidate 同路径) ----
# 拖拽与点击以拖动距离阈值区分(布局决策 5):按下起跟踪拖距,超阈值进拖拽态逐帧平移视口,
# 抬杆不提交;未超阈值的完整点击(按下→抬杆)=提交沿——命中候选即提交,语义零改动。
func _on_canvas_input(ev: InputEvent) -> void:
if _map.runner == null or not visible:
return
if ev is InputEventMouseMotion:
var nid := _candidate_at(ev.position) # gui_input 事件坐标 = 接收控件局部坐标(push_input 同成立)
if nid != _hover_id:
_hover_id = nid
_map.canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW
if ev is InputEventMouseButton and ev.pressed and _wheel_pan_step(ev.button_index) != 0.0:
_map.viewport.pan_by(Vector2(_wheel_pan_step(ev.button_index), 0))
elif ev is InputEventMouseMotion:
_on_canvas_motion(ev)
elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT:
_on_canvas_click(ev)
## 悬停预承诺与拖拽态推进沿(画布局部坐标——gui_input 事件流同源)
func _on_canvas_motion(ev: InputEventMouseMotion) -> void:
if _drag_active:
_map.viewport.pan_by(ev.relative) # 拖拽态逐帧平移(视口件执行 clamp——本屏只判定)
return
if _drag_press_pos.x >= 0.0 and ev.position.distance_to(_drag_press_pos) >= DRAG_THRESHOLD:
_drag_active = true
_drag_press_pos = Vector2(-1, -1)
if _hover_id != -1: # 进拖拽态清悬停预承诺(拖拽非选路意图)
_hover_id = -1
_map.canvas.mouse_default_cursor_shape = Control.CURSOR_ARROW
_map.canvas.queue_redraw()
elif ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT and ev.pressed:
var nid2 := _candidate_at(ev.position)
if nid2 >= 0:
_submit_choice(nid2)
_map.viewport.pan_by(ev.relative) # 跨阈值帧位移计入平移(拖距全长=平移全长)
return
var nid := _candidate_at(ev.position) # gui_input 事件坐标 = 接收控件局部坐标(push_input 同成立)
if nid != _hover_id:
_hover_id = nid
_map.canvas.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if nid >= 0 else Control.CURSOR_ARROW
_map.canvas.queue_redraw()
## 点击沿与拖拽抬杆(未超阈值的完整点击=提交沿——布局决策 2 语义零改动)
func _on_canvas_click(ev: InputEventMouseButton) -> void:
if ev.pressed:
_drag_press_pos = ev.position
_drag_active = false
return
_drag_press_pos = Vector2(-1, -1)
if _drag_active:
_drag_active = false # 拖拽抬杆=平移结束,不触发选路提交(布局决策 5)
return
var nid2 := _candidate_at(ev.position)
if nid2 >= 0:
_submit_choice(nid2)
## 滚轮横滚步进(代拍板:下/右=向右前进向——视线移向深层,画布左移;上/左反向;非滚轮=0)
func _wheel_pan_step(button_index: int) -> float:
if button_index == MOUSE_BUTTON_WHEEL_DOWN or button_index == MOUSE_BUTTON_WHEEL_RIGHT:
return -WHEEL_STEP
if button_index == MOUSE_BUTTON_WHEEL_UP or button_index == MOUSE_BUTTON_WHEEL_LEFT:
return WHEEL_STEP
return 0.0
## 拖距跟踪器生命周期清理(按下即武装——鼠标离画布且未入拖拽态时解除,
## 防丢失 release 沿后的残留 armed 态把后续 motion 误判成拖拽激活)
func _on_canvas_mouse_exited() -> void:
if not _drag_active:
_drag_press_pos = Vector2(-1, -1)
# 候选命中:位置 = 布局公式(与 _draw_graph 同源),矩形 = 节点方块 + 外扩容差;非候选格不响应
@@ -413,6 +479,42 @@ func press_candidate(index: int) -> void:
_submit_choice(nid)
# ---- 视口锚点(布局决策 5:当前节点居中;入口停点尚无当前节点=锚入口组包围盒居中) ----
## 锚点画布坐标提供方(构造注入视口件——本屏供数,视口件只消费)
func _viewport_anchor() -> Vector2:
if _map.runner == null:
return _content_size / 2.0
var nodes: Array[Variant] = _map.runner.nodes()
var pos := _layout(nodes, _map.canvas)
var cur_id: int = _map.runner.current_node_id()
if cur_id < 0:
return _entry_group_center(nodes, pos) # 入口停点(尚未走出第一步)
var cur := _node_by_id(cur_id, nodes)
if cur.is_empty():
return _content_size / 2.0
return pos[cur]
func _entry_group_center(nodes: Array, pos: Dictionary) -> Vector2:
# 入口组 = 全部无入边节点(与 _assign_layers 入口判据同源);多入口取包围盒居中
var inbound := {}
for node in nodes:
for tid in _out_ids(node, nodes):
inbound[tid] = true
var lo := Vector2(INF, INF)
var hi := Vector2(-INF, -INF)
for node in nodes:
if inbound.has(_id_of(node)):
continue
var p: Vector2 = pos[node]
lo = lo.min(p)
hi = hi.max(p)
if lo.x > hi.x:
return _content_size / 2.0 # 空图兜底(无入口——不达路径,防御位)
return (lo + hi) / 2.0
func _id_of(node: Dictionary) -> int:
# 编号显示经行进器读面(编号空间解析单一出口——单链 = 下标 / 图 = node_depth)
return _map.runner.node_id_of(node)
@@ -0,0 +1,190 @@
## 卷轴视口承载件(level-map.md 布局决策 5——关卡画面选路屏):画布宿主与裁切、平移状态
## 与 clamp、锚点跟随与平滑滚动、指引件(屏缘渐隐带+平移提示 chip)。
## 纯呈现态:平移位置零核心写、不进存档(重进本屏按锚点规则重新定位);跟随只随停点切换/
## 兑付回图发生(零自动推进)。拖拽/滚轮判定留画布 _gui_input(事件流同源——悬停/选路同源),
## 本件只管裁切/offset/clamp/跟随/指引;画布包络=画布实际尺寸(open_choices 拟合后同帧收敛)。
class_name LevelMapViewport
extends Control
const SCROLL_DUR := 0.3 # 平滑滚动时长(布局决策 5 provisional 0.3s 单段缓动)
const FADE_BAND_W := 70.0 # 屏缘渐隐带宽(布局决策 5/像素级判据②)
const CHIP_SIZE := Vector2(240, 30) # 平移提示 chip(像素级判据③——图区底部居中)
const CHIP_CORNER := 6 # chip 圆角(布局决策 5)
const CHIP_BOTTOM_GAP := 24.0 # chip 距视口底缘(效果图档同族取值)
const CHIP_TEXT := "◄ 拖拽画布 / 滚轮平移 ►" # 14px(FS_HINT 档——布局决策 5)
const EDGE_EPS := 0.5 # 包络边界判定容差(浮点平移累积)
var _canvas: Control
var _anchor_provider: Callable # () -> Vector2 锚点画布坐标(screen 提供——当前节点/入口组)
var _viewport_size := Vector2.ZERO # 窗口尺寸兜底(setup 注入设计基座——实况窗口以控件 size 为准,布局晚于构造)
var _offset := Vector2.ZERO: # 画布左上角在视口件坐标系的位置(纯呈现态——直驱画布 position)
set(value):
_offset = value
if _canvas != null:
_canvas.position = _offset
var _view_tween: Tween
var _guided_visible := false # 指引件可见态(画布超窗才暗示——浅图无平移余量即隐藏)
## 一次交齐注入(code-conventions §1.6 拆分交接:画布宿主、锚点来源、窗口兜底尺寸)
func setup(canvas: Control, anchor_provider: Callable, viewport_size: Vector2) -> void:
_canvas = canvas
_anchor_provider = anchor_provider
_viewport_size = viewport_size
clip_contents = true
add_child(canvas)
_build_guidance()
func _notification(what: int) -> void:
# 窗口实况尺寸落定/变化即重锚(首开布局晚于 open_choices 的锚定——隐藏子树容器排序
# 首个可见帧才传播,4.7 行为;实况窗口以本件 size 为准,落定即收敛锚点)
if what == NOTIFICATION_RESIZED and _canvas != null and _anchor_provider.is_valid():
reanchor(false)
## 锚点跟随(布局决策 5:锚点居中;smooth=停点切换/兑付回图平滑滚动,减动效开=跳终态;
## 重入先杀在飞 tween 再建新——快速连续停点切换防叠加)
func reanchor(smooth: bool) -> void:
if _canvas == null or not _anchor_provider.is_valid():
return
var target := _clamped_center_offset(_anchor_provider.call())
_kill_scroll_tween()
if not smooth or ArmedTimer.motion_still():
_offset = target
_sync_guidance()
return
_view_tween = create_tween()
_view_tween.tween_property(self, "_offset", target, UiKit.motion(SCROLL_DUR)) \
.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
_sync_guidance()
## 手动平移(画布 _gui_input 判定后调用——拖拽沿逐帧/滚轮步进;纯呈现零核心写)
func pan_by(delta: Vector2) -> void:
if _canvas == null:
return
_kill_scroll_tween()
var win := _window_size()
_offset = Vector2(
_axis_pan_offset(_offset.x, delta.x, _canvas.size.x, win.x),
_axis_pan_offset(_offset.y, delta.y, _canvas.size.y, win.y))
_sync_guidance()
## 视口中线的画布坐标(锚点居中判据投影面——浅图=画布中心,深图=夹紧域内当前注视点)
func viewport_center_canvas_pos() -> Vector2:
return _window_size() / 2.0 - _offset
## 视口是否钉在画布包络边界(任一超窗轴 offset 顶边——clamp 判据面)
func is_clamped_at_edge() -> bool:
var win := _window_size()
if _canvas == null:
return false
return _axis_at_edge(_offset.x, _canvas.size.x, win.x) \
or _axis_at_edge(_offset.y, _canvas.size.y, win.y)
func _kill_scroll_tween() -> void:
if _view_tween != null and _view_tween.is_valid():
_view_tween.kill()
_view_tween = null
## 单轴锚定 offset:画布不超窗=居中分支(夹紧域退化——布局决策 5);
## 超窗=锚点夹紧在画布包络内,锚点落视口中线(offset = 半窗 − 锚点)
func _axis_center_offset(anchor: float, canvas_len: float, half_win: float) -> float:
if canvas_len <= half_win * 2.0:
return half_win - canvas_len / 2.0
return half_win - clampf(anchor, half_win, canvas_len - half_win)
func _clamped_center_offset(anchor: Vector2) -> Vector2:
var half := _window_size() / 2.0
return Vector2(
_axis_center_offset(anchor.x, _canvas.size.x, half.x),
_axis_center_offset(anchor.y, _canvas.size.y, half.y))
## 单轴平移收敛:居中轴零平移余量不动;超窗轴 offset 收敛在 [半窗−画布, 0](不出露屏底空档)
func _axis_pan_offset(cur: float, delta: float, canvas_len: float, win_len: float) -> float:
if canvas_len <= win_len:
return cur
return clampf(cur + delta, win_len - canvas_len, 0.0)
func _axis_at_edge(offset: float, canvas_len: float, win_len: float) -> bool:
if canvas_len <= win_len:
return false
return offset >= -EDGE_EPS or offset <= win_len - canvas_len + EDGE_EPS
## 实况窗口优先(容器布局晚于构造——注入值只兜构造帧数学)
func _window_size() -> Vector2:
if size.x > 0.0 and size.y > 0.0:
return size
return _viewport_size
## 指引件(布局决策 5/像素级判据②③):屏缘渐隐带盖在画布之上+图区底部居中提示 chip;
## 可见性=画布超窗(_sync_guidance 随锚定/平移刷新)
func _build_guidance() -> void:
var band_l := TextureRect.new()
band_l.name = "FadeL"
band_l.texture = _fade_gradient(false)
band_l.set_anchors_preset(Control.PRESET_LEFT_WIDE)
band_l.offset_right = FADE_BAND_W
band_l.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
band_l.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(band_l)
var band_r := TextureRect.new()
band_r.name = "FadeR"
band_r.texture = _fade_gradient(true)
band_r.set_anchors_preset(Control.PRESET_RIGHT_WIDE)
band_r.offset_left = -FADE_BAND_W
band_r.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
band_r.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(band_r)
var chip := PanelContainer.new()
chip.name = "PanHint"
chip.add_theme_stylebox_override("panel",
UiKit.panel_flat(UiKit.HP_BAR_BACK, UiKit.STONE, CHIP_CORNER, 1, Vector4(12, 5, 12, 5)))
chip.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
chip.offset_left = -CHIP_SIZE.x / 2.0
chip.offset_right = CHIP_SIZE.x / 2.0
chip.offset_top = -CHIP_SIZE.y - CHIP_BOTTOM_GAP
chip.offset_bottom = -CHIP_BOTTOM_GAP
chip.custom_minimum_size = CHIP_SIZE
chip.mouse_filter = Control.MOUSE_FILTER_IGNORE
var chip_label := UiKit.label(CHIP_TEXT, UiKit.FS_HINT, false, UiKit.WARM)
chip_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
chip_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
chip.add_child(chip_label)
add_child(chip)
_sync_guidance()
## 屏缘渐隐渐变(色取屏底同族 BG_ROOT——代拍板:不透明端 0.9 单段衰减)
func _fade_gradient(transparent_at_left: bool) -> GradientTexture2D:
var grad := Gradient.new()
grad.offsets = PackedFloat32Array([0.0, 1.0])
grad.colors = PackedColorArray([Color(UiKit.BG_ROOT, 0.9), Color(UiKit.BG_ROOT, 0.0)])
var tex := GradientTexture2D.new()
tex.gradient = grad
tex.fill_from = Vector2(1, 0.5) if transparent_at_left else Vector2(0, 0.5)
tex.fill_to = Vector2(0, 0.5) if transparent_at_left else Vector2(1, 0.5)
tex.width = int(FADE_BAND_W)
return tex
func _sync_guidance() -> void:
var win := _window_size()
var over := _canvas != null and (_canvas.size.x > win.x or _canvas.size.y > win.y)
if over == _guided_visible:
return
_guided_visible = over
for nm in ["FadeL", "FadeR", "PanHint"]:
var node := get_node_or_null(NodePath(nm)) as Control
if node != null:
node.visible = over
@@ -0,0 +1 @@
uid://dgin6f6214cbg
+91
View File
@@ -0,0 +1,91 @@
# level_map_shot_probe.gd — 关卡画面视口 golden 采集探针(卷轴视口批 golden 扩项)
# 两 Shot 面(2026-11-05 用户裁:只对拍关卡最开始与关卡 Boss 节点,中间过程不对拍):
# lmap_entry.png = 入口停点(关卡最开始——锚点=入口组包围盒中心夹紧)
# lmap_boss.png = 中段停点锚定末列(Boss 紫赤描边列入屏——viewport_center 夹紧右缘)
# 采集确定性 = 固定种子单关直构(LevelRunner.start_level_external defer_entry / resume_state,
# 零 RNG 消耗路径)+ 探针隔离档(无断点、出厂设置 1280×800);运行=窗口化(--headless 禁——
# 渲染闸机器面吃真实渲染帧)。截图经 user args 目录落盘,基线换代走 golden_baseline_refresh.sh --lmap。
# 运行:Godot_console.exe --path game res://probe/level_map_shot_probe.tscn -- <shots_dir>
extends Node
const SEED_L3 := 910212 # 深图固定种子(与 level_map_viewport_probe 同源——图确定性承载)
func _ready() -> void:
var args := OS.get_cmdline_user_args()
var out_dir := args[0] if args.size() > 0 else "user://"
await get_tree().process_frame
var host := Control.new()
add_child(host)
host.size = get_viewport().get_visible_rect().size
var screen = load("res://presentation/views/level_map_screen.gd").new()
screen.set_anchors_preset(Control.PRESET_FULL_RECT)
host.add_child(screen)
for i in range(6):
await get_tree().process_frame # 布局收敛(视口实况尺寸落定→resize 重锚)
# Shot①:入口停点(关卡最开始)
var runner = _make_runner(true, -1)
screen.open_choices(_cand_nodes(runner), runner)
await _settle()
_shot(out_dir, "lmap_entry.png")
# Shot②:中段停点锚定末列(布局 x 最深的有出边节点——id 不按深度排,锚定后 Boss 列入屏)
var boss_stop := _deepest_with_outs(screen, runner)
runner = _make_runner(false, boss_stop)
screen.open_choices(_cand_nodes(runner), runner)
await _settle()
_shot(out_dir, "lmap_boss.png")
print("[level_map_shot_probe] PASS — 两 Shot 落盘(entry+boss → %s)" % out_dir)
get_tree().quit(0)
func _settle() -> void:
for i in range(8):
await get_tree().process_frame
await get_tree().create_timer(0.5).timeout # 平滑滚动终态余量(reanchor tween 0.3s)
func _make_runner(entry: bool, node_id: int):
var rng = load("res://core/rng_service.gd").new()
rng.new_adventure(SEED_L3)
var runner = load("res://core/level_runner.gd").new(get_node("/root/ConfigDB"), rng)
var ok: bool
if entry:
ok = runner.start_level_external(3, Callable(runner, "rest_heal_amount"), true)
else:
ok = runner.start_level_external(3, Callable(runner, "rest_heal_amount"),
false, {"node_id": node_id, "visited_ids": [node_id]})
if not ok:
push_error("[level_map_shot_probe] 状态注入失败(entry=%s node=%d)" % [str(entry), node_id])
get_tree().quit(1)
return runner
func _cand_nodes(runner) -> Array:
var ids: Array = runner.next_candidate_ids()
var out: Array = []
for node in runner.nodes():
if ids.has(runner.node_id_of(node)):
out.append(node)
return out
func _deepest_with_outs(screen, runner) -> int:
var pos: Dictionary = screen.layout_probe(runner.nodes())
var best := -1
var best_x := -INF
for node in runner.nodes():
if runner.node_out_ids(node).is_empty():
continue
var x: float = pos[node].x
if x > best_x:
best_x = x
best = runner.node_id_of(node)
return best
func _shot(out_dir: String, fname: String) -> void:
DirAccess.make_dir_recursive_absolute(out_dir) # 落盘目录自建(save_png 对缺目录静默失败——刷新链不预建)
var img := get_viewport().get_texture().get_image()
var err := img.save_png(out_dir.path_join(fname))
if err != OK:
push_error("[level_map_shot_probe] 截图落盘失败 %s(err=%d)" % [fname, err])
get_tree().quit(1)
+1
View File
@@ -0,0 +1 @@
uid://j4ax48si4nq5
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/level_map_shot_probe.gd" id="1"]
[node name="LevelMapShotProbe" type="Node"]
script = ExtResource("1")
+389
View File
@@ -0,0 +1,389 @@
# level_map_viewport_probe.gd — 卷轴视口行为探针(卷轴视口批 R1–R8 验收载体)
# 判的是 level-map.md 布局决策 5 的视口行为四条:浅图整幅居中无裁切(R1)、停点切换/兑付回图
# 当前节点居中锚定——平滑滚动与减动效瞬切(R2/R7)、拖拽/滚轮手动平移与 clamp 包络——
# 首末列可达(R3/R4/R5)、拖拽不触发选路提交(R6)、指引件可见性与几何(R8,像素级判据②③)。
# 真实入口 = 合成拖拽沿/滚轮沿/点击沿经 push_input 进引擎输入管线打在真实画布 _gui_input 上
# (probe_ui_util.push_drag_at/push_wheel_at/push_click_at);状态注入 = 直调核心层写口构造
# 单关停点(LevelRunner.start_level_external——defer_entry=入口停点 / resume_state=断点续玩
# 重建到任意停点,零 RNG 消耗;test-conventions §5.4 自动化测试直构口径),零 main 冒险流程
# 依赖:真实屏 = LevelMapScreen 实例 + 真实单关 runner。
# 运行:Godot_console.exe --headless --path . res://probe/level_map_viewport_probe.tscn
extends Node
const ProbeUiUtil := preload("res://probe/probe_ui_util.gd")
const ViewportScript := preload("res://presentation/views/level_map_viewport.gd")
const SEED_L1 := 910211 # 浅图停点种子(L1 单链——画布 846.5×256 全幅入窗)
const SEED_L3 := 910212 # 深图停点种子(L3 图——画布超 1280 窗宽,滚动视口生效面)
var _problems: Array[String] = []
var _checks := 0
var _runner # 当前停点的单关 runner(open_choices 驱动随记——查证面共用)
func _check(desc: String, ok: bool) -> void:
_checks += 1
if not ok:
_problems.append(desc)
func _ready() -> void:
await get_tree().process_frame
await get_tree().process_frame
var host := Control.new()
add_child(host)
host.size = get_viewport().get_visible_rect().size # 显式置底座(父非 Control 锚点不解析)
var screen = load("res://presentation/views/level_map_screen.gd").new()
screen.set_anchors_preset(Control.PRESET_FULL_RECT)
host.add_child(screen)
await get_tree().process_frame
await get_tree().process_frame # 布局收敛先行(视口实况窗口尺寸就位后再开停点——锚点数学以实况窗口为准)
var vp = _find_viewport(screen)
if vp == null:
_problems.append("卷轴视口承载件未挂入关卡屏(LevelMapViewport 实例缺位)")
_finish()
return
await _probe_shallow_no_crop(screen, vp)
await _probe_deep_entry_clamp(screen, vp)
await _probe_wheel_pan(screen, vp)
await _probe_drag_pan(screen, vp)
await _probe_clamp_bounds_and_columns(screen, vp)
await _probe_drag_no_submit(screen, vp)
await _probe_follow_anchor(screen, vp)
await _probe_smooth_scroll_reduced_motion(screen, vp)
await _probe_guidance_chrome(screen, vp)
await _probe_shallow_guidance_hidden(screen, vp)
_finish()
## R1 浅图整幅居中无裁切+画布入视口件承载(画布=视口首子件——setup 注入序)
func _probe_shallow_no_crop(screen, vp) -> void:
_runner = _make_runner_entry(1, SEED_L1)
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().process_frame
await get_tree().process_frame
var canvas: Control = vp.get_child(0) as Control
if canvas == null:
_problems.append("R1 画布实例缺位(视口首子件非 Control)")
return
_check("R1 浅图画布全幅入窗(canvas=%s window=%s)〔卷轴批 V1〕" % [canvas.size, vp.size],
canvas.size.x <= vp.size.x + 0.5 and canvas.size.y <= vp.size.y + 0.5)
var want: Vector2 = (vp.size - canvas.size) / 2.0
_check("R1 居中分支 offset 定位(want=%s got=%s)〔卷轴批 V1〕" % [want, canvas.position],
canvas.position.distance_to(want) <= 0.5)
_check("R1 画布矩形在窗内无裁切〔卷轴批 V1〕", vp.get_global_rect().encloses(canvas.get_global_rect()))
## R5 前置+深图入口停点:锚点=入口组包围盒中心夹紧(spec :101——多入口并列取包围盒居中)
func _probe_deep_entry_clamp(screen, vp) -> void:
_runner = _make_runner_entry(3, SEED_L3)
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().process_frame
await get_tree().process_frame
var center: Vector2 = vp.viewport_center_canvas_pos()
var half: float = vp.size.x / 2.0
var canvas_len: float = _canvas_len(screen)
_check("R5 入口锚定后视口中线在夹紧域内(center.x=%.1f ∈ [%.1f, %.1f])〔卷轴批 V5〕" %
[center.x, half, canvas_len - half],
center.x >= half - 0.5 and center.x <= canvas_len - half + 0.5)
# 入口停点候选 = 入口节点——包围盒中心夹紧即锚点(本档图入口分布跨图,中心落夹紧域)
var nodes: Array = _runner.nodes()
var pos: Dictionary = screen.layout_probe(nodes)
var ids: Array = _runner.next_candidate_ids()
var lo := INF
var hi := -INF
for node in nodes:
if ids.has(_runner.node_id_of(node)):
lo = minf(lo, pos[node].x)
hi = maxf(hi, pos[node].x)
var want: float = clampf((lo + hi) / 2.0, half, canvas_len - half)
_check("R5 入口组包围盒中心=锚点(bbox=%.1f center=%.1f want=%.1f)〔卷轴批 V5〕" %
[(lo + hi) / 2.0, center.x, want], absf(center.x - want) <= 0.5)
## R4 滚轮横滚移动视口(下=向右前进向——代拍板映射;窗内平移不钉边界,与 R5 两极 true 构成翻转)
func _probe_wheel_pan(screen, vp) -> void:
var before: float = vp.viewport_center_canvas_pos().x
var pos: Vector2 = vp.get_global_rect().get_center()
ProbeUiUtil.push_wheel_at(self, pos, true)
await get_tree().process_frame
await get_tree().process_frame
var after: float = vp.viewport_center_canvas_pos().x
_check("R4 滚轮下单档平移 +60(before=%.1f after=%.1f)〔卷轴批 V4〕" % [before, after],
absf(after - before - 60.0) <= 0.5)
_check("R4 窗内平移不钉边界(is_clamped_at_edge=false)〔卷轴批 V4〕", not vp.is_clamped_at_edge())
ProbeUiUtil.push_wheel_at(self, pos, false)
await get_tree().process_frame
await get_tree().process_frame
_check("R4 滚轮上回移 −60〔卷轴批 V4〕",
absf(vp.viewport_center_canvas_pos().x - before) <= 0.5)
## R3 拖拽平移移动视口(窗内位移量=拖拽位移,无 clamp 干扰)
func _probe_drag_pan(screen, vp) -> void:
# 先滚入窗内区(两档 +120——避开左包络边界的 clamp 吸收),再拖拽核位移
var pos: Vector2 = vp.get_global_rect().get_center()
ProbeUiUtil.push_wheel_at(self, pos, true)
ProbeUiUtil.push_wheel_at(self, pos, true)
await get_tree().process_frame
await get_tree().process_frame
var before: float = vp.viewport_center_canvas_pos().x
ProbeUiUtil.push_drag_at(self, pos, Vector2(-40, 0))
await get_tree().process_frame
await get_tree().process_frame
var after: float = vp.viewport_center_canvas_pos().x
_check("R3 拖拽左移 −40 平移量逐位一致(画布随手——视线 +40,before=%.1f after=%.1f)〔卷轴批 V3〕" % [before, after],
absf(after - before - 40.0) <= 0.5)
# 复位到左包络边界,给后续探针稳定起点
ProbeUiUtil.push_drag_at(self, pos, Vector2(2000, 0))
await get_tree().process_frame
await get_tree().process_frame
## R5 clamp 边界与首末列可达(拖拽两极)
func _probe_clamp_bounds_and_columns(screen, vp) -> void:
var nodes: Array = _runner.nodes()
var pos: Dictionary = screen.layout_probe(nodes)
var first_node := _extreme_node(pos, nodes, true)
var last_node := _extreme_node(pos, nodes, false)
var vp_rect: Rect2 = vp.get_global_rect()
# 右极:拖拽超包络 → 视口钉左缘,首列(深度 0)入屏
ProbeUiUtil.push_drag_at(self, vp_rect.get_center(), Vector2(2000, 0))
await get_tree().process_frame
await get_tree().process_frame
var center: float = vp.viewport_center_canvas_pos().x
var half: float = vp.size.x / 2.0
_check("R5 拖拽超包络收敛夹紧域(center.x=%.1f)〔卷轴批 V5〕" % center,
center >= half - 0.5 and center <= _canvas_len(screen) - half + 0.5)
_check("R5 右极视口钉边界(is_clamped_at_edge=true)〔卷轴批 V5〕", vp.is_clamped_at_edge())
_check("R5 首列(深度 0)中心入屏〔卷轴批 V5〕",
vp_rect.has_point(_node_screen_pos(screen, pos, first_node)))
# 左极:反向超包络 → 视口钉右缘,末列(含 Boss 紫赤描边列)入屏
ProbeUiUtil.push_drag_at(self, vp_rect.get_center(), Vector2(-2000, 0))
await get_tree().process_frame
await get_tree().process_frame
_check("R5 左极视口钉边界(is_clamped_at_edge=true)〔卷轴批 V5〕", vp.is_clamped_at_edge())
_check("R5 末列中心入屏(拖拽两极可达——布局决策 5)〔卷轴批 V5〕",
vp_rect.has_point(_node_screen_pos(screen, pos, last_node)))
## R6 拖拽不触发选路提交;阈值另一侧完整点击仍提交(提交语义零改动)
func _probe_drag_no_submit(screen, vp) -> void:
# 重建到中段可居中停点(候选=停点出边——候选节点居屏中可拖可达)
var stops := _centerable_stops(screen, vp)
if stops.size() < 3:
_problems.append("R6 可居中停点不足(=%d)" % stops.size())
return
_runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[0]))
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().process_frame
await get_tree().process_frame
var nodes: Array = _runner.nodes()
var cands: Array = _runner.next_candidate_ids()
var target := {}
for node in nodes:
if cands.has(_runner.node_id_of(node)):
target = node
break
if target.is_empty():
_problems.append("R6 候选节点缺位(候选=%s)" % str(cands))
return
var gpos: Vector2 = _node_screen_pos(screen, screen.layout_probe(nodes), target)
var submitted: Array = []
var on_route := func(nid: int) -> void: submitted.append(nid)
screen.route_chosen.connect(on_route)
ProbeUiUtil.push_drag_at(self, gpos, Vector2(120, 24))
await get_tree().process_frame
await get_tree().process_frame
_check("R6 拖拽沿起止候选上方零提交(submitted=%s)〔卷轴批 V6〕" % str(submitted), submitted.is_empty())
_check("R6 拖拽后本屏未关〔卷轴批 V6〕", screen.visible)
var gpos2: Vector2 = _node_screen_pos(screen, screen.layout_probe(nodes), target) # 拖拽平移后重算投影(画布随视口移动)
ProbeUiUtil.push_click_at(self, gpos2)
await get_tree().process_frame
await get_tree().process_frame
_check("R6 完整点击(未超阈值)仍提交选路——阈值区分另一侧〔卷轴批 V6〕", submitted.size() == 1)
screen.route_chosen.disconnect(on_route)
## R2 停点切换后当前节点居中(视口中线投影 x=640±2——视口满屏宽即屏中线)
func _probe_follow_anchor(screen, vp) -> void:
var stops := _centerable_stops(screen, vp)
if stops.size() < 3:
_problems.append("R2 可居中停点不足(=%d)" % stops.size())
return
_runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[1]))
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().create_timer(0.6).timeout # 平滑滚动终态(0.3s 单段缓动+余量)
var sx: float = _node_screen_pos(screen, screen.layout_probe(_runner.nodes()), stops[1]).x
var want: float = vp.get_global_rect().get_center().x
_check("R2 停点切换后当前节点投影=视口中线(got=%.1f want=%.1f±2)〔卷轴批 V2〕" % [sx, want],
absf(sx - want) <= 2.0)
## R7 平滑滚动有 tween 中间帧;减动效开=位置即达(画布坐标域判定)
func _probe_smooth_scroll_reduced_motion(screen, vp) -> void:
var saved_scale: float = UiKit.motion_scale
UiKit.motion_scale = 1.0
var stops := _centerable_stops(screen, vp)
if stops.size() < 3:
_problems.append("R7 可居中停点不足(=%d)" % stops.size())
return
# 起点:锚在停点 A(画面已停稳)
_runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[0]))
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().create_timer(0.6).timeout
var start: float = vp.viewport_center_canvas_pos().x
# 停点切换 A→B(屏幕保持可见=兑付回图同路径)——平滑滚动
var target_b: float = screen.layout_probe(_runner.nodes())[stops[1]].x
_runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[1]))
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().create_timer(0.05).timeout # 中间帧采样(300ms 单段缓动早段)
var mid: float = vp.viewport_center_canvas_pos().x
var total := absf(target_b - start)
_check("R7 平滑滚动中间帧在途中(start=%.1f mid=%.1f target=%.1f)〔卷轴批 V7〕" % [start, mid, target_b],
total > 4.0 and absf(mid - start) > 0.5 and absf(mid - target_b) > 0.5
and (mid - start) * (target_b - start) > 0.0) # 同向在途
await get_tree().create_timer(0.5).timeout
_check("R7 平滑滚动终态到位〔卷轴批 V7〕",
absf(vp.viewport_center_canvas_pos().x - target_b) <= 2.0)
# 减动效置位(UiKit.motion 通道单点——设置面开关写同一值):停点切换 B→C 位置即达
UiKit.motion_scale = 0.5
var target_c: float = screen.layout_probe(_runner.nodes())[stops[2]].x
_runner = _make_runner_at_stop(3, SEED_L3, _runner.node_id_of(stops[2]))
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().process_frame
await get_tree().process_frame
_check("R7 减动效开=位置即达(got=%.1f target=%.1f)〔卷轴批 V7〕" %
[vp.viewport_center_canvas_pos().x, target_c],
absf(vp.viewport_center_canvas_pos().x - target_c) <= 2.0)
UiKit.motion_scale = saved_scale
## R8 指引件可见性与几何(深图超窗——像素级判据②③)
func _probe_guidance_chrome(screen, vp) -> void:
var band_l: Control = vp.get_node_or_null(NodePath("FadeL"))
var band_r: Control = vp.get_node_or_null(NodePath("FadeR"))
var chip: Control = vp.get_node_or_null(NodePath("PanHint"))
if band_l == null or band_r == null or chip == null:
_problems.append("R8 指引件缺位(FadeL/FadeR/PanHint)〔卷轴批 V8〕")
return
_check("R8 深图渐隐带可见〔卷轴批 V8〕", band_l.visible and band_r.visible)
_check("R8 渐隐带宽 70px(L=%.1f R=%.1f)〔卷轴批 V8〕" % [band_l.size.x, band_r.size.x],
absf(band_l.size.x - 70.0) <= 0.5 and absf(band_r.size.x - 70.0) <= 0.5)
_check("R8 平移提示 chip 可见且 240×30(size=%s)〔卷轴批 V8〕" % chip.size,
chip.visible and absf(chip.size.x - 240.0) <= 0.5 and absf(chip.size.y - 30.0) <= 0.5)
_check("R8 chip 图区底部居中(水平中线对窗中线±2)〔卷轴批 V8〕",
absf(chip.get_global_rect().get_center().x - vp.get_global_rect().get_center().x) <= 2.0)
## R8 浅图指引件隐藏(无暗示必要)
func _probe_shallow_guidance_hidden(screen, vp) -> void:
_runner = _make_runner_entry(1, SEED_L1)
screen.open_choices(_cand_nodes(_runner), _runner)
await get_tree().process_frame
await get_tree().process_frame
var band_l: Control = vp.get_node_or_null(NodePath("FadeL"))
var chip: Control = vp.get_node_or_null(NodePath("PanHint"))
_check("R8 浅图画布不超窗指引件隐藏〔卷轴批 V8〕",
band_l != null and not band_l.visible and chip != null and not chip.visible)
# ---- 驱动与查证工具(状态注入面——直调核心层写口) ----
## 入口停点 runner(defer_entry——选路态挂起、候选=入口节点)
func _make_runner_entry(level_index: int, seed_val: int):
var rng = load("res://core/rng_service.gd").new()
rng.new_adventure(seed_val)
var runner = load("res://core/level_runner.gd").new(get_node("/root/ConfigDB"), rng)
if not runner.start_level_external(level_index, Callable(runner, "rest_heal_amount"), true):
_problems.append("状态注入失败:start_level_external(%d, %d) 未受理" % [level_index, seed_val])
return runner
## 中段停点 runner(断点续玩重建——图由种子+参数档重生成同图,零 RNG 消耗)
func _make_runner_at_stop(level_index: int, seed_val: int, node_id: int):
var rng = load("res://core/rng_service.gd").new()
rng.new_adventure(seed_val)
var runner = load("res://core/level_runner.gd").new(get_node("/root/ConfigDB"), rng)
if not runner.start_level_external(level_index, Callable(runner, "rest_heal_amount"),
false, {"node_id": node_id, "visited_ids": [node_id]}):
_problems.append("状态注入失败:断点停点 %d 未受理" % node_id)
return runner
func _cand_nodes(runner) -> Array:
var ids: Array = runner.next_candidate_ids()
var out: Array = []
for node in runner.nodes():
if ids.has(runner.node_id_of(node)):
out.append(node)
return out
## 深图可居中停点选取(有出边且画布 x 落在夹紧域内的节点——锚定不贴边、当前节点投影可居中;
## 按 x 降序去重取第 idx 个——同层同 x 只计一个,层距 122 保证停点间滚动位移充分)
func _centerable_stops(screen, vp) -> Array:
var nodes: Array = _runner.nodes()
var pos: Dictionary = screen.layout_probe(nodes)
var half: float = vp.size.x / 2.0
var canvas_len: float = _canvas_len(screen)
var by_x_desc: Array = nodes.duplicate()
by_x_desc.sort_custom(func(a, b) -> bool: return pos[a].x > pos[b].x)
var picked: Array = []
var seen_x := {}
for node in by_x_desc:
if _runner.node_out_ids(node).is_empty():
continue
var x: float = pos[node].x
if x < half + 20.0 or x > canvas_len - half - 20.0 or seen_x.has(x):
continue
seen_x[x] = true
picked.append(node)
return picked
func _find_viewport(screen) -> Control:
return _find_by_script(screen, ViewportScript)
func _find_by_script(root: Node, script: Script) -> Control:
if root.get_script() == script:
return root as Control
for child in root.get_children():
var found := _find_by_script(child, script)
if found != null:
return found
return null
func _canvas_len(screen) -> float:
return screen.canvas_probe()["min_size"].x # 画布包络宽(open_choices 拟合值——画布尺寸即它)
func _node_screen_pos(screen, pos: Dictionary, node: Dictionary) -> Vector2:
return screen.canvas_probe()["origin"] + pos[node]
func _extreme_node(pos: Dictionary, nodes: Array, first: bool) -> Dictionary:
var best := {}
var best_x := INF if first else -INF
for node in nodes:
var x: float = pos[node].x
if (first and x < best_x) or (not first and x > best_x):
best_x = x
best = node
return best
func _finish() -> void:
if _checks == 0:
_problems.append("零断言执行(断言数下限闸——三硬闸③)")
if _problems.is_empty():
print("[level_map_viewport_probe] PASS — 卷轴视口行为断言全绿(%d 项)" % _checks)
get_tree().quit(0)
else:
for p in _problems:
printerr("[level_map_viewport_probe] FAIL — " + p)
get_tree().quit(1)
@@ -0,0 +1 @@
uid://cprbakccv06re
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe/level_map_viewport_probe.gd" id="1"]
[node name="LevelMapViewportProbe" type="Node"]
script = ExtResource("1")
+42
View File
@@ -213,6 +213,48 @@ static func scroll_button_into_view(main, view: Control, btn: Control) -> void:
await main.get_tree().process_frame
# 真输入拖拽沿(press→motion×steps→release 成对经 push_input 进引擎输入管线——卷轴视口批
# R3/R5/R6 驱动沿;沿 push_click_at 同式。relative 按步显式置值——合成事件的位移量由事件
# 自身承载,被测面按 relative 逐帧平移)
static func push_drag_at(main, pos: Vector2, delta: Vector2, steps: int = 4) -> void:
var down := InputEventMouseButton.new()
down.position = pos
down.global_position = pos
down.button_index = MOUSE_BUTTON_LEFT
down.pressed = true
main.get_viewport().push_input(down, true)
for i in range(1, steps + 1):
var cur := pos + delta * float(i) / float(steps)
var mv := InputEventMouseMotion.new()
mv.position = cur
mv.global_position = cur
mv.relative = delta / float(steps)
main.get_viewport().push_input(mv, true)
var up := InputEventMouseButton.new()
up.position = pos + delta
up.global_position = pos + delta
up.button_index = MOUSE_BUTTON_LEFT
up.pressed = false
main.get_viewport().push_input(up, true)
# 真输入滚轮沿(单档滚轮事件经 push_input——卷轴视口批 R4 驱动沿;方向映射在被测面,
# 本工具只发原始滚轮档)。press+release 成对(同 push_wheel 注——只发 press 钉死鼠标焦点)。
static func push_wheel_at(main, pos: Vector2, wheel_down: bool = true) -> void:
var ev := InputEventMouseButton.new()
ev.position = pos
ev.global_position = pos
ev.button_index = MOUSE_BUTTON_WHEEL_DOWN if wheel_down else MOUSE_BUTTON_WHEEL_UP
ev.pressed = true
main.get_viewport().push_input(ev, true)
var ev2 := InputEventMouseButton.new()
ev2.position = pos
ev2.global_position = pos
ev2.button_index = MOUSE_BUTTON_WHEEL_DOWN if wheel_down else MOUSE_BUTTON_WHEEL_UP
ev2.pressed = false
main.get_viewport().push_input(ev2, true)
# 营地经进档开层(卡② mn9 新路径——campbtn 已撤;槽模板注入隔离命名空间,防碰真实槽
# user://save_slot_N.json——真实档红线)。创建=激活=暂行开营地(卡③ Q3 乙落点);
# 驱动后写盘目标=隔离槽文件(持久化断言读 user://<tag>_slot_1.json)。返回 false=新档钮缺失(驱动中断)。
+16
View File
@@ -87,6 +87,14 @@ func _ready() -> void:
evf.position = fog_pos
evf.global_position = fog_pos
main.get_viewport().push_input(evf, true)
# 完整点击=press+release 成对(卷轴视口批阈值语义:拖距跟踪器由按下起武装,
# 单 press 沿会留 armed 态污染后续 motion 判定——判据语义不变:雾下点击零提交)
var evf2 := InputEventMouseButton.new()
evf2.button_index = MOUSE_BUTTON_LEFT
evf2.pressed = false
evf2.position = fog_pos
evf2.global_position = fog_pos
main.get_viewport().push_input(evf2, true)
await get_tree().process_frame
await get_tree().process_frame
ro.route_chosen.disconnect(on_route)
@@ -114,6 +122,14 @@ func _ready() -> void:
evc.position = gpos
evc.global_position = gpos
main.get_viewport().push_input(evc, true)
# 完整点击=press+release 成对(卷轴视口批阈值语义:提交沿=未超阈值的抬杆——
# 判据语义不变:图上真实点击提交选路;单 press 沿不再是提交沿)
var evr := InputEventMouseButton.new()
evr.button_index = MOUSE_BUTTON_LEFT
evr.pressed = false
evr.position = gpos
evr.global_position = gpos
main.get_viewport().push_input(evr, true)
await get_tree().process_frame
await get_tree().process_frame
if main.ui.modals.run.level_screen.visible or main.run_scope.runner.awaiting_choice():
+1 -1
View File
@@ -72,7 +72,7 @@ func _initialize() -> void:
if attune_mode == "on":
# 贪心画像:Slots=3(槽位判定源=树同调位节点点亮数,模拟画像=三节点
# 全点亮常量);装配序 =
# 表序 passive 优先至 Slots-1 + 首个 active(slots=3 → [淬毒刃纹, 标中殉爆, 掘进蓄能],
# 表序 passive 优先至 Slots-1 + 首个 active(slots=3 → 表序头三件占满示例,
# provisional 画像归调参);Y = 营地升级 Lv1 表值 1。autoplay 默认不刷——Y 仅注入容量,
# 刷新消费归玩家策略面(钓鱼归玩家)。
var slots: int = 3
+2 -4
View File
@@ -411,14 +411,12 @@ static func _on_step_row(row: Dictionary, _runner, st: Dictionary, collect_rows:
# 封印经济聚合(自结算流水聚合,零新随机源):
# ⚠ 行侧判据考证实录:结算段对存续增益实例每轮复发行 effect 行且复用实例 source_class
#(battle_orchestrator 状态列举行)——source_class=seal_hit 的行 ≠ 标中事件。事件行唯一判据
# = 授予行专带 highlight="seal_hit"(实测 87 行 == 受理封印 87 动作,1:1 精确);
# 雷区图谱追加分grant 无高光,以 display_name 后缀补捕(计入事件与合计——同封印第二份兑付)。
# = 授予行专带 highlight="seal_hit"(实测 87 行 == 受理封印 87 动作,1:1 精确)。
# 满层拒收行(reward_ineffective)零兑付不入。标空事件=归因行(type=seal_miss 每格恰一次);
# 标空惩罚合计只入一次性兑付行(flat 直伤 damage/heal + gp_loss escrow 行)——sapped 授予行
# 与其状态复发行行侧不可区分不入合计(登记注记,判读侧按事件数补算)。
if row.source_class == "seal_hit" and row.type != "reward_ineffective":
if String(row.get("highlight", "")) == "seal_hit" \
or String(row.get("display_name", "")).contains("雷区图谱"):
if String(row.get("highlight", "")) == "seal_hit":
st.seal_hit += 1
st.seal_hit_gain += int(row.value)
if row.type == "seal_miss":
+1 -1
View File
@@ -160,7 +160,7 @@ tests/
| 四柜台拆解后五界面入口改挂与写口零回归 | 营地五界面 | 五界面意图信号 → `CampShopPresenter` → `MetaProgression.tree_buy()` 写口 | `forge_workbench_test.gd:_run`(承载类同迁断言十二项);`ui_click_probe.gd:_ready`(写口链段) |
| 营地篝火回主菜单与笔记本改挂动线 | 营地交互流 | 笔记本命中区 → `CareerOverlay.open()`;篝火命中区 → ConfirmDialog 二次确认 → 主菜单 | `ui_struct_probe.gd:_ready`(career 段);`screen_flow_probe.gd:_ready`(篝火/开合段) |
| 关卡画面视口跟随 | 关卡画面(选路枢纽) | 停点切换与兑付回图经真实屏流程;读面 `LevelMapViewport.viewport_center_canvas_pos()` | `level_map_viewport_probe.gd:_probe_follow_anchor`;`level_map_viewport_probe.gd:_probe_smooth_scroll_reduced_motion` |
| 关卡画面视口平移 | 关卡画面(选路枢纽) | 合成拖拽沿与滚轮沿经 `push_input`(`ProbeUiUtil.push_drag_at()`/`push_wheel_at()`) | `level_map_viewport_probe.gd:_probe_drag_pan`;`level_map_viewport_probe.gd:_probe_wheel_pan`;`level_map_viewport_probe.gd:_probe_clamp_bounds`;`level_map_viewport_probe.gd:_probe_drag_no_submit` |
| 关卡画面视口平移 | 关卡画面(选路枢纽) | 合成拖拽沿与滚轮沿经 `push_input`(`ProbeUiUtil.push_drag_at()`/`push_wheel_at()`) | `level_map_viewport_probe.gd:_probe_drag_pan`;`level_map_viewport_probe.gd:_probe_wheel_pan`;`level_map_viewport_probe.gd:_probe_clamp_bounds_and_columns`;`level_map_viewport_probe.gd:_probe_drag_no_submit` |
| 关卡画面视口指引 | 关卡画面(选路枢纽) | 深图经 `LevelMapScreen.open_choices()` 真实停点 | `level_map_viewport_probe.gd:_probe_guidance_chrome` |
| 营地图鉴占位挂起态与反参照结构纪律 | 营地场景视图 | 布告板命中区 → `CampSceneView._gui_input()`(object_pressed 信号) → 图鉴占位视图;营地根态场景树审计 | `ui_struct_probe.gd:_ready`(占位禁词/禁行段);`ui_struct_probe.gd:_ready`(结构禁词段) |
| 营地刷新单口与场景根 HUD 同步 | 营地刷新总线 | 写口尾 → `CampRefreshBus.refresh()` → 现开界面刷新+场景根 HUD 刷新 | `ui_struct_probe.gd:_ready`(顶栏位段);`ui_click_probe.gd:_ready`(写口尾刷新段) |
+8 -4
View File
@@ -4,17 +4,21 @@
`windows/` 与 `mac/` 子目录各持一套,平台内部照常严格比对,跨平台不互比;
平台选择实现单点 = `tools/gate_platform.sh` 的 `gate_platform_id`,比较器 `tools/golden_compare.py`
自动拼平台子目录)。本目录现持:`windows/`(棋盘三图 `board_L1_dungeon` / `board_L2_underdark` /
`board_L3_deep_underdark`,裁剪区 900×760 + 启动态菜单 `menu_boot`,全屏)、
`level_map`(关卡画面全屏,历史基线留根——现行闸件未对拍它)、
`board_L3_deep_underdark`,裁剪区 900×760 + 启动态菜单 `menu_boot`,全屏 + 关卡视口两 Shot
`lmap_entry` / `lmap_boss`——2026-11-05 用户裁两 Shot 面:只对拍关卡最开始(入口停点)与
关卡 Boss 节点(末列锚定),中间过程不对拍)、
`level_map`(关卡画面全屏,历史基线留根——现行闸件未对拍它,由两 Shot 面接替)、
`palette_baseline/`(色板基线,纯文件内容比对与平台无关)。
比对判据与工具见 `tools/golden_compare.py`
(逐像素灰度差 >16 的像素占比 ≤2%)。
(逐像素灰度差 >16 的像素占比 ≤2%)。两 Shot 基线采集=`res://probe/level_map_shot_probe.tscn`
(固定种子单关直构——确定性采集),换代入口=`tools/golden_baseline_refresh.sh --lmap`;
他平台(mac)的关卡视口基线尚未冻结——渲染闸对本平台缺基线记 SKIP 不判红,冻结义务归批末呈报。
**未建锚面备案**:营地/设置/战斗三屏与战末结算/三选一/图例/调试浮窗四浮层无改前 golden 锚;这些呈现面的等价证据承载=现役探针/单元测试断言面+实机目检(体验验证清单 ㊵ 节)。
## 时效判定纪律
基线冻结于 2026-09-30。**判定先于动作**:每代换代面(视觉/素材/布局改动)
基线冻结于 2026-09-30;关卡视口两 Shot(lmap_entry/lmap_boss)冻结于 2026-11-05——两 Shot 面口径(只对拍入口停点与 Boss 节点)见上文现持清单行。**判定先于动作**:每代换代面(视觉/素材/布局改动)
跑批时先跑一次渲染闸看差异落不落在裁剪区与容差内——
- **判有效**(changed 远低于 2% 阈值):基线**留在位**,不得顺手重制;
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

@@ -236,7 +236,7 @@ func _smoke_global_variant() -> void:
Kit.check("生存族下调:heal 4→3 / defense 11→7(v=30 构造例)", wmap.get("heal_minor", -1) == 3 and wmap.get("ac_up", -1) == 7)
Kit.check("进攻族上浮:attack 14→15 / blast 16→18 / pierce 8→9 / magic 8→9(v=30 构造例,等比上浮守恒)", wmap.get("attack_up", -1) == 15 and wmap.get("spark_aoe", -1) == 18 and wmap.get("pierce_strike", -1) == 9 and wmap.get("magic_bolt", -1) == 9)
eco.roller.board_skew = 30
var combo_rows: Array = eco.roller._biased_cell_rows(2) # 件级加算 2=偏运硬币行 value(reward_roll bias_rewards 命中面同值)
var combo_rows: Array = eco.roller._biased_cell_rows(2) # 件级加算 2=订阅行 value(reward_roll bias_rewards 命中面同值)
var cw := _rows_weight_map(combo_rows)
Kit.check("合成序:图级偏斜先(乘法面)件级 luck_bias 后(加法 +2×四稀有类)——合计 77+8", eco.roller._pool_weight(combo_rows) == 77 + 4 * 2)
Kit.check("合成序构造例:skew(heal 3)+2=5 / gp 2+2=4 / pierce 9+2=11 / magic 9+2=11(图级定型后玩家构筑叠加)", cw.get("heal_minor", -1) == 5 and cw.get("gp_coin", -1) == 4 and cw.get("pierce_strike", -1) == 11 and cw.get("magic_bolt", -1) == 11)
@@ -674,7 +674,7 @@ func _smoke_tree_rules() -> void:
ctx_fz2.level_bucket = lvl_shared # 挂同一 level 桶引用(新形态跨战持久:无 export/seed,同字典即持久)
Kit.check("锋:限次跨战持久(level 桶共享引用——替代旧副本搬运链)〔无状态化 V4〕",
(fz2.dispatch(ctx_fz2, "mine_hit", {}, Callable()) as Array).is_empty())
# 锋·惊巢反制效果行换代断言(迭代36 u13/wa12——_grant_routed 减半分支效果行=战斗日志
# 锋·挖掘惩罚减半效果行换代断言(迭代36 u13/wa12——_grant_routed 减半分支效果行=战斗日志
# step_row 真实生产面;display_name 玩家可见,禁字「挖雷」由此行清零钉住)
var orch_nc = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777003))
orch_nc.begin_battle({"level_index": 1, "node_depth": 0, "hero_hp": 40, "hero_atk": 8, "hero_max_hp": 40,
@@ -687,17 +687,17 @@ func _smoke_tree_rules() -> void:
# heal 负向行表值 power=6(极性字典行值——负向 power 走表值不走快照)→ halve 50% ceil=3
orch_nc.reward_grant().grant_routed({"cell_type": "heal", "power": 10}, "negative",
{"source_class": "mine_hit", "cell": Vector2i.ZERO, "ri": 0, "halve_pct": 50})
# 受理判据=惊巢反制效果行落 step_row(halve 分支先于 dispatch 发行——flat 行 _apply_flat_row 分派
# 受理判据=减半效果行落 step_row(halve 分支先于 dispatch 发行——flat 行 _apply_flat_row 分派
# 恒返 false,返回值不承载受理语义;行发行即分支受理)
var nc_hit := ""
for r in nc_rows:
if String((r as Dictionary).get("display_name", "")).contains("惊巢反制"):
if String((r as Dictionary).get("display_name", "")).contains("挖掘惩罚减半"):
nc_hit = String((r as Dictionary).get("display_name", ""))
break
Kit.check("锋:惊巢反制 mine_hit 减半分支受理(wa12 前提——halve_pct=50 效果行落 step_row)〔wa12〕",
Kit.check("锋:挖掘惩罚减半 mine_hit 减半分支受理(wa12 前提——halve_pct=50 效果行落 step_row)〔wa12〕",
nc_hit != "")
Kit.check("惊巢反制效果行==「惊巢反制——危险格挖掘惩罚减半(6→3)」零「挖雷」(u13 换代)〔wa12〕",
nc_hit == "惊巢反制——危险格挖掘惩罚减半(6→3)" and not nc_hit.contains("挖雷"))
Kit.check("减半效果行==「挖掘惩罚减半(6→3)」零「挖雷」(u13 换代)〔wa12〕",
nc_hit == "挖掘惩罚减半(6→3)" and not nc_hit.contains("挖雷"))
# 守侧五场景(契约 12):编排 rig——begin_battle 携 sense_guard,授予段 _grant_seg._judge_seal 直注
var orch = load("res://core/battle_orchestrator.gd").new(_db, _fresh_rng(777001))
Kit.check("守侧 rig:战斗编排启动(tree_rules=sense_guard 携带)",
@@ -11,12 +11,33 @@ const Kit := preload("res://tests/unit/test_kit.gd")
const SRC_DIR := "res://data/tables"
const BAD_OVERFLOW_DIR := "user://effect_kind_fixture_overflow"
const BAD_SEGMENTS_DIR := "user://effect_kind_fixture_segments"
const BAD_SPIKE_DIR := "user://effect_kind_fixture_spike"
const BAD_TRADEKEY_DIR := "user://effect_kind_fixture_tradekey"
# 注入探针:死通道 do.kind(甲案摘名两族——各一探针行)
const PROBE_OVERFLOW := ' {\n "feat_id": "kind_overflow_probe",\n "display_name": "通道探针·溢出",\n "rarity": "common",\n "triggers": [ { "on": "dig_reveal", "when": [], "do": { "kind": "overflow", "value": 50 } } ],\n "desc": "夹具探针——甲案退役词 overflow"\n},\n'
const PROBE_SEGMENTS := ' {\n "feat_id": "kind_segments_probe",\n "display_name": "通道探针·连珠",\n "rarity": "common",\n "triggers": [ { "on": "dig_reveal", "when": [], "do": { "kind": "attack_segments", "cap": 2, "divisor": 3, "value": 0 } } ],\n "desc": "夹具探针——甲案退役词 attack_segments"\n},\n'
# 注入探针:改写族退役词(改写族清退批 T2)——spike kind 出域与 trade 键出域(各一探针行)
const PROBE_SPIKE_MODE := ' {
"feat_id": "kind_spike_probe",
"display_name": "词表探针·甲",
"rarity": "common",
"triggers": [ { "on": "dig_reveal", "when": [], "do": { "kind": "spike", "value": 2 } } ],
"desc": "夹具探针——改写族退役 kind spike"
},
'
const PROBE_TRADE_KEY := ' {
"feat_id": "kind_tradekey_probe",
"display_name": "键闭探针·乙",
"rarity": "common",
"triggers": [ { "on": "dig_reveal", "when": [], "do": { "kind": "grant_ap", "trade_hit": 5, "value": 50 } } ],
"desc": "夹具探针——改写族退役 do 键 trade_hit"
},
'
func _inject(dir: String, probe: String) -> bool:
var path := dir + "/feats.tres"
var f := FileAccess.open(path, FileAccess.READ)
@@ -65,5 +86,11 @@ func _init() -> void:
if not _assert_rejected(BAD_SEGMENTS_DIR, PROBE_SEGMENTS, "effect_kind_retired_test/②attack_segments 出域"):
quit(2)
return
print("[effect_kind_retired_test] 双探针均正确拒绝(甲案词表退役——死通道误写加载致命)〔架构整改 V3〕")
if not _assert_rejected(BAD_SPIKE_DIR, PROBE_SPIKE_MODE, "effect_kind_retired_test/③词表闭面:kind=spike 出域拒绝"):
quit(2)
return
if not _assert_rejected(BAD_TRADEKEY_DIR, PROBE_TRADE_KEY, "effect_kind_retired_test/④do 键闭面:trade_hit 出域拒绝"):
quit(2)
return
print("[effect_kind_retired_test] 四探针均正确拒绝(甲案词表退役+改写族清退——退役词/键误写加载致命)〔架构整改 V3〕〔改写族清退 T2〕")
quit(1)
@@ -212,9 +212,10 @@ func _test_negative_cases() -> void:
# ---- V11:效果类型闭集零新增(满级特效本期不开新词)----
# 实数订正(L2 在册):卡片 F11「24 种」系设计文档事实快照,
# 现行 31;本套件零扩签,钉现行数(将来任何加词即转红提示复核)。
# 32→31:改写族清退批摘 spike(CI run 87 追绿——词表合法收窄的断言随动)。
func _test_effect_kinds_closed() -> void:
Kit.check("效果引擎效果类型闭集 32 种(甲案词表退役——overflow/attack_segments 摘名)〔锻造工坊批 V11〕",
EffectEngineScript.EFFECT_KINDS.size() == 32)
Kit.check("效果引擎效果类型闭集 31 种(甲案词表退役 overflow/attack_segments+改写族清退摘 spike)〔锻造工坊批 V11〕",
EffectEngineScript.EFFECT_KINDS.size() == 31)
Kit.check("效果引擎事件词表在册 21 种(charge_full 位摘名——蓄力层删键批)〔锻造工坊批 V11〕",
EffectEngineScript.EVENTS.size() == 21)
@@ -221,7 +221,10 @@ func _test_layout_cache(db) -> void:
await process_frame # 换代图真实绘制路径重算(缓存随内容变失效重写)
var pc1: Dictionary = screen.layout_probe(chain)
var pc2: Dictionary = screen.layout_probe(chain)
# 第三判据原=新旧图 id0 位置差——改前靠容器延迟收敛的 origin 中间态偶然成立(换图首探针
# 画布未及重排,origin≠0);视口化批画布同帧收敛(canvas.size 恒=内容包络)后两图 origin
# 恒等、id0 同层同槽位置恒等,前提失义。等强换键=重算字典键数恰为新图节点数(旧缓存
# 63 键,换代不重算必不成立),同一性判据(核心失效重算面)不动。
Kit.check("布局缓存换代:新图查询返回新实例非旧缓存(失效重算生效)〔levelmap批 V2〕",
not is_same(p1, pc1) and is_same(pc1, pc2)
and (pc1[chain[0]] as Vector2) != (p1[nodes[0]] as Vector2))
not is_same(p1, pc1) and is_same(pc1, pc2) and pc1.size() == chain.size())
screen.queue_free()