diff --git a/design/gdd/systems/冒险循环/冒险结构.md b/design/gdd/systems/冒险循环/冒险结构.md index 16fad961..e9fc4725 100644 --- a/design/gdd/systems/冒险循环/冒险结构.md +++ b/design/gdd/systems/冒险循环/冒险结构.md @@ -163,7 +163,7 @@ GP(显示名「金币」)是冒险内的一次性属性提升货币:战斗 | 钥石掉落 | Boss / 精英怪击杀 | 战斗掉落型 | Boss 掉 2–3 个钥石(首杀/复杀两档读表)、精英怪掉 1 个(精英怪未实装,设定登记预留) | | 部分回血 | 关卡通关(非末关) | 固定兑付型 | 回复生效上限 × 回血比例(provisional 20%,参考恢复节点量级),封顶上限、不回满;满血回 0 合法 | -兑付次序:部分回血先行(按当刻账本与上限结算),随后呈现三选一**选择停点**——零自动推进,必须玩家点选才兑付,重复兑付被拒(结算受理只算一次语义同族)。选择停点可中断:断点附随件扩「奖励选择态 + 件获取次数计数」,续玩不重抽、不静默丢失(见「冒险断点」节)。 +兑付次序:部分回血先行(按当刻账本与上限结算),随后呈现三选一**选择停点**——零自动推进,必须玩家点选才兑付,重复兑付被拒(结算受理只算一次语义同族)。选择停点可中断:断点附随件扩「奖励选择态 + 件获取次数计数」,续玩已选件不静默丢失;**候选逐位一致系停点快照承载的实现现状、非验收义务**(不一致不影响玩法——2026-10-01 用户裁决,见「冒险断点」节)。 **提升池六种**(件值/权重/次数上限全读冒险表,provisional 归[难度曲线与Boss](难度曲线与Boss.md)曲线调参批): @@ -199,7 +199,7 @@ GP(显示名「金币」)是冒险内的一次性属性提升货币:战斗 冒险进行中随时关游戏走人(**中断**),下次启动可从最近停点继续——承载 = 存档 `breakpoint` 域(格式归[存档序列化](../表现与支撑/存档序列化.md),内容口径归本文)。 - **停点粒度**:快照只挂选路停点(入口呈现/战斗胜利后/兑付确认后的选路态)——这是唯一可恢复停点形态。战斗进行中不写快照:中断恢复后**当场战斗从头打**,棋盘中局不入档。 -- **七字段拍板面**:关卡序号 / 节点位 / 生命 / 本关加成区 / GP 钱包 / 商店已购档 / 已抽模板。附随件(续玩正确性必需,随批登记):主种子 + 取数计数器(rng 快照契约回填——续玩从断点号位精确续接原随机时间线,已抽模板零重抽)、钥石累计(Boss 掉落逐杀入账的中途值,终局摘要不失真)、已到节点序(关卡画面图条重建)、魔法道具库存(奖励节点已选件防静默丢失)、**奖励选择态**(三选一停点中断:已抽候选 / 已选件 / 同调位替换在途——续玩不重抽、不静默丢失,迭代 10 扩)、**件获取次数计数**(攻击/生命上限/护甲/行动点四件各自已获取次数——次数上限判定不可由加算区累加值反推,牌库剔除态依赖它,迭代 10 扩)。 +- **七字段拍板面**:关卡序号 / 节点位 / 生命 / 本关加成区 / GP 钱包 / 商店已购档 / 已抽模板。附随件(续玩正确性必需,随批登记):主种子 + 取数计数器(rng 快照契约回填——续玩从断点号位精确续接原随机时间线,已抽模板零重抽)、钥石累计(Boss 掉落逐杀入账的中途值,终局摘要不失真)、已到节点序(关卡画面图条重建)、魔法道具库存(奖励节点已选件防静默丢失)、魔法道具库存(奖励节点已选件防静默丢失)、**奖励选择态**(三选一停点中断:已选件 / 同调位替换在途——续玩已选不丢失;候选一致性=快照现状非强求 feature,2026-10-01 用户裁决)、**件获取次数计数**(攻击/生命上限/护甲/行动点四件各自已获取次数——次数上限判定不可由加算区累加值反推,牌库剔除态依赖它,迭代 10 扩)。 - **中断与退出两口径分离**(2026-09-29 用户裁决):中断(关游戏)= 留断点可续;**退出冒险**(放弃流程)= 失败同型结算 + **清除断点**——退出后「继续上局」不再出现。 - **续玩**:自断点重建到最近停点(账面逐字段回填 + 随机流回填),停点处玩家重新选路。三结局(通关/失败/放弃)一律清断点——断点只活在一个进行中的冒险上。 - 与测量面的关系:断点面只在注入存档承载的正式冒险路径生效;模拟/冒烟不注入 = 整面静默,MC 对拍零扰动。 diff --git a/design/gdd/ux/level-clear.md b/design/gdd/ux/level-clear.md index 39adc21f..da869225 100644 --- a/design/gdd/ux/level-clear.md +++ b/design/gdd/ux/level-clear.md @@ -56,7 +56,7 @@ 按 2026-09-26 信息归属两铁律(循环口径 + 单一展示面)过闸后的准入清单: -1. **三张提升选项卡**(第一注视点):件名称 + 一行效果读数(值读冒险表)——选择型奖励的本体;已达次数上限的件不出现(剔牌库在先) +1. **三张提升选项卡**(第一注视点):件名称 + 一行效果读数(值读冒险表)——选择型奖励的本体;已达次数上限的件不出现(剔牌库在先);**魔法道具件直显掷定的具体道具**(「魔法道具 / 探巢摆钟 · 真视 3×3」——掷定前置,候选抽定时掷定具体道具,追加批二) 2. **部分回血兑付行**:回血量(前后生命读数,如「生命 34 → 42」);满血回 0 时呈「生命已满」反馈而非 0 字样 3. **兑付反馈**(确认后):选中件效果一行(如「攻击 +1」)+ 另两件已丢弃提示 4. **同调位替换子交互**(条件呈现):选魔法道具件且库存满位时——现有件 + 新件共 N+1 选 N 的子面板:每件「丢弃换新」钮 +「放弃新件」钮;终态恒 ≤ 同调位 @@ -107,7 +107,7 @@ - [ ] 部分回血兑付行可见且封顶上限;满血回 0 呈合法态文案 - [ ] 同调位替换子交互:满位收新触发 N+1 选 N,丢旧收新与放弃新件两路终态恒 ≤ 同调位 - [ ] 末关通关分支正确直通冒险结算(不发三选一与回血),冒险摘要五件事完整 -- [ ] 确认前冒险状态冻结(无后台推进、无战斗事件串扰);选择停点中断 → 续玩候选不重抽、已选不丢失 +- [ ] 确认前冒险状态冻结(无后台推进、无战斗事件串扰);选择停点中断 → 续玩已选不丢失(**候选一致性=停点快照承载的实现现状、非验收义务**——2026-10-01 用户裁决) - [ ] 呈现遵循 HUD框架 可访问性基线(对比 ≥4.5:1、字号下限、纯鼠标可完成;减动效 = 静态化) - [ ] 本屏零钥石元素、零 GP 元素(禁入清单机检随冒烟断言) diff --git a/design/gdd/ux/node-reward.md b/design/gdd/ux/node-reward.md index 5e1351d8..ebf4e114 100644 --- a/design/gdd/ux/node-reward.md +++ b/design/gdd/ux/node-reward.md @@ -13,6 +13,7 @@ - 载体 = 提升三选一覆盖层(BonusPickOverlay,与 [level-clear](level-clear.md) 共用组件——零新增交互原语)。 - 头部行:「关卡奖励点 / 从提升池抽三选一(与通关三选一同池同权重同上限):选择一件带走:」(Narrator.node_bonus_header)。 - 三卡点选(✓ 高亮,确认前可改选)→「确认选择」→ 兑付反馈(选中件效果 + 丢弃提示)→「继续」→ 回图选路(零自动推进同款)。 +- **魔法道具件直显具体道具**(掷定前置,v2.1):候选抽定时即掷定具体道具,卡片显示「魔法道具 / 探巢摆钟 · 真视 3×3」——不再是「随机获得一件」盲选。 - 魔法道具件满位:替换子面板(现有库存件 + 新件 N+1 选 N,丢旧收新 / 放弃新件两路——与通关面同构;丢弃目标 = 关卡域库存件句柄)。 ## 口径 @@ -28,3 +29,4 @@ |---|---|---| | v1.0 | 2026-09-29 | 迭代 7 T5 随批出生(三选一构筑点形态——用户拍板②丙) | | v2.0 | 2026-10-01 | **共享池改写(迭代 10 追加批——用户拍板「奖励节点与通关三选一相同、共享同一个池子」)**:①定位改提升三选一选择面(六种共享池、同权重同次数上限、同 reward_pick 流);②载体改 BonusPickOverlay 共用面(原 node_overlay 道具三选一面退役);③道具件满位升级为替换子交互两路(原过渡形态=满位不授予);④「池空直通」合法态退役(牌库恒非空);⑤道具件入本关库存(即时可用),区别于通关渠道的跨关携带 | +| v2.1 | 2026-10-01 | **掷定前置(目检反馈批——用户反馈「抽到魔法道具不显示详情不直观」,推荐案获批)**:道具件具体道具随候选抽定(reward_pick 流序内挪,消费数不变),卡片直显「魔法道具 / 道具名 · 效果摘要」;断点停点快照随载 aux_detail(续玩卡片详情不丢);node_bonus_candidates 返回扩 dict {candidates, aux_detail} | diff --git a/design/qa/m3-iter10-exit-checklist-2026-10-01.md b/design/qa/m3-iter10-exit-checklist-2026-10-01.md index c79a4a0e..57d02cad 100644 --- a/design/qa/m3-iter10-exit-checklist-2026-10-01.md +++ b/design/qa/m3-iter10-exit-checklist-2026-10-01.md @@ -14,7 +14,7 @@ | 6 | AP 件目检(如抽到) | 兑付后下一场战斗首轮 AP 条 = 9 点(表值 8 +1);后续轮次回 8 | | 7 | GP 袋件目检(如抽到) | 兑付后金币余额即涨 +20;三选一面自身不显 GP(禁入清单) | | 8 | 满血通关 | 回血行显「生命已满——通关回血 0(合法态)」非 0 字样 | -| 9 | 三选一面关游戏 → 主菜单「继续上局」 | 回到三选一面,候选与中断前逐位一致(不重抽);兑付后中断则回兑付反馈态 | +| 9 | 三选一面关游戏 → 主菜单「继续上局」 | 回到三选一面,**已选不丢失**(候选与中断前一致系快照承载的实现现状、非验收义务——2026-10-01 用户裁决降级;复验以「已选不丢」为准) | | 10 | L3(末关)Boss 击杀 | 不出三选一面,直通冒险完成结算;钥石明细列逐杀三笔(首杀 3×3) | | 11 | 营地回城 | 钥石余额 = 本局逐杀掉落合计;营地呈现存量(与进账时刻不同义) | | 12 | 同调位替换子面板 | **实机目检延后(2026-10-01 用户目检裁决:前置=营前装配/同调位未实装,凑满触发条件苛刻不科学)**——逻辑正确性已由单测(断点 P5/冒烟 ⑪⑫)承载,实机全链目检随同调实施批 slots≥2 场景 | @@ -60,10 +60,12 @@ | 项 | 结果 | |---|---| | 1/3/4/5/6(结算框钥石行/三卡点选/兑付反馈/继续推进/AP 件) | **PASS**(用户实测正常) | -| 8(减动效) | 根因=设置面 reduced=true 早前遗留开启(全部演出静态化——用户报告「零动画特效」);已翻回 false,动画复测待第二轮 | +| 8(减动效) | 根因=设置面 reduced=true 早前遗留开启(全部演出静态化——用户报告「零动画特效」);已翻回 false。**2026-10-01 用户终裁:逐行结算演出机制系迭代 8 已落地(排播队列 0.3/0.22s + 弹道/闪白),非本迭代事项不追**——减动效开启态所见「全部一起结算」即该开关定义行为 | | 2/12(同调位替换) | 用户裁决:前置未实装不可科学触发——实机延后同调实施批(第 12 项已改写) | -| 道具件详情 | **新反馈(待拍板)**:三选一道具件卡片只写「随机获得一件」不显示具体道具——修法提案见收口报告(掷定前置:抽签时掷定具体道具、卡片直显名称+效果行),待用户批准后实施 | -| 7/9/10/11/14 | 待第二轮补测 | +| 道具件详情 | **已修复(掷定前置落籍,2026-10-01 推荐案获批)**:候选抽定时即掷定具体道具,卡片直显「魔法道具 / 探巢摆钟 · 真视 3×3」;断点快照随载 aux_detail;node-reward.md v2.1。复验随第二轮目检 | +| 7(金币袋)/10(末关)/11(营地) | **机测已承载、用户免测(2026-10-01 用户裁决)**:⑦ 金币入账=冒烟 ⑬ ledger 断言;末关分支=冒烟 ②(picks 恰 2 + 末关钥石/不发三选一);营地存量=冒烟 settle 块余额断言 | +| 9(中断续玩) | 用户实测正常(候选一致);文档强求已降级为现状注记(冒险结构/level-clear 2026-10-01) | +| 14(奖励节点三选一) | 用户实测正常 | ## 六、合并前待办(用户点名后执行) diff --git a/game/core/adventure_runner.gd b/game/core/adventure_runner.gd index 4f01e819..09bc6b89 100644 --- a/game/core/adventure_runner.gd +++ b/game/core/adventure_runner.gd @@ -80,6 +80,7 @@ var _bonus_pending := {} # 停点态:{"candidates": Array, "p var _bonus_result := {} # 已兑付待推进态(bonus_resolved 载荷副本——中断续玩不重兑付) var _bonus_aux_carry: Array[String] = [] # 道具件跨关携带(兑付后注入下一关库存——清库后授予的体验窗) var _keystone_drops: Array = [] # Boss 逐杀掉落明细(additive 摘要键 keystone_drops——结算明细/复盘归因数据源) +var _last_draw_aux: Array[String] = [] # 最近一次抽签掷定的道具件(掷定前置——卡片直显具体道具;追加批二) var _node_bonus_pending := {} # 奖励节点三选一停点态(迭代 10 追加批——共享池;断点不载:续玩重建到选路停点重走,恢复的计数器保证同候选重抽) @@ -166,6 +167,8 @@ func resume_adventure(bp: Dictionary, params: Dictionary = {}) -> bool: if not bp_pending.is_empty(): _pending_next_level = true _bonus_pending = {"candidates": (bp_pending.get("candidates", []) as Array).duplicate(true)} + if bp_pending.has("aux_detail"): + _bonus_pending["aux_detail"] = (bp_pending.get("aux_detail", []) as Array).duplicate(true) # 掷定前置随停点快照续接 var payload: Dictionary = (bp_pending.get("payload", {}) as Dictionary).duplicate(true) if bp_pending.has("incoming"): _bonus_pending["chosen"] = "aux_item" @@ -277,7 +280,9 @@ func pick_bonus(id: String) -> bool: return true -# 道具件兑付(同调位约束):有位直接入跨关携带;满位转替换子交互(incoming 暂存、等 resolve_aux_replace) +# 道具件兑付(同调位约束):有位直接入跨关携带;满位转替换子交互(incoming 暂存、等 resolve_aux_replace)。 +# 掷定前置(追加批二):具体道具随候选抽定(_last_draw_aux / _bonus_pending.aux_detail)——本函数消费预掷值, +# 断点续玩/无预掷值时回退当场掷定(流序不同但消费数不变,确定性由调用序保证)。 func _pick_aux_item(count: int) -> bool: var pool: Array = _bonus_aux_pool() if pool.is_empty(): @@ -285,8 +290,14 @@ func _pick_aux_item(count: int) -> bool: return false var hp_before := int(ledger.get("hero_hp", 0)) var picked: Array[String] = [] + var detail: Array = (_bonus_pending.get("aux_detail", []) as Array).duplicate() + if detail.is_empty(): + detail = _last_draw_aux.duplicate() for i in maxi(1, count): - picked.append(_roll_bonus_aux_item(pool)) + if i < detail.size(): + picked.append(String(detail[i])) + else: + picked.append(_roll_bonus_aux_item(pool)) var slots := maxi(0, int(db.adventure.transitional_sync_slots)) if _bonus_aux_carry.size() + picked.size() <= slots: for iid in picked: @@ -335,14 +346,16 @@ func _finish_bonus_pick(picked: String, value: int, hp_before: int) -> void: # ---- 奖励节点三选一(迭代 10 追加批——用户拍板:与通关三选一共享同池同权重同次数上限)---- -# 候选呈现(节点到达时调用——恰一 reward_pick scope 取数;本停点断点不载, -# 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现) -func node_bonus_candidates() -> Array: - return _draw_bonus_candidates() +# 候选呈现(节点到达时调用——恰一 reward_pick scope 洗牌 + 道具件掷定 scope;本停点断点不载, +# 续玩重建到选路停点后重走重抽,恢复的全局计数器保证同候选逐位复现)。 +# 掷定前置(追加批二):返回 dict 带 aux_detail(道具件具体道具)供卡片直显。 +func node_bonus_candidates() -> Dictionary: + var cands: Array = _draw_bonus_candidates() + return {"candidates": cands, "aux_detail": _last_draw_aux.duplicate()} -func present_node_bonus(candidates: Array) -> void: - _node_bonus_pending = {"candidates": candidates.duplicate(true)} +func present_node_bonus(candidates: Array, aux_detail: Array = []) -> void: + _node_bonus_pending = {"candidates": candidates.duplicate(true), "aux_detail": aux_detail.duplicate(true)} # 点选兑付(节点域——无部分回血无钥石;件获取计数与牌库剔除与通关三选一同账) @@ -381,15 +394,20 @@ func apply_node_bonus(id: String) -> bool: # 节点渠道道具件(同调位约束):持有=关卡域库存+跨关携带,有位直接入本关库存(当关可用); -# 满位转替换子交互(丢弃目标=库存件 handle——与通关渠道的 carry 序号分轨) +# 满位转替换子交互(丢弃目标=库存件 handle——与通关渠道的 carry 序号分轨)。 +# 掷定前置:消费 _node_bonus_pending.aux_detail 预掷值(缺省回退当场掷定)。 func _apply_node_aux(count: int) -> bool: var pool: Array = _bonus_aux_pool() if pool.is_empty(): push_error("[冒险结构] apply_node_bonus(aux_item) 拒绝——解锁道具池空集(白板/门控态不入牌库的防御兜底)") return false var picked: Array[String] = [] + var detail: Array = (_node_bonus_pending.get("aux_detail", []) as Array).duplicate() for i in maxi(1, count): - picked.append(_roll_bonus_aux_item(pool)) + if i < detail.size(): + picked.append(String(detail[i])) + else: + picked.append(_roll_bonus_aux_item(pool)) var slots := maxi(0, int(db.adventure.transitional_sync_slots)) var records: Array = [] if _runner != null and _runner.aux_inventory != null: @@ -442,9 +460,11 @@ func _finish_node_bonus(picked: String, value: int) -> void: # 加权洗牌不放回掷 3(E-S weight-key:每件 key = U(0,1)^(1/权重) 取前 3——单次零重复、逐件按权重出 # 概率;达次数上限件先剔牌库〔AP 用尽 2 次/属性件达 3 次永不再现;GP/道具件无次数上限,牌库恒非空〕; -# 道具件解锁池空集〔白板/门控态〕不入牌库。消费 reward_pick 封闭流——一次停点恰一 scope,同种子同三候选) +# 道具件解锁池空集〔白板/门控态〕不入牌库。消费 reward_pick 封闭流——一次停点恰一 scope,同种子同三候选; +# 道具件具体道具在本调用内掷定(_last_draw_aux——掷定前置,卡片直显,追加批二)。 func _draw_bonus_candidates() -> Array: var deck: Array = [] + _last_draw_aux = [] for id in db.adventure.BONUS_IDS: var cap := int(db.adventure.bonus_cap(String(id))) if cap != db.adventure.BONUS_CAP_UNLIMITED and int(_bonus_taken.get(String(id), 0)) >= cap: @@ -460,10 +480,20 @@ func _draw_bonus_candidates() -> Array: keyed.sort_custom(func(a, b): return float(a["key"]) > float(b["key"])) var out: Array = [] for i in mini(3, keyed.size()): - out.append(String(keyed[i]["id"])) + var cid := String(keyed[i]["id"]) + out.append(cid) + if cid == "aux_item": + var roll_rg: RandomNumberGenerator = rng.open_stream("reward_pick") + for j in maxi(1, int(db.adventure.bonus_aux_item_count)): + _last_draw_aux.append(_roll_from(roll_rg)) return out +func _roll_from(rg: RandomNumberGenerator) -> String: + var pool: Array = _bonus_aux_pool() + return String(pool[rg.randi_range(0, pool.size() - 1)]) + + # 三选一道具件随机池(与投放门控同源:aux 活行 ∩ 解锁门控集——空集 = 道具件不入牌库) func _bonus_aux_pool() -> Array: var out: Array = [] @@ -587,6 +617,7 @@ func _on_level_finished(payload: Dictionary) -> void: var pick_payload: Dictionary = { "level_index": level_index, "candidates": _draw_bonus_candidates(), + "aux_detail": _last_draw_aux.duplicate(), # 掷定前置(追加批二——道具件具体道具随候选抽定,卡片直显) "heal_gained": heal, "hp_before": hp_before, "hp_after": int(ledger.get("hero_hp", 0)), @@ -596,6 +627,7 @@ func _on_level_finished(payload: Dictionary) -> void: "aux_cleared": int(payload.get("aux_cleared", 0)), # additive 扩签(迭代 7 T4——关卡末清空反馈取数) } _bonus_pending = {"candidates": (pick_payload["candidates"] as Array).duplicate(true), + "aux_detail": _last_draw_aux.duplicate(), "payload": pick_payload.duplicate(true)} bonus_choice_presented.emit(pick_payload) _snapshot_breakpoint() # 选择停点可中断(契约 5——续玩不重抽不静默丢失) diff --git a/game/presentation/battle_narrator.gd b/game/presentation/battle_narrator.gd index 96e94f90..13040dce 100644 --- a/game/presentation/battle_narrator.gd +++ b/game/presentation/battle_narrator.gd @@ -92,8 +92,9 @@ static func bonus_pick_header(payload: Dictionary) -> String: return "\n".join(lines) -# 三选一选项卡文案(件名 + 效果读数;值 = 冒险表行值,组合根喂入) -static func bonus_card_text(id: String, value: int) -> String: +# 三选一选项卡文案(件名 + 效果读数;值 = 冒险表行值,组合根喂入;道具件掷定前置时 +# aux_line = 具体道具「名 · 效果摘要」行——追加批二,空 = 无预掷回退通用文案) +static func bonus_card_text(id: String, value: int, aux_line: String = "") -> String: match id: "attack": return "攻击力提升\n攻击 +%d" % value @@ -104,6 +105,8 @@ static func bonus_card_text(id: String, value: int) -> String: "gp": return "金币袋\n金币 +%d" % value "aux_item": + if aux_line != "": + return "魔法道具\n%s" % aux_line return "魔法道具\n随机获得一件" "ap": return "行动点提升\n每战首轮 AP +%d" % value diff --git a/game/presentation/main.gd b/game/presentation/main.gd index fe77f727..fe07a10a 100644 --- a/game/presentation/main.gd +++ b/game/presentation/main.gd @@ -397,10 +397,11 @@ func _on_runner_node(node: Dictionary, info: Dictionary) -> void: # 奖励节点三选一(迭代 10 追加批——与通关三选一共享同池同权重同上限;BonusPickOverlay 共用面, # 道具件满位替换子交互同构)。原「魔法道具投放渠道」三选一(迭代 7 过渡形态)随批退役。 _bonus_context = "reward_node" - var node_cands: Array = adventure.node_bonus_candidates() + var node_draw: Dictionary = adventure.node_bonus_candidates() + var node_cands: Array = node_draw.get("candidates", []) as Array _open_modal(bonus_overlay) - bonus_overlay.open_pick(Narrator.node_bonus_header(), _bonus_card_texts(node_cands)) - adventure.present_node_bonus(node_cands) + bonus_overlay.open_pick(Narrator.node_bonus_header(), _bonus_card_texts(node_cands, node_draw.get("aux_detail", []) as Array)) + adventure.present_node_bonus(node_cands, node_draw.get("aux_detail", []) as Array) log_view.append_bb(Narrator.reward_line()) elif t == "rest": var healed: int = int(info.get("healed", 0)) @@ -441,20 +442,52 @@ func _on_bonus_presented(payload: Dictionary) -> void: func _show_bonus_pick() -> void: var payload: Dictionary = _pending_bonus _open_modal(bonus_overlay) - bonus_overlay.open_pick(Narrator.bonus_pick_header(payload), _bonus_card_texts(payload.get("candidates", []) as Array)) + bonus_overlay.open_pick(Narrator.bonus_pick_header(payload), _bonus_card_texts(payload.get("candidates", []) as Array, payload.get("aux_detail", []) as Array)) _refresh_presentations() var _last_replace_items: Array = [] # 替换子态续玩重建缓存(aux_replace_presented 载荷) var _last_replace_incoming := {} # 同上——incoming 记录 -func _bonus_card_texts(candidates: Array) -> Array: +func _bonus_card_texts(candidates: Array, aux_detail: Array = []) -> Array: var out: Array = [] + var aux_i := 0 for id in candidates: - out.append({"id": String(id), "text": Narrator.bonus_card_text(String(id), int(db.adventure.bonus_value(String(id))))}) + var cid := String(id) + var text := "" + if cid == "aux_item": + var iid := String(aux_detail[aux_i]) if aux_i < aux_detail.size() else "" + aux_i += 1 + text = Narrator.bonus_card_text(cid, int(db.adventure.bonus_value(cid)), _aux_card_line(iid)) + else: + text = Narrator.bonus_card_text(cid, int(db.adventure.bonus_value(cid))) + out.append({"id": cid, "text": text}) return out +# 道具件卡片行(掷定前置——具体道具名 + 效果摘要;空 iid = 无预掷回退态显示通用文案) +func _aux_card_line(iid: String) -> String: + if iid == "": + return "" + var row: Dictionary = db.aux_item_row(iid) + if row.is_empty(): + return "" + var parts: Array[String] = [] + for k in (row.get("effect_kinds", []) as Array): + if String(k) == "mine_reveal": + parts.append("真视") + elif String(k) == "reward_preview": + parts.append("预读") + elif String(k) == "ap_grant": + parts.append("AP +%d" % int(row.get("magnitude", 0))) + var tgt := String(row.get("target", "none")) + if tgt == "area_3x3": + parts.append("3×3") + elif tgt == "board_wide": + parts.append("全图") + return "%s · %s" % [String(row.get("display_name", iid)), " ".join(parts)] + + func _on_bonus_pick_confirmed(item_id: String) -> void: if adventure == null: return diff --git a/game/smoke/fx_shot_probe.gd b/game/smoke/fx_shot_probe.gd new file mode 100644 index 00000000..b9ce1ef9 --- /dev/null +++ b/game/smoke/fx_shot_probe.gd @@ -0,0 +1,131 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 演出渲染证据探针(渲染层测试解冻批 2026-10-01——fx_shot_probe:真实渲染设备抓战斗演出瞬间帧)。 +# 必须**窗口化**运行(真渲染设备——headless 无渲染面):Godot_console.exe --path . res://smoke/fx_shot_probe.tscn -- <输出目录> +# 流程 = shot_probe 同款隔离面(存档/设置/元进度换注隔离档,禁碰真实档)+ 真实按钮路径开冒险进首战; +# 自动玩家挖格驱动行结算,在 行到达/弹道飞行/结算排播 三时刻抓帧——演出链「逻辑在(fx_probe PASS)× +# 渲染在(本探针 PNG)」双证的第二半边。PNG 落输出目录(artwork/qa-fx-*/,素材纪律不入 git)。 +extends Node + +var main +var out_dir := "" +var _problems: Array = [] + + +func _ready() -> void: + var args := OS.get_cmdline_user_args() + out_dir = args[0] if args.size() >= 1 and args[0] != "" else "user://qa-fx" + DirAccess.make_dir_recursive_absolute(out_dir) + await get_tree().process_frame + main = load("res://presentation/main.tscn").instantiate() + add_child(main) + await get_tree().process_frame + for p0 in ["user://fx_shot_probe.json", "user://fx_shot_probe.json.tmp", "user://fx_shot_probe_settings.cfg"]: + if FileAccess.file_exists(p0): + DirAccess.remove_absolute(p0) + var iso_c = load("res://core/save_manager.gd").new("user://fx_shot_probe.json") + iso_c.load_from_disk() + main.save_manager = iso_c + main.meta = load("res://core/meta_progression.gd").new(main.db, iso_c) + main.settings_store = load("res://presentation/settings_store.gd").new("user://fx_shot_probe_settings.cfg") + for i in 8: + await get_tree().process_frame # UI 装配完成等足帧(canvas_item 空引用窗口) + var lvbtn: Button = _find_button_by_sub(main._start_overlay, "开始冒险") + if lvbtn == null: + _fail("开始冒险按钮缺失") + return + lvbtn.pressed.emit() + # 等关卡画面出图(choices_presented 异步到达——最多 3s 轮询) + for i in 60: + await get_tree().process_frame + if main.level_screen != null and main.level_screen.visible: + break + if main.level_screen == null or not main.level_screen.visible: + _fail("关卡画面未出现(lvbtn 路径)") + return + for i in 5: + await get_tree().process_frame + main.level_screen.press_candidate(0) + for i in 20: + await get_tree().process_frame + var st = main.settlement + if st == null: + _fail("首战未开(settlement 空)") + return + var rules = main.settlement.minesweeper + var board = rules.current_board() + # 自有驱动(只挖/走——测试驱动可用板面数据挑安全格;新手策略档的插旗拒绝会耗尽预算):挖到 3 格停 + var dug := 0 + var guard := 40 + while dug < 3 and guard > 0: + guard -= 1 + var target = null + for n in board.neighbors8(rules.hero_pos()): + if not rules.is_revealed(n) and not board.is_mine(n): + target = n + break + if target != null: + var out: Array = rules.settle_batch([{"action": "dig", "cell": target}]) + if out.is_empty() or not bool(out[0].get("accepted", false)): + continue + dug += 1 + await get_tree().create_timer(0.12).timeout + await _snap("fx_dig%d" % dug) # 行到达/弹道在飞窗口 + await get_tree().create_timer(0.35).timeout + await _snap("fx_settle%d" % dug) # 结算排播窗口 + continue + # 无相邻可挖 → 走向有未开安全格邻居的已揭开格(挖掘期移动免费) + var dest = null + for c in rules.revealed_cells(): + if c == rules.hero_pos() or rules.compute_move_path(c).is_empty(): + continue + for n in board.neighbors8(c): + if not rules.is_revealed(n) and not board.is_mine(n): + dest = c + break + if dest != null: + break + if dest == null: + break + rules.settle_batch([{"action": "move", "cell": dest}]) + # 停手 → 轮末结算(敌方攻击弹道/受击闪白/结算排播在 0.2-1.6s 窗口流过)——连抓 6 帧 + rules.settle_batch([{"action": "stop"}]) + for i in 6: + await get_tree().create_timer(0.25).timeout + await _snap("fx_round%d" % (i + 1)) + if dug == 0: + _fail("无安全格可挖(首板全雷不可信)") + return + await get_tree().create_timer(0.6).timeout + await _snap("fx_final") + if _problems.is_empty(): + print("[fx_shot_probe] PASS — 演出帧 %d 张落 %s(挖格 %d)" % [dug * 2 + 1, out_dir, dug]) + get_tree().quit(0) + else: + for p in _problems: + push_error("[fx_shot_probe] FAIL — " + p) + get_tree().quit(1) + + +func _snap(tag: String) -> void: + await RenderingServer.frame_post_draw + var img: Image = get_viewport().get_texture().get_image() + if img == null or img.is_empty(): + _problems.append("抓帧空纹理: " + tag) + return + img.save_png("%s/%s.png" % [out_dir, tag]) + + +func _find_button_by_sub(root: Node, needle: String) -> Button: + if root is Button and String(root.text).contains(needle): + return root + for c in root.get_children(): + var r := _find_button_by_sub(c, needle) + if r != null: + return r + return null + + +func _fail(msg: String) -> void: + _problems.append(msg) + push_error("[fx_shot_probe] FAIL — " + msg) + get_tree().quit(1) diff --git a/game/smoke/fx_shot_probe.tscn b/game/smoke/fx_shot_probe.tscn new file mode 100644 index 00000000..caf38f16 --- /dev/null +++ b/game/smoke/fx_shot_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://smoke/fx_shot_probe.gd" id="1"] + +[node name="FxShotProbe" type="Node"] +script = ExtResource("1") diff --git a/game/smoke/level_autoplay.gd b/game/smoke/level_autoplay.gd index a0654b19..d4cc9fac 100644 --- a/game/smoke/level_autoplay.gd +++ b/game/smoke/level_autoplay.gd @@ -174,12 +174,13 @@ static func _on_adventure_node_entered(node: Dictionary, info: Dictionary, adr, elif t == "reward": # 奖励节点三选一(迭代 10 追加批——共享池确定性策略:候选取首件、替换放弃新件; # 兑付后 continue_after_node 回图选路) - var ncands: Array = adr.node_bonus_candidates() + var ndraw: Dictionary = adr.node_bonus_candidates() + var ncands: Array = ndraw.get("candidates", []) as Array st.reward_events.append({"node_bonus_pick": ncands.duplicate(true)}) if ncands.is_empty(): r.continue_after_node() # 牌库空合法态直通(结构上不可达——gp/道具件无上限,防御保留) else: - adr.present_node_bonus(ncands) + adr.present_node_bonus(ncands, ndraw.get("aux_detail", []) as Array) if adr.apply_node_bonus(String(ncands[0])): if not adr._node_bonus_pending.is_empty() and adr._node_bonus_pending.has("incoming"): adr.resolve_bonus_replace(-1) # 满位替换:放弃新件 diff --git a/game/smoke/smoke_all.gd b/game/smoke/smoke_all.gd index 17d0ef00..74ac60d7 100644 --- a/game/smoke/smoke_all.gd +++ b/game/smoke/smoke_all.gd @@ -551,9 +551,14 @@ func _smoke_adventure() -> void: check(a13.start_adventure(13, {"first_script": true, "aux_unlocked": {"blast_kit": true}}), "节点共享池场景:冒险启动(seed 13)") a13._bonus_taken["attack"] = int(_db.adventure.bonus_cap_attack) - var ndraw: Array = a13.node_bonus_candidates() - check(not (ndraw as Array).has("attack"), - "奖励节点共享池:达上限件同样剔牌库(同 _draw 同上限——%s)" % str(ndraw)) + var ndraw: Dictionary = a13.node_bonus_candidates() + var ncands13: Array = ndraw.get("candidates", []) as Array + check(not ncands13.has("attack"), + "奖励节点共享池:达上限件同样剔牌库(同 _draw 同上限——%s)" % str(ncands13)) + if ncands13.has("aux_item"): + check(not (ndraw.get("aux_detail", []) as Array).is_empty(), + "掷定前置:道具件候选自带具体道具(aux_detail 随抽签掷定——卡片直显数据源)") + a13.present_node_bonus(ncands13, ndraw.get("aux_detail", []) as Array) a13.present_node_bonus(["gp"]) var gp13 := int(a13.ledger.get("gp", 0)) check(a13.apply_node_bonus("gp") and int(a13.ledger.get("gp", 0)) == gp13 + int(_db.adventure.bonus_gp_amount), @@ -590,10 +595,13 @@ func _adv_drive_to_shop(a, st: Dictionary, autoplay) -> bool: var t := String(r.current_node().get("type", "")) if t == "reward": # 奖励节点三选一(迭代 10 追加批——候选取首件、替换放弃新件、兑付确认回图) - var ncands: Array = a.node_bonus_candidates() - if not ncands.is_empty() and a.apply_node_bonus(String(ncands[0])): - if not a._node_bonus_pending.is_empty() and a._node_bonus_pending.has("incoming"): - a.resolve_bonus_replace(-1) + var ndraw: Dictionary = a.node_bonus_candidates() + var ncands: Array = ndraw.get("candidates", []) as Array + if not ncands.is_empty(): + a.present_node_bonus(ncands, ndraw.get("aux_detail", []) as Array) + if a.apply_node_bonus(String(ncands[0])): + if not a._node_bonus_pending.is_empty() and a._node_bonus_pending.has("incoming"): + a.resolve_bonus_replace(-1) r.continue_after_node() elif t == "rest": r.continue_after_node()