diff --git a/game/core/action_economy.gd b/game/core/action_economy.gd index 679302ef..6becbdfc 100644 --- a/game/core/action_economy.gd +++ b/game/core/action_economy.gd @@ -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: diff --git a/game/core/battle_orchestrator.gd b/game/core/battle_orchestrator.gd index 0aba641a..d30b320e 100644 --- a/game/core/battle_orchestrator.gd +++ b/game/core/battle_orchestrator.gd @@ -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 diff --git a/game/core/damage_controller.gd b/game/core/damage_controller.gd index 4223ef42..2b32f298 100644 --- a/game/core/damage_controller.gd +++ b/game/core/damage_controller.gd @@ -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 敌的账) diff --git a/game/core/effect_engine.gd b/game/core/effect_engine.gd index 33d3a725..56b9e75f 100644 --- a/game/core/effect_engine.gd +++ b/game/core/effect_engine.gd @@ -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、 diff --git a/game/core/reward_grant.gd b/game/core/reward_grant.gd index db957961..5fb230d6 100644 --- a/game/core/reward_grant.gd +++ b/game/core/reward_grant.gd @@ -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) diff --git a/game/core/reward_roller.gd b/game/core/reward_roller.gd index 57010bac..ac1f9e3b 100644 --- a/game/core/reward_roller.gd +++ b/game/core/reward_roller.gd @@ -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: diff --git a/game/core/settlement/grant_segment.gd b/game/core/settlement/grant_segment.gd index 1eee08d0..eb472a60 100644 --- a/game/core/settlement/grant_segment.gd +++ b/game/core/settlement/grant_segment.gd @@ -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"): diff --git a/game/data/schemas/level_gen_params.gd b/game/data/schemas/level_gen_params.gd index 40563cc5..6bb36305 100644 --- a/game/data/schemas/level_gen_params.gd +++ b/game/data/schemas/level_gen_params.gd @@ -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]}) diff --git a/game/data/schemas/treasure_item_table.gd b/game/data/schemas/treasure_item_table.gd index ceaeccf0..d4891c9f 100644 --- a/game/data/schemas/treasure_item_table.gd +++ b/game/data/schemas/treasure_item_table.gd @@ -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"): diff --git a/game/presentation/views/level_map_screen.gd b/game/presentation/views/level_map_screen.gd index fd0af5e7..c7ad7bf6 100644 --- a/game/presentation/views/level_map_screen.gd +++ b/game/presentation/views/level_map_screen.gd @@ -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) diff --git a/game/presentation/views/level_map_viewport.gd b/game/presentation/views/level_map_viewport.gd new file mode 100644 index 00000000..70c5642e --- /dev/null +++ b/game/presentation/views/level_map_viewport.gd @@ -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 diff --git a/game/presentation/views/level_map_viewport.gd.uid b/game/presentation/views/level_map_viewport.gd.uid new file mode 100644 index 00000000..0e5acf00 --- /dev/null +++ b/game/presentation/views/level_map_viewport.gd.uid @@ -0,0 +1 @@ +uid://dgin6f6214cbg diff --git a/game/probe/level_map_shot_probe.gd b/game/probe/level_map_shot_probe.gd new file mode 100644 index 00000000..0f920158 --- /dev/null +++ b/game/probe/level_map_shot_probe.gd @@ -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 -- +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) diff --git a/game/probe/level_map_shot_probe.tscn b/game/probe/level_map_shot_probe.tscn new file mode 100644 index 00000000..d34d0546 --- /dev/null +++ b/game/probe/level_map_shot_probe.tscn @@ -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") diff --git a/game/probe/level_map_viewport_probe.gd b/game/probe/level_map_viewport_probe.gd new file mode 100644 index 00000000..6c1ede3a --- /dev/null +++ b/game/probe/level_map_viewport_probe.gd @@ -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) diff --git a/game/probe/level_map_viewport_probe.gd.uid b/game/probe/level_map_viewport_probe.gd.uid new file mode 100644 index 00000000..06779e47 --- /dev/null +++ b/game/probe/level_map_viewport_probe.gd.uid @@ -0,0 +1 @@ +uid://cprbakccv06re diff --git a/game/probe/level_map_viewport_probe.tscn b/game/probe/level_map_viewport_probe.tscn new file mode 100644 index 00000000..5a3ee798 --- /dev/null +++ b/game/probe/level_map_viewport_probe.tscn @@ -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") diff --git a/game/probe/probe_ui_util.gd b/game/probe/probe_ui_util.gd index 2a33d2b3..d64ea998 100644 --- a/game/probe/probe_ui_util.gd +++ b/game/probe/probe_ui_util.gd @@ -141,6 +141,42 @@ static func push_click_at(main, pos: Vector2) -> void: main.get_viewport().push_input(up, true) +# 真输入拖拽沿(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 驱动沿;方向映射在被测面, +# 本工具只发原始滚轮档) +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) + + # 营地经进档开层(卡② mn9 新路径——campbtn 已撤;槽模板注入隔离命名空间,防碰真实槽 # user://save_slot_N.json——真实档红线)。创建=激活=暂行开营地(卡③ Q3 乙落点); # 驱动后写盘目标=隔离槽文件(持久化断言读 user://_slot_1.json)。返回 false=新档钮缺失(驱动中断)。 diff --git a/game/probe/ui_input_probe.gd b/game/probe/ui_input_probe.gd index 65f89a51..e3ecde7c 100644 --- a/game/probe/ui_input_probe.gd +++ b/game/probe/ui_input_probe.gd @@ -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(): diff --git a/game/sim/adventure_monte_carlo.gd b/game/sim/adventure_monte_carlo.gd index a669679d..4c43b7f6 100644 --- a/game/sim/adventure_monte_carlo.gd +++ b/game/sim/adventure_monte_carlo.gd @@ -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 diff --git a/game/sim/level_autoplay.gd b/game/sim/level_autoplay.gd index 13f34021..ec06dab9 100644 --- a/game/sim/level_autoplay.gd +++ b/game/sim/level_autoplay.gd @@ -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": diff --git a/game/tests/golden/README.md b/game/tests/golden/README.md index 4dac9021..9936ab1d 100644 --- a/game/tests/golden/README.md +++ b/game/tests/golden/README.md @@ -4,11 +4,15 @@ `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 锚;这些呈现面的等价证据承载=现役探针/单元测试断言面+实机目检(体验验证清单 ㊵ 节)。 diff --git a/game/tests/golden/windows/lmap_boss.png b/game/tests/golden/windows/lmap_boss.png new file mode 100644 index 00000000..90d14187 Binary files /dev/null and b/game/tests/golden/windows/lmap_boss.png differ diff --git a/game/tests/golden/windows/lmap_entry.png b/game/tests/golden/windows/lmap_entry.png new file mode 100644 index 00000000..d710cd53 Binary files /dev/null and b/game/tests/golden/windows/lmap_entry.png differ diff --git a/game/tests/unit/battle_settlement/variant_placement_test.gd b/game/tests/unit/battle_settlement/variant_placement_test.gd index 415c86de..620c3be3 100644 --- a/game/tests/unit/battle_settlement/variant_placement_test.gd +++ b/game/tests/unit/battle_settlement/variant_placement_test.gd @@ -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) diff --git a/game/tests/unit/core/meta_progression_forge_test.gd b/game/tests/unit/core/meta_progression_forge_test.gd index 7048c521..84bdd583 100644 --- a/game/tests/unit/core/meta_progression_forge_test.gd +++ b/game/tests/unit/core/meta_progression_forge_test.gd @@ -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 携带)", diff --git a/game/tests/unit/data_config/effect_kind_retired_test.gd b/game/tests/unit/data_config/effect_kind_retired_test.gd index fe9819cb..a3b46a29 100644 --- a/game/tests/unit/data_config/effect_kind_retired_test.gd +++ b/game/tests/unit/data_config/effect_kind_retired_test.gd @@ -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) diff --git a/reports/iter53-反哺点-2026-11-05.md b/reports/iter53-反哺点-2026-11-05.md new file mode 100644 index 00000000..b1c9eeef --- /dev/null +++ b/reports/iter53-反哺点-2026-11-05.md @@ -0,0 +1,21 @@ +# 迭代 53 反哺点清单(2026-11-05) + +- 接收方:[待办清单](../待办清单.md)(反哺点裁决后由文档批/工程批写入并把本清单条目清掉)。 +- 依据:`reports/iter53-实现报告-2026-11-05.md`;两卡「关联文档同步面」逐条落点如下。 + +| # | 落点(哪个文件哪一节) | 改成什么 | 依据 | +|---|---|---|---| +| 1 | `design/gdd/systems/跨局循环/锻造工坊.md` §满级特效扩展位——重甲反震候选条 | 实现前提句补一句:反弹伤害需新开消费点(本批摘 `spike` 词表位后彻底无位,候选=「需新代码」档) | 卡② §6 第 2 行;damage_controller.gd 摘除后 `open_ticket` reflect 通道为唯一反弹面且零现役表行 | +| 2 | `design/gdd/systems/战斗循环/增益状态.md`/`伤害与护甲.md` | 零命中备案——两文档叙述「按序改写族」的句子未挂退役件名,无需改动 | 卡② §6 机械面核对读数(40 名扫描两文件零命中) | +| 3 | `design/gdd/systems/冒险循环/奇物.md`、`systems/战斗循环/地图战斗资源.md`、`design/gdd/术语表.md`、`00-游戏概念.md`、`03-内容规模.md` | 各文仍提退役内容名的句子逐处换机制名(清退口径同卡② T3:只换名不换职责) | 卡② §5 去向表;40 名扫描命中所在(GDD 面不在本批可写域) | +| 4 | 结算明细标签是否入表(文案硬编码 `reward_grant.gd:226`) | 规范裁决点:玩家可见明细文案归属——维持代码硬编码 or 入数据表行;裁决后改对应规范文档 | 卡② §5 去向表末条;本批已把标签换代为「挖掘惩罚减半(%d→%d)」 | +| 5 | `tools/gate_platform.sh` `gate_run_timeout` | 修 Windows 恒 124 缺陷:`kf` 改「先赋路径、`rm -f` 后置守卫」(probe_gate.sh run_suite 同款正确写法);连带修 `golden_baseline_refresh.sh` 的 guard 子Shell kill 短路(7 分钟等待症状) | iter53 实现报告「遗留读数义务与发现呈报」第 1 条;实证=直接调用 4 秒返回 124、refresh 两次 7m1s | +| 6 | `game/tests/golden/mac/` | mac 侧补冻关卡视口两 Shot 基线(跑 `golden_baseline_refresh.sh --lmap`) | 渲染闸 lmap 段缺基线记 SKIP;windows 已冻结(6394abf7),mac 冻结后两平台同盯 | +| 7 | `game/tests/golden/README.md` §时效判定纪律 | 「基线冻结于 2026-09-30」句补 lmap 两 Shot 冻结日(2026-11-05)与两 Shot 面口径指针 | 本批 golden/README 台账已改现持清单,冻结纪律节的时间句留待文档批随批换代 | +| 8 | `game/tests/README.md` 能力行 :163(关卡画面视口平移) | 代表用例名 `_probe_clamp_bounds` 回表为落地名 `_probe_clamp_bounds_and_columns`(实现按卡 §8 对照表外的更 descriptive 名落地,回表归写卡段) | 验收审计 D1(建议级):名实回表义务 | +| 9 | 探针直读申报口径(test-conventions §8.2/§8.4 受控例外注记) | 「探针直读视口子件结构(`get_child(0)` 画布位、按名取指引子件 FadeL/FadeR/PanHint)」补进探针直读惯例的申报面注记 | 验收审计 D2(观察级):直读有注释理由、零风险,补成文口径 | +| 10 | `reports/iter53-实现报告-2026-11-05.md` 决定 3 | 已随本清单同批订正(case⑰→⑱ 笔误)——归档备查,无文档批动作 | 验收审计 D3 | + +## 零反哺备案 + +- 卡① `design/gdd/ux/level-map.md`:实现未发现 spec 缺口——布局决策 5 行为四条+像素级判据①–④+AC 260–271 全部有绿读数承载(实现报告对照表 R1–R9+golden 段)。 diff --git a/reports/iter53-实现报告-2026-11-05.md b/reports/iter53-实现报告-2026-11-05.md new file mode 100644 index 00000000..b86116fe --- /dev/null +++ b/reports/iter53-实现报告-2026-11-05.md @@ -0,0 +1,80 @@ +# 迭代 53 实现报告(2026-11-05) + +- 接收方:[待办清单](../待办清单.md)条目「超预算图策略升级路径——等比缩放与滚动视口两案待裁」(卡①卷轴视口批)+「damage_controller 按序改写族——内容孤儿机制与退役名注释层」(卡②改写族清退批)。 +- 需求卡片:`reports/需求卡片-卷轴视口批-2026-11-05.md`(验收档:中)+`reports/需求卡片-改写族孤儿机制与退役名清退批-2026-11-05.md`(验收档:中)。 +- 独立验收审计:`reports/iter53-验收审计-2026-11-05.md`(未参与实现的会话执行;两卡均按中档深度)。 +- 执行形态:整迭代单工作区(`g-plan2-iter53`,两卡均未声明可并行),卡序=卷轴视口 → 改写族清退。 + +## 各卡状态 + +| 卡 | 状态 | T 卡 | 凭据摘要 | +|---|---|---|---| +| 卡①卷轴视口批 | 完成(T0–T4 全收口+golden 扩项冻结) | T0 基线读数在案/T1–T3 实现波提交 c1043902/T4 指针反转 4e87a012/golden 扩项 6394abf7 | 新探针 26 断言全绿+探针族全绿+冒烟 15/0+S13 基线内 7 新增 0+渲染闸全绿(lmap 双图 changed=0.00%) | +| 卡②改写族清退批 | 完成(T0–T5 全收口) | T0 基线读数在案/T1 b5c21e3a/T2 1bd50304/T3+T4 并笔 ed793e8c | 三套件+四套窄测 exit=0+对拍六组逐字节 diff=0+四探针负域全拒+契约 1 三条 grep 全零+40 名注释面清零 | + +## 卡① 实际改动—验收对照表 + +| 需求点 | 改动(文件:行) | 验收测试(落点+尾缀) | 层级 | 真实入口 | 判据绑定面 | 结果 | +|---|---|---|---|---|---|---| +| R1 浅图整幅居中无裁切 | level_map_screen.gd 三段页承载+level_map_viewport.gd `_axis_center_offset` 居中分支 | `level_map_viewport_probe.gd:71/74/76`〔卷轴批 V1〕 | 探针 | `open_choices` 真实停点(L1 固定种子直构 runner) | 画布≤窗居中 offset 定位+画布矩形 encloses 于视口 | 绿 | +| R2 停点切换当前节点居中 x=640±2 | level_map_viewport.gd `reanchor`+screen `_viewport_anchor`/open_choices 接线 | `level_map_viewport_probe.gd:221`〔卷轴批 V2〕 | 探针 | 断点停点重建+真实 `open_choices` | 当前节点画布投影=视口中线±2 | 绿 | +| R3 拖拽平移 | screen `_on_canvas_motion` 拖拽态逐帧 `pan_by`(含激活帧位移) | `level_map_viewport_probe.gd:137`〔卷轴批 V3〕 | 探针 | `push_drag_at` 合成沿走 `push_input` | 视线位移=拖拽位移(40px 逐位) | 绿 | +| R4 滚轮横滚 | screen `_wheel_pan_step` 映射(下/右=前进向) | `level_map_viewport_probe.gd:114/116/120`〔卷轴批 V4〕 | 探针 | `push_wheel_at` 合成滚轮沿 | 单档 ±60 逐位+窗内不钉边界 | 绿 | +| R5 clamp 包络与首末列可达 | level_map_viewport.gd `_axis_pan_offset`/`is_clamped_at_edge` | `level_map_viewport_probe.gd:88/102/158/160/161/167/168`〔卷轴批 V5〕 | 探针 | 超包络拖拽沿两极 | 中线恒在夹紧域+两极 edge=true+首末列中心入屏+入口组包围盒锚点 | 绿 | +| R6 拖拽不触发选路 | screen `_on_canvas_click` 拖拽抬杆不提交 | `level_map_viewport_probe.gd:200/201/206`〔卷轴批 V6〕 | 探针 | 拖拽沿/完整点击沿起止候选上方 | `route_chosen` 零发射+屏未关;完整点击仍提交=1 | 绿 | +| R7 平滑滚动与减动效瞬切 | level_map_viewport.gd `reanchor(smooth)` tween `UiKit.motion(0.3)`+`ArmedTimer.motion_still` 跳终态 | `level_map_viewport_probe.gd:245/249/258`〔卷轴批 V7〕 | 探针 | 可见屏停点切换+`UiKit.motion_scale` 置位 | 平滑有 tween 中间帧(同向在途)+减动效即达 | 绿 | +| R8 指引件可见性与几何 | level_map_viewport.gd `_build_guidance`/`_sync_guidance` | `level_map_viewport_probe.gd:271–287`〔卷轴批 V8〕 | 探针 | 深图真实停点+浅图复验 | 渐隐带 70px+chip 240×30 居中可见;浅图隐藏 | 绿 | +| R9 schema 头注指针反转(非行为) | level_gen_params.gd:3-4/:66 头注 | 静态判据:grep「调参表」残留=0、「参数契约节」指向=2;`--check-only` 零错 | 静态 | — | 全仓无「域并单点=调参表」残留 | 绿 | +| golden 两 Shot 面(2026-11-05 用户裁) | level_map_shot_probe.gd+golden/windows/lmap_entry·lmap_boss+render_gate.sh 对拍段 | 渲染闸「关卡视口 golden 逐像素」段(changed=0.00% 双绿);重采集确定性=0.00% | golden | 窗口化真实渲染+固定种子 | 两 Shot 逐像素 ≤16 差/≤2% | 绿 | + +非行为改动行:无(R9 之外全部行为面;`.uid` 边车随新件入库)。 + +## 卡② 实际改动—验收对照表 + +| # | 需求点 | 改动(文件:行) | 验收测试(落点+尾缀) | 层级 | 真实入口 | 判据绑定面 | 结果 | +|---|---|---|---|---|---|---|---| +| R1 | 摘四段不可达改写分支 | damage_controller.gd(原 :297-302/:305-309/:310-314/:316-323/:350-353/:367-372/:623 全摘+spike_ready 键摘) | `damage_controller_test.gd`(exit=0);`roll_multiplier_test.gd`(exit=0);`round_flow_skill_pool_test.gd`(exit=0);dump_battle 六组改前改后逐字节比对 diff=0 | 单元+集成 | `BattleOrchestrator.begin_battle()` 全链 | step_row 流逐位=改前基线(L{1,2}×S{42,779401,779501});静态=契约 1 三条 grep 全零+摘后 `spike_ready` 零残留。观测面局限申报:零现役注册行使分支不可达,禁注入表行取证(卡 §8 申报在案) | 绿 | +| R2 | EFFECT_KINDS 摘 spike | effect_engine.gd:30 | `effect_kind_retired_test.gd:89`「effect_kind_retired_test/③词表闭面:kind=spike 出域拒绝」 | 单元(负面,独立进程) | `ConfigDB.load_tables()` → `validate_trigger_row` | do.kind=spike 行被拒+error 诊断在案+退出码 1(负面期望码) | 绿 | +| R3 | DO_KEYS 摘 trade_hit/trade_atk | effect_engine.gd:82 | `effect_kind_retired_test.gd:92`「effect_kind_retired_test/④do 键闭面:trade_hit 出域拒绝」 | 单元(负面,独立进程) | 同上 | 带 trade_hit 键 do 字典被拒+退出码 1 | 绿 | +| R4 | 注释面退役名清零(13 文件) | T3 35 处注释改写(对照卡 3.3 表逐处) | 静态判据:40 名全扫仅剩卡 §5 留底 3 行(treasure_items_test:31/33/35 StubDB 夹具名);宿主套件逐位不变(四套窄测 exit=0) | 静态(闸件扫描) | 宿主套件回归(改写面不进运行期) | 注释面命中 0+套件绿 | 绿 | +| R5 | 减半明细行去退役件名 | reward_grant.gd:226(标签→「挖掘惩罚减半(%d→%d)」+effect_id→nest_halve) | `meta_progression_forge_test.gd:694/697/699/700`(断言串随动,非空+等值+不含旧词强度不降) | 集成(信号采集) | 锻造锋线满级特效 → begin_battle → mine_hit 减半路径 → step_row | display_name==「挖掘惩罚减半(6→3)」+power 减半数值与发射次数逐字不变+grep nest_counter=0 | 绿 | +| R6 | 空集拒绝 reason 去退役件名 | reward_grant.gd:189(→「中立移除:…」) | 静态判据:grep 爆破工兵包 reward_grant.gd=0;`table_structure_guard_test.gd` exit=0 | 静态+单元 | 通道校验面(零现役行不可达) | grep 零命中+通道校验绿。观测面局限申报:reason 不可达,禁造行取证 | 绿 | +| R7 | 反弹明细标签去退役件名 | grant_segment.gd:166(标签→「反弹伤害」+归因串→attune_nest_halve;channel=="reflect" 判据与开单参数字典逐字不动) | 静态判据:grep 惊巢反制 game/=0、attune_nest_counter=0;`round_flow_skill_pool_test.gd` exit=0 | 静态+集成 | mine_hit 订阅施加链(reflect 行族零表行,今天不可达) | grep 零命中+发射面零变化回归。观测面局限申报:禁造行取证 | 绿 | +| R8 | sim 恒 false 匹配删除 | level_autoplay.gd(原 :415 注释+:421 contains(雷区图谱) 条件删,highlight==seal_hit 单判据保留) | 静态判据:grep 雷区图谱 game/=0;sim 驱动链套件绿(四套窄测含 autoplay 消费面 exit=0) | 静态+单元 | autoplay 驱动链(测量面) | grep 零命中+套件绿。观测面局限申报:恒 false 条件不可达 | 绿 | + +非行为改动行:T3 注释面 35 处(由 R4 行覆盖);契约 3 约束下 :226/:166 仅标签与归因串文本变动、取值/位序/载荷键逐字未动(R1 对拍 diff=0 为其逐位证据)。 + +## 读数义务对账(卡内 T 卡产出) + +- **卡①T0**:S13 基线无 `level_map_screen` 键(记「无键、按工具默认」);ui_struct_probe PASS 全屏结构断言零红;screen_flow_probe PASS;冒烟 15 项 0 失败。 +- **卡①T4 复跑**:S13 基线内 7 条、新增 0(`_on_canvas_input` 重构触发的 max-branches 新违规已拆 `_on_canvas_motion`/`_on_canvas_click`/`_wheel_pan_step` 归零);ui_struct_probe/screen_flow_probe 复跑绿(探针族全绿内含)。 +- **卡②T0**:契约 1 三条 grep——`game/data/tables/` 内 spike/armor_rewrite/discount_shift/trade_hit/trade_atk 全零(对照记录=feats.tres:855 clamp_bonus 现役一行);tree_rules 面零;effect_kinds 面 cell_removal 表面零(仅 schema 在册:treasure_item_table.gd:20/:26,本批不摘通道)。S13 四文件无登记键(damage_controller/effect_engine/reward_grant/level_autoplay 均按工具默认)。改前对拍基线六组落盘(L{1,2}×S{42,779401,779501})。改前断言串=「惊巢反制——危险格挖掘惩罚减半(6→3)」(:693/:698-701 现行文本抄录在案)。 +- **卡②T5**:改后对拍六组 diff=0;40 名全扫终读数=3 行留底夹具;R5–R8 静态 grep 全零;负域套件 exit=1(期望码)四探针全拒。 +- **存量触面 S13 对照**:damage_controller(分支数/函数长度随摘除下降,无新增键);effect_engine(常量字面量变短,方法数/成员数不变);reward_grant/level_autoplay(删文本与恒 false 条件)——S13 终读数=基线内 7、新增 0、恶化 0。 + +## 实机目检总清单(登记不阻塞) + +| 目检项 | 来源卡 | 承载 | +|---|---|---| +| 深度 13+ 图拖拽可达首末列、推进跟随方向感、chip 不碍选路 | 卡① | 窗口化实跑截图已冻结(golden/windows/lmap_entry·lmap_boss);真人试玩复核登记体验验证清单(归批后收尾) | +| 结算明细行减半标签「挖掘惩罚减半(6→3)」一眼可读、无退役件名突兀感 | 卡② | 实现报告登记;真人目检归体验验证清单(归批后收尾) | + +## 实现决定记录(超白名单/超卡片明列面逐条备案) + +1. **拖拽态成员落屏侧**(卡①§11 把 `_drag_active`/`_drag_accum` 枚举进视口件成员,§6 职责边界却写明视口件「只管裁切/offset/clamp/跟随」、拖拽判定留画布)——取 §6 职责边界优先:拖拽判定状态随输入处理落 screen(`_drag_active`/`_drag_press_pos`),视口件成员 6 个(≤8 达标)、公开方法 5 个(≤20 达标)。撤销方式=成员迁视口件+canvas 经方法驱动(纯结构搬移)。 +2. **open_choices 平滑判据=有无当前节点**(卡①§6 写「reanchor(false) 首锚」、T2 写「停点切换/回图接 reanchor(true)」,open_choices 同时承载两路径)——合并读法:入口停点(current_node_id<0)即达=首锚,其余停点/兑付回图平滑=跟随。R2/R7 断言均经此路径。 +3. **提交沿移至抬杆**(原提交在按下沿;布局决策 5 阈值区分语义下按下时无法预知是否成拖拽,R6 要求拖拽沿零发射)——完整点击(按下→未超阈值抬杆)提交、拖拽抬杆不提交;ui_input_probe case⑫/⑱ 补 release 沿适配(验收审计 D3 订正:原呈报误写⑰,实际落点两处)(判据语义「图上真实点击提交选路」不变,输入形态更完整)。 +4. **滚轮步进 60px、渐隐带色 BG_ROOT 不透明端 0.9、chip 底 HP_BAR_BACK/描边 STONE/文字 WARM、缓动 TRANS_CUBIC+EASE_OUT**——卡 §12 白名单 4/5/3 授权面内定值。 +5. **视口件 NOTIFICATION_RESIZED 即席重锚**——首开布局晚于 open_choices 锚定(隐藏子树容器排序首可见帧才传播,4.7 行为;实证=探针首跑 y 锚偏 88px),resize 即席 `reanchor(false)` 收敛;同时是窗口尺寸变化的正确行为。 +6. **_fit_canvas 画布尺寸同帧收敛**(`canvas.size = _content_size` 显式置)——视口锚点数学与 `_layout` 同源读画布尺寸,容器延迟收敛不等锚点。 +7. **mouse_exited 清拖距跟踪器**——按下即武装的跟踪器在丢失 release 沿(alt-tab 等)后残留会把后续 motion 误判为拖拽激活(ui_input_probe case⑰⑧ press-only 实证);非拖拽态离画布即解除。 +8. **卡②探针①注入 do.kind="spike"**(卡 T2 括注 kind=modify_ticket+mode=spike 形态)——括注形态无拒绝通道(`validate_trigger_row` 无 mode 值域检查,mode 属载荷字段非词表),按断言名「词表闭面:kind=spike 出域拒绝」与 R2 判据绑定面(effect_kinds 出域文案)的机制注入 kind 形态。 +9. **golden 两 Shot 面口径**(卡①现状事实写「本批不触渲染闸对拍」、批纪律 iter 4.4+test-conventions §8.2 要求扩项)——呈裁用户,2026-11-05 裁「只处理最初始的和最终的:关卡最开始+关卡 Boss 节点,中间过程不对拍」,按两 Shot 面扩项冻结。 +10. **卡② T3 与 T4 并笔提交**——卡 §10 白名单授权面。 + +## 遗留读数义务与发现呈报 + +1. **闸件缺陷呈报(工程批)**:`tools/gate_platform.sh` 的 `gate_run_timeout` 用 `mktemp` 造超时标志文件——mktemp 即建文件,末尾 `[ -f "$kf" ]` 在 Windows(Git Bash)恒真→**恒返回 124**。被各闸「判定锚工件而非退出码」口径掩住(菜单/棋盘 golden 段与本批 lmap 段均工件锚定,不受影响);`tools/golden_baseline_refresh.sh` 的 7 分钟等待症状(guard 子Shell `sleep 420` 未被 kill 短路)同根。修法=改 `kf="$TMP/..." ; rm -f "$kf"`(run_suite 同款,probe_gate.sh 已是正确写法)。本批不代修(闸件改动归工程批)。 +2. **mac 端关卡视口基线冻结义务**:`golden/mac/` 缺 lmap_entry/lmap_boss——渲染闸缺基线记 SKIP 不判红(windows 已锚即盯),mac 冻结归 mac 侧跑 `golden_baseline_refresh.sh --lmap`。 +3. **效果通道与事件词的零现役普查**——已在待办清单在册(卡② §5 去向),本批不扩。 +4. 卡① level-map.md 同步面:实现未发现 spec 缺口(布局决策 5 行为四条+像素级判据+AC 全部有绿读数承载),零反哺。 diff --git a/reports/iter53-验收审计-2026-11-05.md b/reports/iter53-验收审计-2026-11-05.md new file mode 100644 index 00000000..022f7813 --- /dev/null +++ b/reports/iter53-验收审计-2026-11-05.md @@ -0,0 +1,122 @@ +# 迭代 53 验收审计(2026-11-05) + +- 审计员:独立会话子代理——未参与本迭代任何实现、设计或写卡工作,只读证据做审计。 +- 审计对象:迭代 53 两张需求卡片——卡①卷轴视口批(`reports/需求卡片-卷轴视口批-2026-11-05.md`)+卡②改写族孤儿机制与退役名清退批(`reports/需求卡片-改写族孤儿机制与退役名清退批-2026-11-05.md`)+卡① golden 扩项(2026-11-05 用户裁两 Shot 面)。 +- 实现报告:`reports/iter53-实现报告-2026-11-05.md`(两卡「实际改动—验收对照表」为逐行核对基准)。 +- 验收档与实际执行深度:两卡均申报「中」档;本次按 test-conventions §8.7 中档执行——对照表逐行全量核对(两卡 R 行逐行)、判据绑定面抽查(超出下限)、测试削弱检查逐处、直查例外核对逐条、判据出处抽查 6 处。**最窄测试复跑=省略,该格写「合回后 CI 现查补记」**(合并尚未发生——本审计为合并前置闸,远端 CI 现查读数待合回落地后按 `tools/ci_check.sh <合并提交>` 补记;静态 grep/文件核对类检查由审计员当场重跑,不属省略面)。 +- 基线提交:`709d9a85..8b4399ad`(iter53 分支;7 笔实现笔 c1043902/4e87a012/b5c21e3a/1bd50304/ed793e8c/6394abf7/78e08234 + 1 笔并 master 8b4399ad)。工作树存在本机 Godot 导入元数据漂移(若干 `*.png.import` 未提交改动),不属提交面审计对象。 +- 审计动作面:`git diff 709d9a85..HEAD` 全量过目(37 文件);测试/探针/闸件改动逐 hunk 核对;契约 1 五词 grep、40 名退役名全扫、契约 5 残留 grep 由审计员当场重跑。 + +## 卡① 卷轴视口批(验收档:中|执行深度:中档) + +### 对照表逐行核对(全量) + +| 行 | 结论 | 依据(文件:行) | +|---|---|---| +| R1 浅图整幅居中无裁切 | 通过 | 断言在案且尾缀可 grep:`game/probe/level_map_viewport_probe.gd:71/74/76`〔卷轴批 V1〕;承载改造落地 `level_map_viewport.gd:30-36`(setup/clip_contents)+`level_map_screen.gd:134-145`(CenterContainer→视口件);真实入口=`open_choices` 真实停点(探针 :62-64,L1 固定种子直构 runner,start_level_external 注入)。运行读数=实现者「绿」在案;合回后 CI 现查补记 | +| R2 停点切换当前节点居中 x=640±2 | 通过 | 断言 `level_map_viewport_probe.gd:221`〔卷轴批 V2〕;断言钉屏空间投影(当前节点屏幕 x 对视口中心 ±2)——比卡面所写 `viewport_center_canvas_pos()` 调用更外层的真实观测,与 level-map.md:101/:154 判据①「居屏中线 x=640±2」同义,非削弱(观察 4);真实入口=断点停点重建+真实 `open_choices`(探针 :216-217)→ screen `reanchor` 接线 `level_map_screen.gd:312-315`+锚点提供方 :483-516。合回后 CI 现查补记 | +| R3 拖拽平移 | 通过 | 断言 `level_map_viewport_probe.gd:137`〔卷轴批 V3〕;真实输入沿=`ProbeUiUtil.push_drag_at`(`game/probe/probe_ui_util.gd:146-166`,press→motion×4→release 全经 `push_input`)打真实画布 `_gui_input`(`level_map_screen.gd:381-403` 拖拽态逐帧 `pan_by`)——**未绕过改动点**(探针全文零 `pan_by` 直调,grep 证);判据=视线位移=拖拽位移 40px 逐位。合回后 CI 现查补记 | +| R4 滚轮横滚 | 通过 | 断言 `level_map_viewport_probe.gd:114/116/120`〔卷轴批 V4〕;`push_wheel_at`(probe_ui_util.gd:168-176)经 `push_input`;映射 `_wheel_pan_step`(level_map_screen.gd:426-433,下/右=+60 前进向,代拍板白名单 5 授权)。合回后 CI 现查补记 | +| R5 clamp 包络与首末列可达 | 通过 | 断言 `level_map_viewport_probe.gd:88/102/158/160/161/167/168`〔卷轴批 V5〕;判据绑定 `viewport_center_canvas_pos()` 夹紧域(level_map_viewport.gd:76-77/:97-100/:111-114)+`is_clamped_at_edge()` 两极翻转(:81-86)+极值列入屏(:161-169,等价 spec 判据④);真实入口=超包络拖拽沿两极。合回后 CI 现查补记 | +| R6 拖拽不触发选路 | 通过 | 断言 `level_map_viewport_probe.gd:200/201/206`〔卷轴批 V6〕;**拖拽沿确从候选节点上方起沿**(:193 `gpos`=候选屏幕投影,:197 `push_drag_at(self, gpos, …)` 起点即候选);判据绑 `route_chosen` 发射面零计数(screen `route_chosen.emit` :465)+屏未关+阈值另一侧完整点击提交=1(:203-206,点击沿 press+release 成对经 push_input)。合回后 CI 现查补记 | +| R7 平滑滚动与减动效瞬切 | 通过 | 断言 `level_map_viewport_probe.gd:245/249/258`〔卷轴批 V7〕;tween 单段缓动 TRANS_CUBIC-EASE_OUT UiKit.motion(0.3)(level_map_viewport.gd:57-59);减动效走 `ArmedTimer.motion_still()`(armed_timer.gd:16-17=motion_scale<1)跳终态——探针置 `UiKit.motion_scale=0.5` 即设置面单点(settings_overlay.gd:96 同驱),判据同向在途+即达。合回后 CI 现查补记 | +| R8 指引件可见性与几何 | 通过 | 断言 `level_map_viewport_probe.gd:272-278`+浅图隐藏 :289〔卷轴批 V8〕;几何常量 FADE_BAND_W=70(level_map_viewport.gd:10)、CHIP_SIZE 240×30(:11)直挂 spec 判据②③;可见性=`_sync_guidance` 画布超窗判据(:181-191)。合回后 CI 现查补记 | +| R9 schema 头注指针反转(非行为) | 通过(静态重跑) | `level_gen_params.gd:3-4`+:70 反转落地;审计员重跑 grep:该文件「调参表」残留=0、「参数契约节」指向=2、`--check-only` 读数实现者在案。全仓唯一「调参表」残留=variation_table.gd:41(地形变异 v1.2 owner,不同文件不同域,不属本卡清退面) | +| golden 两 Shot 面(用户裁) | 通过 | `game/probe/level_map_shot_probe.gd`(PASS 签名 :36+落盘 fail-fast :89-91)+`game/tests/golden/windows/lmap_entry.png`/`lmap_boss.png` 已入库(6394abf7,Bin 215164/211513 字节)+`tools/render_gate.sh:168-197` 对拍段(PASS 签名+双图非零工件锚定+平台缺基线 SKIP 不判红)+`tools/golden_baseline_refresh.sh` --lmap 模式(:59-85,素材齐盘守卫)+`game/tests/golden/README.md` 台账随动;mac 缺基线义务已呈报(实现报告遗留 2+反哺点 6)。运行读数(changed=0.00% 双绿)=实现者在案;合回后 CI 现查补记 | + +卡①补充核对:26 断言计数属实(探针 26 处 `_check`+尾缀〔卷轴批 V1..V8〕27 行含 1 处缺位告警);`tools/probe_gate.sh:137-141` 注册新探针;ui_struct_probe 本批零改动(未触其结构断言面,卡①削弱申报该项落空=无事);实现报告实现决定 1-7(拖拽态落屏侧/open_choices 平滑判据/提交沿移抬杆/代拍板白名单定值/resize 即席重锚/_fit_canvas 同帧/mouse_exited 清跟踪器)均有代码承载且不越卡 §12 白名单;7 笔实现笔无一触碰 `design/`/两份根清单/`game/tests/README.md`(逐笔 name-only 核对)。 + +### 判据绑定面抽查(本卡抽 4 行,超中档下限 2 行) + +- R6(如上)——`route_chosen` 零发射+屏未关+完整点击仍提交,三面钉在「拖拽不提交/点击提交」的特有提交面上。 +- R2(如上)——当前节点屏空间投影=视口中线 ±2。 +- R5(如上)——夹紧域+两极 edge 翻转+极值列入屏。 +- R3(如上)——拖拽位移与视线位移逐位一致,且全程走引擎输入管线。 + +### 直查例外核对(逐条) + +| 直查行 | 申报状态 | 结论 | +|---|---|---| +| `level_map_viewport_probe.gd:67` `vp.get_child(0)`(取视口画布子件) | 探针内注释已给理由(「画布=视口首子件——setup 注入序」,:61),对照表/实现报告**未单列申报口径** | 缺漏(建议级,见末节 D2)——断言对象即承载结构本身,无公开可观测面可钉,风险零 | +| `level_map_viewport_probe.gd:266-268/:287-288` 按名取 FadeL/FadeR/PanHint | 同上——探针内注释在案,对照表未单列 | 缺漏(建议级,并入 D2)——R8 断言对象即指引子件结构本身 | +| screen 侧私有成员 | 新探针**未直读**任何 screen 私有成员(`_drag_active`/`_drag_press_pos` 零探针触达);既有「探针直读冻结」惯例面(level_map_screen.gd:39/:57-64)未扩 | 通过 | + +## 卡② 改写族孤儿机制与退役名清退批(验收档:中|执行深度:中档) + +### 对照表逐行核对(全量) + +| # | 结论 | 依据(文件:行) | +|---|---|---| +| R1 摘四段不可达改写分支 | 通过(静态面重跑一致;运行读数合回后 CI 现查补记) | diff 逐段核对:`damage_controller.gd` 命中侧交换/免掷覆盖/spike 置位/移档调用/增伤侧/spike 倍乘/`_discount_shift_up` 整函数全摘;`_ctx_rewrites` 返回键集={roll_read,threshold,crit,discount,clamp_hits}(:301-303)、`_hero_attack_ctx` 摘 spike_ready(:278-281)——与卡 T1 收窄键集逐字一致;armor_roll 发射点保留不接收返回值(:289-290,卡 3.6 口径);spike_ready/ar_hits/shift 残留 grep=0(审计员重跑)。六组逐字节对拍 diff=0 与三套件 exit=0=实现者读数在案;dump_battle 六组参数面(seed 42 默认+参数覆盖,dump_battle.gd:13/:21)核对成立 | +| R2 EFFECT_KINDS 摘 spike | 通过 | `effect_engine.gd:30` 词表摘名;断言 `effect_kind_retired_test.gd:89`「③词表闭面:kind=spike 出域拒绝」在案,沿既有 `_assert_rejected`/`_inject` 骨架(卡 T2 明示沿用);注入形态偏差(do.kind=spike,非卡 T2 括注 kind=modify_ticket+mode=spike)=实现决定 8 申报在案且经审计员核实——`validate_trigger_row`(effect_engine.gd:382-396)确无 mode 值域检查,括注形态无拒绝通道,实现口径与断言名/R2 判据绑定面(词表出域)一致。运行读数(负域套件 exit=1 四探针全拒)合回后 CI 现查补记 | +| R3 DO_KEYS 摘 trade_hit/trade_atk | 通过 | `effect_engine.gd:80-82` 摘两键;断言 `effect_kind_retired_test.gd:92`「④do 键闭面:trade_hit 出域拒绝」在案。运行读数合回后 CI 现查补记 | +| R4 注释面退役名清零 | 通过(40 名全扫审计员重跑) | 审计员自解析 `design/content-archive/道具退役素材库.md` 两表 40 名(41 减现役淬毒刃纹)重扫 `game/` 全域 .gd/.tres/.md/.tscn:命中仅 3 行=`treasure_items_test.gd:31/33/35` StubDB 夹具名——与卡 §5 留底申报逐行一致;契约 2 抽 4 处(grant_segment.gd:232「完美布防→封印全中倍率」、damage_controller.gd:109「猎巢人→nest_count 订阅消费面」、reward_grant.gd:130「掘进蓄能→付费积点账本」、grant_segment.gd:359「雷区图谱→标中雷追加」)——职责陈述(做什么/按什么序/取值从哪来)逐处保留,只换名;宿主套件绿=实现者读数,合回后 CI 现查补记 | +| R5 减半明细行去退役件名 | 通过 | `reward_grant.gd:226` 标签→「挖掘惩罚减半(%d→%d)」+effect_id→nest_halve;断言随动 `meta_progression_forge_test.gd:694/697/699-700`,强度三件(nc_hit 非空+等值「挖掘惩罚减半(6→3)」+零「挖雷」)与改前逐项对齐(基线 709d9a85:693-701 核对);grep nest_counter=0(审计员重跑)。合回后 CI 现查补记 | +| R6 空集拒绝 reason 去退役件名 | 通过(静态重跑) | `reward_grant.gd:189` reason 前缀→「中立移除:…」,`{"ok": false}` 形态未动(diff 核对);grep 爆破工兵包 game/=0(审计员重跑);观测面局限申报(reason 不可达、禁造行)在卡 §8/对照表一致在案 | +| R7 反弹明细标签去退役件名 | 通过(静态重跑) | `grant_segment.gd:165-166` 标签→「反弹伤害」+归因串→attune_nest_halve;**契约 3 逐字核对成立**:`channel=="reflect"` 判据、开单参数字典(channel/base_value/target_rule/ri/origin_cell)、`reward_grant.submit` 调用全未动(diff 对照基线 709d9a85:164-170);grep 惊巢反制/attune_nest_counter=0(审计员重跑)。运行读数合回后 CI 现查补记 | +| R8 sim 恒 false 匹配删除 | 通过(静态重跑) | `level_autoplay.gd:414-418` highlight=="seal_hit" 单判据保留、contains(雷区图谱) 条件与 :415 注释随删;grep 雷区图谱 game/=0(审计员重跑)。套件绿=实现者读数,合回后 CI 现查补记 | + +卡②补充核对(契约逐条):契约 1 三条 grep 审计员重跑全零——tables 五词 0、tree_rules 面 0、cell_removal 表面 0 仅 schema 在册(treasure_item_table.gd:20/:26);`feats.tres:855` clamp_bonus 现役一行对照记录与实现报告 T0 一致,表内现存 mode 值(queue/round/battle/clamp_bonus/refresh)零退役四模式。契约 5 禁留空壳——spike_ready/ar_hits/shift/_discount_shift_up 残留 grep 全零(审计员重跑)。契约 6 实现批禁写文档——7 笔实现笔 name-only 核对零触 design//待办清单/体验验证清单/game/tests/README.md。契约 7 零新增数值——diff 内零 .tres 表值改动。反哺点清单 7 条落点齐(含「明细标签是否入表」裁决点+增益状态/伤害与护甲零命中备案)。 + +### 判据绑定面抽查(本卡抽 3 行) + +- R2/R3(如上)——负域探针钉「退役词/键注入行→load_tables 拒绝+error 诊断≥1+进程退出码 1」;归属由夹具结构保证(copy_tables 全量现役表+唯一异常探针行)。观察 5:卡面写的「error 载荷含 effect_kinds 出域文案」未做逐字断言——沿用卡 T2 明示的既有骨架语义,非削弱。 +- R5(如上)——step_row display_name 等值断言+非空+零旧词,判据绑减半路径特有发射面。 +- R1 静态面(如上)——收窄键集逐字核对+摘除残留 grep,钉在「分支消亡」这一改动特有提交面。 + +### 测试削弱检查(逐处,两卡合并过目) + +`git diff 709d9a85..HEAD -- game/tests/ game/probe/` 全量 hunk 过目,共 5 文件: + +1. `effect_kind_retired_test.gd`:**纯新增**(两探针常量+两次 `_assert_rejected` 调用+PASS 文案随动);既有探针①②与 quit 语义零改动;`run_all.gd:195` 在册条目 expect=1/negative=true 未动。无削弱。 +2. `meta_progression_forge_test.gd`:恰 4 处期望串随 R5 换代(:694 匹配串/:697 描述/:699-700 等值串)+2 行注释(卡 3.3 表申报面)——断言强度逐项不降(非空+等值+不含旧词全保留),与卡 §8 削弱申报逐字吻合。无申报外削弱。 +3. `variant_placement_test.gd`:1 行注释(偏运硬币→订阅行,卡 3.3 表 :239 申报面),断言零改动。无削弱。 +4. `ui_input_probe.gd`:+16 行=case⑫断言段(图上命中提交沿)+case⑱ 雾段(雾下点击零提交)各补 release 沿——实现报告决定 3 申报在案;判据语义核对:雾段=零提交不变(且改为完整点击沿后更强)、命中段=提交沿随新语义移至抬杆(单 press 沿在新语义下不再是提交沿,不适配则必红——必要适配非放宽)。**呈报笔误**:申报写「case⑫⑰⑧」,实际落点两处、无 ⑰(case⑰ 未开格类型微光走 `_push_click` 既有成对沿,无需适配)(建议级 D3)。 +5. `ui_struct_probe.gd`:零改动——卡①「适配须保持语义」条款无事可核,非缺漏。 + +无申报外的断言删除、阈值放宽、扫描面缩小、换下层直调。 + +### 判据出处抽查(6 处,超下限 4 处) + +| 断言预期值 | 规格出处 | 独立可写出性 | +|---|---|---| +| 居中 ±2(探针 R2 :221) | level-map.md:101「屏中线 x=640±2」/:154 判据①/:269 | 能——规格句直接给出 640±2 | +| 渐隐带 70px(探针 R8 :273-274) | level-map.md:104/:154 判据②「屏缘两侧各 70px 渐隐带」 | 能 | +| chip 240×30(探针 R8 :275-276) | level-map.md:104/:154 判据③「240×30、圆角 6」 | 能 | +| 「挖掘惩罚减半(6→3)」(forge 测试 :699-700) | 卡② §8 R5 判据明文+reward_grant.gd:226 契约 3 边界;6→3 可由测试构造独立推出(heal 行 power=6×halve 50% ceil=3) | 能——预期值在卡内先行锁定,非镜像实现 | +| 拖拽阈值 8px(DRAG_THRESHOLD,level_map_screen.gd:34) | 卡① §12 白名单 2「8px 附近 provisional 区间内定值」授权;spec :103 明示阈值实现期定值 | 能——授权面在卡,值属 provisional 自主面 | +| golden 两 Shot 面 | 2026-11-05 用户裁决(实现报告决定 9 在案):只对拍关卡最开始+Boss 节点 | 能——裁决记录在实现报告与 golden/README 台账双在案 | + +六处均非「预期值只在实现里出现」的同义反复。 + +## 附加核对 + +- 能力行名实(game/tests/README.md:162-164 三行)::162 跟随行(_probe_follow_anchor/_probe_smooth_scroll_reduced_motion)与 :164 指引行(_probe_guidance_chrome)名实一致;**:163 平移行的 `_probe_clamp_bounds` 与落地名 `_probe_clamp_bounds_and_columns` 不一致**(建议级 D1——卡① §5「批末收尾核对表上名与落地一致(改名则回表)」未回表,反哺点清单亦未收录;实现批禁写 README,正确通道=回写卡段/反哺点)。 +- 负面套件在册:`game/tests/run_all.gd:195` effect_kind_retired_test expect=1/negative=true 登记在位,卡② T2 未动在册条目——一致。 +- S13 基线读数:`tools/gdstyle_baseline.txt` 恰 7 条且无本批触面键(level_map_screen/damage_controller/effect_engine/reward_grant/level_autoplay 均无键)——与实现报告 T0/T4 读数一致。 +- 闸件缺陷呈报核实(实现报告遗留 1):`tools/gate_platform.sh:37` `kf="$(mktemp …)"` 即建文件+:48 `[ -f "$kf" ]` 恒真→恒 124,呈报描述与代码相符;probe_gate.sh:97 `kf="$TMP/killed.$RANDOM"`(先赋路径不预建)为正确写法参照——呈报属实;该缺陷修法归工程批,本审计不裁定(不在验收对象内,已入反哺点 5)。 + +## 缺漏与观察清单 + +阻断级:无。 + +建议级(不阻断合并,归批后收尾/文档批): + +- D1:能力清单 `game/tests/README.md:163` 代表用例名 `_probe_clamp_bounds` ≠ 落地名 `_probe_clamp_bounds_and_columns`(卡① §5 名实核对义务未回表;反哺点清单漏收)。 +- D2:新探针对宿主内部结构的直查(`level_map_viewport_probe.gd:67` get_child(0)+:266-268/:287-288 按名取指引子件)未按 test-conventions §8.7 第 4 条在对照表逐条申报口径;探针内注释已给理由(断言对象=承载结构本身,无公开观测面),screen 侧既有「探针直读冻结」惯例未被动用、新私有成员零直读,风险零。 +- D3:实现报告/提交信息写「ui_input_probe case⑫⑰⑧ 补 release 沿」,实际落点为 case⑫断言段+case⑱雾段两处、无 ⑰——呈报笔误,判据语义已核保持。 + +观察(记录在案,不构成缺漏): + +- O1:R2 判据绑定面用屏空间投影而非卡面所写 `viewport_center_canvas_pos()` 调用——更外层真实观测,与 spec 判据①同义,非削弱;连带 `game/tests/README.md:162` 读面句与实况有轻微出入。 +- O2:卡② R2/R3 负域探针未逐字断言错误文案(沿用卡 T2 明示骨架语义;归属由夹具唯一异常行结构保证)。 +- O3:卡① T1 写「探针宿主沿组合根真实屏」、卡 §8 对照表写「场景探针实例化本屏」——卡内两处口径不一,实现取对照表口径且在对照表真实入口格如实呈现;`push_input` 真实输入管线口径照守(禁合成投喂的实质红线未破)。 +- O4:探针宿主直构 runner(start_level_external)属 test-conventions §5.4 自动化测试直构口径,探针头注已申报。 + +## 审计结论 + +**通过**(可合并;合并落地后按中档梯度以 `tools/ci_check.sh <合并提交>` 现查读数补记「最窄复跑」格——零触发或不可判不作绿证)。 + +- 卡①卷轴视口批(验收档:中):对照表 10 行全通过;绑定面抽查 4 行钉面成立;削弱零申报外;判据出处 4+ 处独立可写出;缺漏 3 项均建议级(D1 能力行名实回表、D2 直查申报口径补记、D3 呈报笔误)。 +- 卡②改写族清退批(验收档:中):对照表 8 行全通过;契约 1/2/3/5/6/7 审计员重跑/逐处核对全部成立;削弱恰为申报内 4 处期望串且强度不降;40 名扫描与卡 §5 留底逐行一致;无阻断项。 +- golden 扩项:两 Shot 面用户裁决在案、基线入库、闸件对拍段与台账齐;mac 冻结义务已呈报(反哺点 6)。 diff --git a/tools/golden_baseline_refresh.sh b/tools/golden_baseline_refresh.sh index 422d5a4f..7aba1e93 100755 --- a/tools/golden_baseline_refresh.sh +++ b/tools/golden_baseline_refresh.sh @@ -5,6 +5,8 @@ # 缺位时先拷主区素材+导入边车再跑)。 # 模式: # --menu:shot_probe 启动态菜单整屏帧 → game/tests/golden/menu_boot.png。 +# --lmap:level_map_shot_probe 关卡视口两 Shot(入口停点+Boss 列锚定——2026-11-05 用户裁 +# 两 Shot 面)→ game/tests/golden//lmap_entry.png + lmap_boss.png。 # 全屏基线可行性前置已验证:菜单连续帧差 0.0000%/max_diff=0(光柱为 # 程序生成静态贴片、零动画着色器,全屏基线不抖动);采集确定性=探针隔离档(无断点+出厂 # 设置 1280×800),与 render_gate 的 shot_menu 对比段同源同帧。 @@ -21,6 +23,7 @@ MODE="board" for a in "$@"; do case "$a" in --menu) MODE="menu" ;; + --lmap) MODE="lmap" ;; *) if [ -z "$GAME" ]; then GAME="$a"; fi ;; esac done @@ -53,6 +56,34 @@ if [ "$MODE" = "menu" ]; then exit 0 fi +if [ "$MODE" = "lmap" ]; then + # 关卡视口两 Shot 面(卷轴视口批·2026-11-05 用户裁:只对拍关卡最开始与关卡 Boss 节点, + # 中间过程不对拍)——入口停点 + 末列 Boss 列锚定,两基线各存平台子目录。 + for a in assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \ + assets/art/ui/button_stone.png \ + assets/art/ui/node_battle.png assets/art/ui/node_reward.png \ + assets/art/ui/node_rest.png assets/art/ui/node_boss.png \ + assets/art/ui/node_shop.png; do + if [ ! -f "$GAME/$a" ]; then + echo "[golden_refresh] 拒跑 — 关卡屏素材缺位: ${a}(S3 停机点口径:基线禁锚字形回退态,先补素材+导入边车)" + exit 1 + fi + done + gate_run_timeout 420 "$TMP/run.log" "$G" --path "$GAME_WIN" --quit-after 3000 res://probe/level_map_shot_probe.tscn -- "$TMP/shots" + st=$? + grep -E "\[level_map_shot_probe\]" "$TMP/run.log" | tail -3 + # 判定锚工件(同 menu——quit-after 与 quit(0) 竞速的 124 非真红):PASS 签名 + 双图非零。 + if ! grep -q "PASS — 两 Shot 落盘" "$TMP/run.log" || [ ! -s "$TMP/shots/lmap_entry.png" ] || [ ! -s "$TMP/shots/lmap_boss.png" ]; then + echo "[golden_refresh] FAIL — 关卡视口截图缺失或探针自检红(st=$st,见上)" + exit 1 + fi + mkdir -p "$REPO/game/tests/golden/$PLAT" + cp "$TMP/shots/lmap_entry.png" "$REPO/game/tests/golden/$PLAT/lmap_entry.png" || exit 1 + cp "$TMP/shots/lmap_boss.png" "$REPO/game/tests/golden/$PLAT/lmap_boss.png" || exit 1 + echo "[golden_refresh] 完成 — 关卡视口两 Shot 基线已代入 game/tests/golden/$PLAT/(提交说明须写明触发原因)" + exit 0 +fi + for a in assets/fonts/ui_font_fusion_pixel_zh_hans.ttf \ assets/art/board/seal_mark.png \ assets/art/character/character_hero_idle.png \ diff --git a/tools/probe_gate.sh b/tools/probe_gate.sh index d47b7af8..b54848ae 100755 --- a/tools/probe_gate.sh +++ b/tools/probe_gate.sh @@ -134,6 +134,12 @@ st=$? grep -E "PASS|FAIL|问题" "$TMP/uip.log" | head -12 verdict "ui_input_probe" "$TMP/uip.log" "$st" || _rc=1 +echo "======== level_map_viewport_probe(卷轴视口批 R1-R8) ========" +run_suite 120 "$TMP/lvp.log" res://probe/level_map_viewport_probe.tscn +st=$? +grep -E "PASS|FAIL" "$TMP/lvp.log" | head -12 +verdict "level_map_viewport_probe" "$TMP/lvp.log" "$st" || _rc=1 + echo "======== ui_struct_probe ========" run_suite 120 "$TMP/stp.log" res://probe/ui_struct_probe.tscn st=$? diff --git a/tools/render_gate.sh b/tools/render_gate.sh index 8e1a7b8a..d8bfb16f 100755 --- a/tools/render_gate.sh +++ b/tools/render_gate.sh @@ -165,6 +165,38 @@ else fi fi + +echo "======== 关卡视口 golden 逐像素(卷轴视口批——两 Shot 面:入口停点+Boss 列锚定)========" +LM="$TMP/lmapshots" +gate_run_timeout 360 "$TMP/lm.log" "$G" --path "$GAME_WIN" --quit-after 3000 res://probe/level_map_shot_probe.tscn -- "$LM" +st=$?; kill_orphans +grep -E "\[level_map_shot_probe\]" "$TMP/lm.log" | tail -3 +# 判定锚工件(同 fx/board——quit-after 竞速 124 非真红):PASS 签名 + 双图非零。 +if ! grep -q "PASS — 两 Shot 落盘" "$TMP/lm.log"; then + echo "[render_gate] FAIL — level_map_shot_probe 未干净完成(exit=${st})"; _rc=1 +fi +for b in lmap_entry.png lmap_boss.png; do + if [ ! -s "$LM/$b" ]; then echo "[render_gate] FAIL — 取证缺失/零字节: $b"; _rc=1; fi +done +scan_log "level_map_shot_probe" "$TMP/lm.log" +if [ "$_rc" -eq 0 ]; then + for b in lmap_entry.png lmap_boss.png; do + if [ ! -s "$GOLDEN/$b" ]; then + # 两 Shot 面(2026-11-05 用户裁)各平台独立冻结——本平台缺基线=未冻结态记 SKIP 不判红 + #(windows 先锚;他平台冻结义务归收尾呈报),已锚平台照常逐像素严判 + echo "[render_gate] SKIP — $GOLDEN/$b 基线缺失(本平台未冻结——跑 tools/golden_baseline_refresh.sh --lmap)" + else + LMG="$TMP/lmgolden" + mkdir -p "$LMG" + cp "$LM/$b" "$LMG/$b" + python3 tools/golden_compare.py "$REPO/game/tests/golden" "$LMG" "$b" | sed 's/^/[render_gate] /' + if [ "${PIPESTATUS[0]}" -ne 0 ]; then + echo "[render_gate] FAIL — 关卡视口 golden 超容差(S2 停机点口径:视口呈现换代=显式重制基线+呈报;超预期面=L3)"; _rc=1 + fi + fi + done +fi + echo "======== 残留检查(批末闸) ========" left="$(orphans | wc -l | tr -d ' ')" total="$(gate_godot_count_all)"