Files
gplan-2/game/presentation/curio_interact_controller.gd
T
DKChan 059b545068 fix-curio-skip: 三选一空池降级(用户令 2026-10-06)——候选 0 件跳过环节不再死面卡死
- core adventure_runner.skip_empty_curio():停点空候选消化出口——仅空候选放行,非空拒绝(零自动推进契约不破,正常消化仍只走 pick_curio/apply_node_curio 兑付)
- controller resume_if_pending():空候选不开选择面(空面无卡无钮=死面卡死成因)——消化停点后按上下文直落后续推进(level_clear=推进下一关/reward_node=回图选路/feat_ticket=关面回店,与 on_continue 分派同构)
- controller present_node_curio():奖励节点空池不开面不挂停点,兑付确认直落回图选路(auto-sim node_entered 内立即 continue_after_node 先例)
- controller on_presented() feat_ticket 分支末道防御(core _redeem_feat_ticket 空集守卫外)
- 候选 1-2 件少显路径既有零改动:_draw_curio_candidates 洗牌收集至池空为止(上限 curio_candidate_count=3),卡片渲染按候选数遍历——本就是「有多少显多少」
2026-10-06 04:37:22 +08:00

297 lines
15 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# 提升三选一与替换子交互控制器(迭代 13 T4/D4——二期家族① 自 main.gd 迁出;规范 1.1 单一职责):
# 三选一停点编排(presented→pick→confirm/refresh→resolved→continue)+ 同调位替换子态
#(presented→chosen)+ TP0 令牌池卡片文案装配("kind|tier|feat_id" 复合键)+ 工坊显示名投影。
# 装配纪律(ADR-0001,仿 treasure_interact_controller 先例):信号连接不在此发生——curio_overlay
# 四信号与 adventure 两信号由组合根接至本类 on_* 入口;模态栈/演播后排窗/呈现刷新/终局面/
# 推进失败 fatal 经组合根回调注入;adventure/runner 随局更替——现值回调注入;兑付写调用唯一
# 经 adventure.pick_curio/apply_node_curio/resolve_curio_replace(core 侧单口,本类零直写)。
extends RefCounted
const Narrator := preload("res://presentation/battle_narrator.gd")
var db
var _meta_get: Callable # () → 元进度账本现值(组合根 lambda——探针 isolate_save 换注面,迭代13 T4 实证)
var curio_overlay: CurioPickOverlay
var end_overlay: BattleEndOverlay
var board_view: BoardView
var toast: ToastStack
var _save_get: Callable # () → 存档承载现值(同上——Attunement.slots 读数面)
var _adventure_get: Callable # () → 冒险结构现值(组合根 lambda——随局更替)
var _runner_get: Callable # () → 关卡推进器现值(组合根 lambda——reward_node 继续推进)
var _adventure_mode_get: Callable # () → 冒险模式标记现值(组合根 lambda)
var _open_modal: Callable # 模态栈单口(组合根)
var _open_modal_after_playback: Callable # 演播后排窗单口(T17——组合根)
var _close_modal: Callable
var _refresh_presentations: Callable
var _fatal_screen: Callable
var _gp_summary: Callable # () → 战斗总结 GP 行文案(组合根 lambda——_last_gain_amount 取数面在根)
var _pending_curio := {} # 三选一停点载荷暂存(迭代 10——战斗总结结算确认后开三选一面;续玩停点复用)
var _curio_context := "level_clear" # 三选一面上下文(level_clear=通关停点→推进下一关 / reward_node=奖励节点→回图选路)
var _last_replace_items: Array = [] # 替换子态续玩重建缓存(treasure_replace_presented 载荷)
var _last_replace_incoming := {} # 同上——incoming 记录
func _init(db_ref, meta_get: Callable, curio_overlay_ref: CurioPickOverlay, end_overlay_ref: BattleEndOverlay,
board_view_ref: BoardView, toast_ref: ToastStack, save_get: Callable,
adventure_get: Callable, runner_get: Callable, adventure_mode_get: Callable,
open_modal: Callable, open_modal_after_playback: Callable, close_modal: Callable,
refresh_presentations: Callable, fatal_screen: Callable, gp_summary: Callable) -> void:
db = db_ref
_meta_get = meta_get
curio_overlay = curio_overlay_ref
end_overlay = end_overlay_ref
board_view = board_view_ref
toast = toast_ref
_save_get = save_get
_adventure_get = adventure_get
_runner_get = runner_get
_adventure_mode_get = adventure_mode_get
_open_modal = open_modal
_open_modal_after_playback = open_modal_after_playback
_close_modal = close_modal
_refresh_presentations = refresh_presentations
_fatal_screen = fatal_screen
_gp_summary = gp_summary
# 停点在途判据(组合根 _on_battle_end_continue 分派面——战斗总结确认后开三选一)
func has_pending() -> bool:
return not _pending_curio.is_empty()
# 续玩停点恢复入口(true=已接手呈现,调用方中止后续分派)。空候选降级(2026-10-06 用户令):
# 候选 0 件不开选择面(空面无卡无钮=死面)——消化停点后按上下文直落后续推进(战斗总结面已
# 给过玩家确认拍,三选一环节整体跳过)
func resume_if_pending() -> bool:
if _pending_curio.is_empty():
return false
if (_pending_curio.get("candidates", []) as Array).is_empty():
return _skip_empty_stop()
show_pick()
return true
# 停点空候选跳过(2026-10-06 用户令——「选出来 0 个则直接跳过该环节」):分派与 on_continue
# 同构(level_clear=推进下一关 / reward_node=兑付确认回图选路 / feat_ticket=只关面回店),
# 差异仅零面可收——curio_overlay 未开不 close。core 停点经 skip_empty_curio 消化后推进
#(continue_to_next_level 的「停点未兑付拒推进」守卫因此放行)。
func _skip_empty_stop() -> bool:
toast.show_toast("可用战利品为空——本次三选一跳过", "info")
var adventure = _adventure_get.call()
if adventure != null:
adventure.skip_empty_curio() # core 停点消化(其守卫=仅空候选放行)
if _curio_context == "reward_node":
var runner = _runner_get.call()
if runner != null:
runner.continue_after_node()
elif _curio_context == "level_clear" and bool(_adventure_mode_get.call()):
if not adventure.continue_to_next_level():
_fatal_screen.call("关卡推进失败——见输出日志")
# feat_ticket = 只消化停点回店(零推进——与 on_continue 同构)
_pending_curio = {}
return true
# 三选一停点(非末关通关恰一次):Boss 胜利先经战斗总结结算面(settlement-summary v1.1——钥石
# 增量行在其上),确认后进三选一面(level-clear.md v2);续玩停点恢复同入口(零自动推进契约)
func on_presented(payload: Dictionary) -> void:
if String(payload.get("context", "")) == "feat_ticket":
# T5 店内门票兑付——临时停点开专长三选一面(零胜利总结面;确认后只关面回店)
_pending_curio = payload.duplicate(true)
_curio_context = "feat_ticket"
if (_pending_curio.get("candidates", []) as Array).is_empty():
# 空候选防御(2026-10-06 用户令——零卡不开死面;core _redeem_feat_ticket 守卫外的末道)
_skip_empty_stop()
return
_open_modal.call(curio_overlay)
curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(payload.get("candidates", []) as Array))
return
if int(payload.get("aux_cleared", 0)) > 0:
toast.show_toast("上关剩余 %d 件秘宝已随关卡结束清空" % int(payload.get("aux_cleared", 0)), "info") # 迭代 7 T4 清空反馈
_pending_curio = payload.duplicate(true)
_curio_context = "level_clear"
if bool(payload.get("replace_pending", false)):
# 续玩:替换子交互在途中断——重建三选一面后直落替换子态(候选不重抽)
show_pick()
return
end_overlay.open_victory(Narrator.battle_end_text("victory", false), "继续",
board_view.get_global_rect(),
"%s\n%s" % [String(_gp_summary.call()), Narrator.bonus_keystone_line(payload)])
_open_modal_after_playback.call(end_overlay)
_refresh_presentations.call()
# 三选一面开出(战斗总结结算确认 / 续玩停点恢复;替换在途由 treasure_replace_presented 续发直落子态)
# 奖励节点三选一(迭代 10 追加批——与通关三选一共享同池同权重同上限;CurioPickOverlay 共用面,
# 道具件满位替换子交互同构)。原「秘宝投放渠道」三选一(迭代 7 过渡形态)随批退役。
func present_node_curio() -> void:
var adventure = _adventure_get.call()
_curio_context = "reward_node"
var node_draw: Dictionary = adventure.node_curio_candidates()
var node_cands: Array = node_draw.get("candidates", []) as Array
if node_cands.is_empty():
# 空池降级(2026-10-06 用户令)——不开面、不挂节点停点(present_node_curio 未调即未挂),
# 兑付确认直落回图选路(auto-sim「node_entered 回调内立即 continue_after_node」先例)
toast.show_toast("可用战利品为空——本次三选一跳过", "info")
var runner = _runner_get.call()
if runner != null:
runner.continue_after_node()
return
_open_modal.call(curio_overlay)
curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(node_cands, node_draw.get("aux_detail", []) as Array))
adventure.present_node_curio(node_cands, node_draw.get("aux_detail", []) as Array)
curio_overlay.set_refresh_info(adventure.attune_refresh_left()) # 迭代 11 第①批 T5b——刷新余量投喂
# 三选一面开出(战斗总结结算确认 / 续玩停点恢复;替换在途由 treasure_replace_presented 续发直落子态)
func show_pick() -> void:
var payload: Dictionary = _pending_curio
_open_modal.call(curio_overlay)
curio_overlay.open_pick(Narrator.curio_pick_header(payload), _curio_card_texts(payload.get("candidates", []) as Array, payload.get("aux_detail", []) as Array))
var adventure = _adventure_get.call()
curio_overlay.set_refresh_info(adventure.attune_refresh_left() if adventure != null else 0) # 迭代 11 第①批 T5b——刷新余量投喂
_refresh_presentations.call()
func _curio_card_texts(candidates: Array, treasure_detail: Array = []) -> Array:
# TP0 令牌池——候选 = {"kind","tier"} 令牌字典;卡片 id = "kind|tier|feat_id" 复合键(同 kind
# 大小两档/同档多 relic 可同屏,kind 单键不构成唯一身份),确认回程拆键(on_pick_confirmed)
var out: Array = []
var treasure_i := 0
for tok in candidates:
var kind := String((tok as Dictionary).get("kind", ""))
var tier := String((tok as Dictionary).get("tier", ""))
var rid := String((tok as Dictionary).get("feat_id", ""))
var cid := "%s|%s|%s" % [kind, tier, rid]
var text := ""
if kind == "feat_ticket":
text = "奇物门票
选定即开一次奇物三选一(T5 门票——拿到即开不进持有集)"
elif kind == "relic":
text = Narrator.feat_card_text(db.feat_row(rid))
elif kind == "treasure_item":
var iid := String(treasure_detail[treasure_i]) if treasure_i < treasure_detail.size() else ""
treasure_i += 1
text = Narrator.bonus_card_text(kind, tier, int(db.adventure.curio_value(kind, tier)), _treasure_card_line(iid))
else:
text = Narrator.bonus_card_text(kind, tier, int(db.adventure.curio_value(kind, tier)))
out.append({"id": cid, "text": text})
return out
# 道具件卡片行(掷定前置——具体道具名 + 效果摘要;空 iid = 无预掷回退态显示通用文案)
func _treasure_card_line(iid: String) -> String:
if iid == "":
return ""
var row: Dictionary = db.treasure_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)]
# 三选一刷新(迭代 11 第①批 T5b——P1 甲 X/Y 双源;通关停点经重发 curio_choice_presented 自然重建,
# 节点停点在此读在途态重 present——零自动推进契约不破,停点仍在途)
func on_refresh() -> void:
var adventure = _adventure_get.call()
if adventure == null:
return
if not adventure.refresh_curio():
return
if _curio_context == "reward_node":
var st: Dictionary = adventure.node_curio_pending_state()
curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(st.get("candidates", []) as Array, st.get("aux_detail", []) as Array))
curio_overlay.set_refresh_info(adventure.attune_refresh_left())
func on_pick_confirmed(item_id: String) -> void:
var adventure = _adventure_get.call()
if adventure == null:
return
# TP0 令牌池——卡片 id "kind|tier|feat_id" 拆键(缺 | = 旧载荷防御归普通档;relic 三段)
var parts := item_id.split("|")
var kind := parts[0]
var tier := parts[1] if parts.size() > 1 else ""
var rid := parts[2] if parts.size() > 2 else ""
var ok: bool = adventure.apply_node_curio(kind, tier, rid) if _curio_context == "reward_node" else adventure.pick_curio(kind, tier, rid)
if not ok:
push_error("[bonus_interact] 三选一兑付被拒——见输出日志")
return
# T5——节点渠道门票拿到即开:候选已被替换为专长面,本地读在途态重绘(零 emit——context 保持 reward_node,
# 后续兑付/确认仍走节点路径回图选路)
if _curio_context == "reward_node" and kind == "feat_ticket":
var st2: Dictionary = adventure.node_curio_pending_state()
curio_overlay.open_pick(Narrator.node_curio_header(), _curio_card_texts(st2.get("candidates", []) as Array))
func on_treasure_replace_presented(items: Array, incoming: Dictionary) -> void:
_last_replace_items = items.duplicate(true)
_last_replace_incoming = incoming.duplicate(true)
if not curio_overlay.visible:
_open_modal.call(curio_overlay) # 续玩替换在途直落——三选一面未开时先入栈(模态栈纪律)
_open_replace(items, incoming)
func _open_replace(items: Array, incoming: Dictionary) -> void:
var named: Array = []
for it in items:
var rec: Dictionary = it.duplicate()
rec["display_name"] = String(item_name_lookup().get(String(rec.get("item_id", "")), String(rec.get("item_id", ""))))
named.append(rec)
curio_overlay.open_replace(named, String(item_name_lookup().get(String(incoming.get("item_id", "")), String(incoming.get("item_id", "")))),
named.size(), Attunement.slots(db, _save_get.call())) # 迭代 11 第①批 T7——呈现读数源切谓词表求值(过渡键退役)
func on_replace_chosen(discard: int) -> void:
var adventure = _adventure_get.call()
if adventure == null or not adventure.resolve_curio_replace(discard):
push_error("[bonus_interact] 同调位替换受理被拒——见输出日志")
# 兑付反馈(curio_resolved 到达——结果行上屏,唯一出口 = 继续)
func on_resolved(result: Dictionary) -> void:
_pending_curio = {}
curio_overlay.show_result(Narrator.bonus_result_text(result))
_refresh_presentations.call()
# 继续钮:收三选一面 → 上下文分派(通关停点=推进下一关 / 奖励节点=兑付确认回图选路——零自动推进)
func on_continue() -> void:
_close_modal.call(curio_overlay)
curio_overlay.close_pick()
var adventure = _adventure_get.call()
if _curio_context == "feat_ticket":
return # T5 店内门票面——专长兑付完成后关面回店(无推进/无回图)
if _curio_context == "reward_node":
var runner = _runner_get.call()
if runner != null:
runner.continue_after_node()
return
if bool(_adventure_mode_get.call()) and adventure != null:
if not adventure.continue_to_next_level():
_fatal_screen.call("关卡推进失败——见输出日志")
# 工坊 item_id → 显示名投影(keystone_settle_text/替换子命名文案数据源——拼句归 Narrator)
func item_name_lookup() -> Dictionary:
var lookup := {}
var meta = _meta_get.call()
if meta != null:
for row in db.meta_workshop_items():
lookup[String(row.get("item_id", ""))] = String(row.get("display_name", ""))
return lookup