814 lines
36 KiB
GDScript
814 lines
36 KiB
GDScript
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||
## 关卡域生命周期编排者:节点图行进、HP 跨战斗携带、恢复点兑付、关卡末收束(垂直切片验证:一关内数值提升可感知);商店族归 ShopSession 件。
|
||
## Core 层零 UI 依赖(headless 可跑完整关卡);零自动推进——兑付确认后经 choices_presented,推进只走 pick_next_node。
|
||
## 种子域:单关模式 start_level 自播种;冒险模式 start_level_external 不重播种(主种子恰一次归 AdventureRunner)。
|
||
## 调试浮窗百分比通道单点归主角账(set_hero_override 转调——公式消费面同源)。
|
||
## 信号顺序契约:呈现层须在 level_started 回调重取 runner 实例,choices_presented 才不会被空防御丢弃。
|
||
## 关卡开始信号前零发射:本件全部信号在 level_started 发射前保持静默(构造与 start 收参零信号——
|
||
## defer_entry 两步式以 level_started 为界,先广播后呈现入口候选;配断言面见冒烟源码断言〔拆件批 V5〕)。
|
||
class_name LevelRunner
|
||
extends RefCounted
|
||
|
||
signal node_entered(node: Dictionary, info: Dictionary) ## info 按 type:battle/boss={} · reward={}(三选一停点空载荷——内容经 present_node_curio 拉取) · rest={healed,hp,max_hp} · shop={gp}
|
||
signal choices_presented(candidates: Array) ## 选路口(兑付确认后——候选节点引用数组,零自动推进)
|
||
signal step_row(row: Dictionary) ## 转发当前 战斗结算 结算行
|
||
signal round_started(round_index: int)
|
||
signal board_changed()
|
||
signal level_finished(payload: Dictionary) ## {result, hp, node_index, node_id, node_type}
|
||
signal wallet_changed(event: String, gp: int, extra: Dictionary) ## GP 钱包事件投影:gain{amount}/buy{product,price}/clear——试玩日志与商店覆盖层刷新源(GP+商店房实施批)
|
||
|
||
var db: ConfigDb
|
||
var rng: RngServiceCore
|
||
var level_row: Dictionary[String, Variant] = {}
|
||
var node_index := -1
|
||
# ---- 账本载体(冒险结构实现批——漂移②清偿)----
|
||
# 冒险模式 = AdventureRunner 共享字典(Dictionary 引用共享——hero_hp/gp 属性读写直接落冒险账本);
|
||
# 单关模式 = 本实例私有账本(start_level 重建,既有单关语义逐位保留)。
|
||
var level_counters: Dictionary[Variant, Variant] = {} # 关卡桶(lvl_ 前缀计数器;每关随 LevelRunner new 天然空=按关清零,替代旧硬编码清除名单)
|
||
var _rest_handler: Callable # 治疗封顶回报执行体(默认内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest——接口③)
|
||
var _external := false # 冒险模式标记(钱包收束时机与播种语义分叉点)
|
||
# 主角账引用(拆件批 T1 过渡面:冒险模式经 set_hero 注入共享实体账、单关模式 _init 自建私有账;
|
||
# 构造注入收口归 T3 ctx 面——后置 setter 随构造注入一并退役)
|
||
var _hero: HeroLedger = HeroLedger.new(null, {}) # 直构缺省私有账(db 随 _init 补)
|
||
var _adv_bucket: Dictionary[Variant, Variant] = {} # 冒险桶(效果引擎 adv_ 计数器宿主字典——冒险模式随 hero 注入;单关=本实例自有)
|
||
# hero_hp / gp 属性化(既有读写点零改动——值类型经属性落主角账)
|
||
var hero_hp: int:
|
||
get: return _hero.hero_hp
|
||
set(v): _hero.hero_hp = v
|
||
var gp: int: # GP 冒险钱包(局内货币——冒险开始 = 0、收束清零;不入存档不入摘要)
|
||
get: return _hero.gp
|
||
set(v): _hero.gp = v
|
||
var _shop: ShopSession = null # 商店件(_init 建;双模式货架/定价/购买/刷新唯一宿主)
|
||
# 协作对象私有(E1/E3 封装收口):settlement 读取经 current_battle() 成文出口
|
||
var _settlement: BattleOrchestrator = null # 当前战斗编排器(关卡结束置 null)
|
||
var treasure_inventory: TreasureItems = null # 秘宝 关卡域库存(TreasureItems;跨战斗延续,
|
||
var _treasure_unlocked := {} # aux 解锁门控集(空集=零投放)
|
||
var _attunement: Array[Variant] = [] # 同调装配集(adventure params.attunement 下传,begin_battle enc 携带进战斗结算;空集=off 零扰动)
|
||
# 关卡末清空+快照计数入 level_finished 载荷)
|
||
# 两根类型轴(adventure 参数面快照经 set_hero_axes 下传;空串=未注入→锻造配置默认值回退。
|
||
# 消费面:effective_ac 甲型基础 AC 层 + begin_battle enc 携带进 unit_container.spawn 英雄快照)
|
||
var _hero_attack_type := ""
|
||
var _hero_armor_type := ""
|
||
# ---- 商店共享池模式态(缺省 legacy=固定三件加定价 off 对照承载,零 RNG 零池消费;TP0 令牌池)----
|
||
# 件内状态白盒读面(断点写侧与既有取数面沿用原字段名;状态本体单点在 ShopSession)
|
||
var _shop_pool_mode: bool:
|
||
get: return _shop.pool_mode
|
||
var _pool_deck: Array[Variant]:
|
||
get: return _shop.pool_deck
|
||
var _pool_consume: Callable:
|
||
get: return _shop.pool_consume
|
||
var _pool_ticket: Callable:
|
||
get: return _shop.pool_ticket
|
||
var _shop_shelf: Array[Variant]:
|
||
get: return _shop.shelf
|
||
var _shop_visit_buys: int:
|
||
get: return _shop.visit_buys
|
||
var _shop_paid_refreshes: int:
|
||
get: return _shop.paid_refreshes
|
||
|
||
# ---- 关卡相位轴(显式 Phase enum:内部 enum + 守卫换算,对齐 行动经济 Phase 先例;
|
||
# level_result:String 与 awaiting_choice() 是 AdventureRunner/呈现层消费的外部契约面)----
|
||
enum Phase { IDLE, RUNNING, AWAITING_CHOICE, ENDED }
|
||
var _phase: int = Phase.IDLE
|
||
var level_result := "" # "" / "victory" / "defeat"(外部契约面)
|
||
var _candidates: Array[Dictionary] = [] # 当前选路候选(节点引用数组)
|
||
var visited: Array[Dictionary] = [] # 已兑付/到达节点序列(呈现层图条数据源;含当前节点)
|
||
var _generator # 参数化长图生成器(惰性建——NodeMapGenerator 持注入的 RngService)
|
||
|
||
|
||
# 构造注入一次给齐(拆件批 T3——后置 setter 群与商店重绑机制退役;_bind_shop 全仓仅本处构造调用)。
|
||
# ctx 键(冒险模式由 AdventureRunner._start_current_level 组包;缺省空字典 = 单关测试模式:
|
||
# 私有主角账自建〔空 override〕、充能账瘦壳自建、冒险桶本实例自有、pool 模式零注入):
|
||
# hero:HeroLedger(共享主角账——活引用)/ledger:RunLedger(充能账瘦壳)/adv_bucket:Dictionary(冒险桶宿主字典)
|
||
# attunement:Array(同调装配集——活引用)/tree_rules:Array(树 rule 键集——活引用)/shop_mode:String("pool" 才注入池面)
|
||
# treasure_unlocked:Dictionary/hero_attack_type:String/hero_armor_type:String
|
||
# voucher_rap:int/voucher_time_sand:int(凭证出箱注入面)
|
||
# pool_wiring:Dictionary(池模式装配 4 参:{deck:Array, consume:Callable, ticket:Callable, cap:Callable}——缺省空字典 = legacy 零注入)
|
||
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore, ctx: Dictionary = {}) -> void:
|
||
db = db_ref
|
||
rng = rng_ref
|
||
if ctx.get("hero", null) is HeroLedger:
|
||
_hero = ctx["hero"]
|
||
_feats = _hero.feats # 特性集活引用注入(关内兑付 relic 当关生效——契约 6)
|
||
else:
|
||
_hero.db = db # 直构私有账补表引用(单关模式——实例不重建,值重置走 start_level)
|
||
if ctx.get("ledger", null) is RunLedger:
|
||
run_ledger = ctx["ledger"]
|
||
if ctx.get("adv_bucket", null) is Dictionary:
|
||
_adv_bucket = ctx["adv_bucket"]
|
||
_treasure_unlocked = ctx.get("treasure_unlocked", {}) if ctx.get("treasure_unlocked", {}) is Dictionary else {}
|
||
_attunement = ctx.get("attunement", []) if ctx.get("attunement", []) is Array else [] # 活引用注入(契约 6——禁快照)
|
||
_tree_rules = ctx.get("tree_rules", []) if ctx.get("tree_rules", []) is Array else [] # 活引用注入
|
||
_hero_attack_type = String(ctx.get("hero_attack_type", ""))
|
||
_hero_armor_type = String(ctx.get("hero_armor_type", ""))
|
||
_voucher_rap = maxi(0, int(ctx.get("voucher_rap", 0)))
|
||
_voucher_time_sand = maxi(0, int(ctx.get("voucher_time_sand", 0)))
|
||
_shop = ShopSession.new(db, rng)
|
||
var pool: Dictionary[Variant, Variant] = ctx.get("pool_wiring", {}) as Dictionary
|
||
if String(ctx.get("shop_mode", "")) == "pool" and not pool.is_empty():
|
||
_shop.set_shop_pool(pool.get("deck", []) as Array, pool.get("consume", Callable()) as Callable,
|
||
pool.get("ticket", Callable()) as Callable, pool.get("cap", Callable()) as Callable)
|
||
_bind_shop()
|
||
|
||
|
||
# 进行中谓词(RUNNING 或 AWAITING_CHOICE 两相位合计;内部守卫专用)
|
||
func _is_running() -> bool:
|
||
return _phase == Phase.RUNNING or _phase == Phase.AWAITING_CHOICE
|
||
|
||
|
||
# ---- 测试面板覆盖(模拟天赋树属性提升——仅测试用,正式侧归 元进度;经 encounter 传入战斗)----
|
||
|
||
func set_hero_override(atk_pct: int, hp_pct: int) -> void:
|
||
_hero.set_debug_override(atk_pct, hp_pct)
|
||
|
||
|
||
# 生效公式单点归主角账(拆件批 T1——本类删公式本体,读面转调;叠加次序注释随公式迁主角账)
|
||
func effective_atk() -> int:
|
||
return _hero.effective_atk()
|
||
|
||
|
||
func effective_max_hp() -> int:
|
||
return _hero.effective_max_hp()
|
||
|
||
|
||
func effective_ac() -> int:
|
||
return _hero.effective_ac()
|
||
|
||
|
||
# 行动点加成生效值(统一冒险加算区第四轴 ap;经战斗开始入参 hero_ap_bonus
|
||
# 传入战斗域,兑现 = 每场战斗开始首轮行动点基线 +加成值〔行动经济 GDD 跨冒险行动点加成轴节〕)
|
||
func effective_ap_bonus() -> int:
|
||
return maxi(0, _hero.bonus_total("ap"))
|
||
|
||
|
||
# 覆盖复位(关卡结束归零——表值不可变,仅清百分比;调用方兼容保留)
|
||
func restore_hero_base() -> void:
|
||
_hero.set_debug_override(0, 0)
|
||
|
||
|
||
# ---- 关卡生命周期 ----
|
||
|
||
func start_level(level_index: int, seed_val: int, base_override_in: Dictionary = {}) -> bool:
|
||
# 单关测试模式:私有账本、内置恢复数学、自调 new_adventure(漂移①
|
||
# 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure)
|
||
# base_override_in = 永久强化 flat 注入尾参(缺省 {} 全零白板,
|
||
# 契约同 AdventureRunner.start_adventure params.hero_override:
|
||
# 三键 {atk, max_hp, armor_class}、缺键/-1 = 0、负值 clamp 0。游戏直入口仍恒白板——
|
||
# 注入面只服务数值验证工具,非玩家可达路径)
|
||
_external = false
|
||
_rest_handler = rest_heal_amount
|
||
var row := _find_level_row(level_index)
|
||
if row.is_empty():
|
||
push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index)
|
||
return false
|
||
node_index = -1
|
||
level_result = ""
|
||
_phase = Phase.IDLE
|
||
_candidates = []
|
||
visited = []
|
||
_hero.rebase_override(base_override_in) # 永久强化 flat 重注(规范化归主角账——契约同冒险开始参数面)
|
||
_hero.reset_for_single_level() # 账面值重置(钱包/加算区/已购档/钥石/日记清零+生命置满——实例不重建,shop 绑定不换代)
|
||
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构)
|
||
if not _assign_generated_graph(row, seed_val):
|
||
_phase = Phase.ENDED
|
||
return false
|
||
_phase = Phase.RUNNING
|
||
var entries := _entry_nodes()
|
||
if entries.is_empty():
|
||
push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)")
|
||
_phase = Phase.ENDED
|
||
return false
|
||
return _enter_node(entries[0])
|
||
|
||
|
||
# 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经主角账共享交接;
|
||
# 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/加算区/已购档,
|
||
# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在主角账写好)。
|
||
# 流程与 start_level 同款(_find_level_row → 生成图装配 → 状态初始化 → _enter_node(入口)),
|
||
# 差异仅在恢复执行体的注入与零重置(主角账/冒险桶经 set_hero 注入——T1 过渡面,T3 收构造)。
|
||
# defer_entry 尾参(缺省 false = 直接进节点)——true 时不自动
|
||
# _enter_node(entry),改置选路态(awaiting_choice + 单候选 = 入口节点),由 AdventureRunner 在
|
||
# level_started 之后调 present_entry_choice() 呈现、玩家 pick_next_node 显式进首战。拆两步的
|
||
# 信号顺序契约:呈现层须先经 level_started 重取 runner 实例,choices_presented 才不会被
|
||
# _on_choices_presented 的 runner 空防御静默丢弃。入口呈现零 RNG 消耗、pick 走同一个
|
||
# _enter_node(entry)——测量面零漂移红线。
|
||
func start_level_external(level_index: int, rest_handler: Callable,
|
||
defer_entry: bool = false, resume_state: Dictionary = {}) -> bool:
|
||
var row := _find_level_row(level_index)
|
||
if row.is_empty():
|
||
push_error("[关卡节点地图] level_index %d 在 LevelTable 无行" % level_index)
|
||
return false
|
||
_external = true
|
||
_rest_handler = rest_handler
|
||
if not _assign_generated_graph(row, rng.master_seed):
|
||
return false
|
||
node_index = -1
|
||
level_result = ""
|
||
_phase = Phase.IDLE
|
||
_candidates = []
|
||
visited = []
|
||
_phase = Phase.RUNNING
|
||
# 断点续玩路径(defer_entry 之外的第三种停点入口):直接重建到停点所在节点,
|
||
# 零 RNG 消耗(图由主种子+参数档重生成同图、节点状态纯重建不 _enter_node——当场战斗从头打)。
|
||
# resume_state = {"node_id": int(-1 = 入口停点), "visited_ids": Array[int]};空字典 = 既有路径逐位不变。
|
||
if not resume_state.is_empty():
|
||
return _restore_to_stop(int(resume_state.get("node_id", -1)),
|
||
resume_state.get("visited_ids", []) as Array)
|
||
var entries := _entry_nodes()
|
||
if entries.is_empty():
|
||
push_error("[关卡节点地图] 入口节点缺失(无入边节点集为空——validate_graph 应已拦)")
|
||
_phase = Phase.ENDED
|
||
return false
|
||
if defer_entry:
|
||
_phase = Phase.AWAITING_CHOICE # 入口停点(node_index 保持 -1)——候选=全部入口(多入口首跳即选路)
|
||
_candidates = entries
|
||
return true
|
||
return _enter_node(entries[0])
|
||
|
||
|
||
# 入口停点呈现(defer_entry=true 的第二步):发出入口候选供呈现层弹
|
||
# 选路层;非停点态(缺省自动进入路径)拒绝。零 RNG 消耗——呈现与行进取号解耦。
|
||
func present_entry_choice() -> bool:
|
||
if _phase != Phase.AWAITING_CHOICE or _candidates.is_empty() or node_index != -1:
|
||
push_error("[关卡节点地图] present_entry_choice 拒绝——非入口停点态(defer_entry 未开或已 pick)")
|
||
return false
|
||
choices_presented.emit(_candidates)
|
||
return true
|
||
|
||
|
||
# ---- 断点续玩(停点粒度重建)----
|
||
|
||
# 停点重建(start_level_external resume_state 路径的落点):node_id = 停点所在节点编号(-1 = 入口
|
||
# 停点),visited_ids = 已到节点编号序(呈现层图条数据源重建)。纯状态重建零 RNG 消耗;
|
||
# 候选 = 停点节点出边(与 choices_presented 停点语义逐位一致)。
|
||
func _restore_to_stop(node_id: int, visited_ids: Array) -> bool:
|
||
if node_id < 0:
|
||
# 入口停点:与 defer_entry 同款形态(node_index 保持 -1,候选 = 全部入口)
|
||
var entries := _entry_nodes()
|
||
if entries.is_empty():
|
||
push_error("[关卡节点地图] 断点恢复失败——入口节点缺失")
|
||
_phase = Phase.ENDED
|
||
return false
|
||
_phase = Phase.AWAITING_CHOICE
|
||
_candidates = entries
|
||
return true
|
||
var stop: Dictionary[Variant, Variant] = {}
|
||
for node in nodes():
|
||
if _node_id(node) == node_id:
|
||
stop = node
|
||
break
|
||
if stop.is_empty():
|
||
push_error("[关卡节点地图] 断点恢复失败——停点节点编号 %d 不在本关图" % node_id)
|
||
_phase = Phase.ENDED
|
||
return false
|
||
node_index = nodes().find(stop)
|
||
visited = []
|
||
for vid in visited_ids:
|
||
for node in nodes():
|
||
if _node_id(node) == int(vid):
|
||
visited.append(node)
|
||
break
|
||
_phase = Phase.AWAITING_CHOICE
|
||
_candidates = _out_nodes(stop)
|
||
if _candidates.is_empty():
|
||
push_error("[关卡节点地图] 断点恢复失败——停点节点 %d 无出边(非合法停点)" % node_id)
|
||
_phase = Phase.ENDED
|
||
return false
|
||
return true
|
||
|
||
|
||
# 停点呈现(断点续玩路径——present_entry_choice 的中途节点版):信号顺序契约同入口
|
||
# (呈现层须先经 level_started 重取 runner 实例)。零 RNG 消耗。
|
||
func present_resumed_choice() -> bool:
|
||
if _phase != Phase.AWAITING_CHOICE or _candidates.is_empty() or node_index < 0:
|
||
push_error("[关卡节点地图] present_resumed_choice 拒绝——非中途停点态")
|
||
return false
|
||
choices_presented.emit(_candidates)
|
||
return true
|
||
|
||
|
||
# 断点道具库存回填(奖励节点已选道具随停点快照回局;幂等,先清后入受理序重排)
|
||
func restore_treasure_items(items: Array) -> void:
|
||
if treasure_inventory == null:
|
||
treasure_inventory = TreasureItems.new(db)
|
||
treasure_inventory.reset()
|
||
treasure_inventory.restore_inventory(items)
|
||
|
||
|
||
func _find_level_row(level_index: int) -> Dictionary:
|
||
return db.level_row(level_index) # 表访问收口(行集直扫归 LevelTable/ConfigDB 访问器)
|
||
|
||
|
||
# ---- 图源装配(关卡节点图生成 v1.1——图 = f(参数档, 种子) 纯函数;无模板池、无兜底图)----
|
||
# 参数档按关卡序号解析(消费侧职责——生成器不认关卡号),种子取本局口径:单关模式用传入种子、
|
||
# 冒险模式用主种子(产图与全局计数器号位无关,断点在停点重生成同图)。产图先过消费侧独立校验
|
||
# LevelTable.validate_graph(与产出侧自证同源规格、分工不重叠);违规=起关拒绝,不降级不回退。
|
||
func _assign_generated_graph(row: Dictionary, seed_val: int) -> bool:
|
||
var level_index: int = int(row.get("level_index", 0))
|
||
var params: Dictionary[Variant, Variant] = db.level_gen_params_row(level_index)
|
||
if params.is_empty():
|
||
push_error("[关卡节点地图] level %d 无参数档行(level_gen_params 缺行——配置错误)" % level_index)
|
||
return false
|
||
if _generator == null:
|
||
_generator = NodeMapGenerator.new(rng)
|
||
var graph: Dictionary[Variant, Variant] = _generator.generate(params, seed_val)
|
||
if graph.is_empty() or _generator.is_fatal():
|
||
push_error("[关卡节点地图] level %d 生成图失败(参数无解或产出不合格——无兜底图)" % level_index)
|
||
return false
|
||
var bad := LevelTable.validate_graph(graph["nodes"] as Array)
|
||
if not bad.is_empty():
|
||
for e in bad:
|
||
push_error("[关卡节点地图] level %d 生成图违规: %s" % [level_index, String(e["message"])])
|
||
return false
|
||
var merged := row.duplicate(true)
|
||
merged["nodes"] = graph["nodes"]
|
||
level_row.assign(merged)
|
||
return true
|
||
|
||
|
||
func nodes() -> Array:
|
||
return level_row.get("nodes", [])
|
||
|
||
|
||
func current_node() -> Dictionary:
|
||
if node_index < 0 or node_index >= nodes().size():
|
||
return {}
|
||
return nodes()[node_index]
|
||
|
||
|
||
# ---- 图版行进面(生成图恒带显式出边;单链退化形态 = 出度 ≤1 的图,无数组序隐式边)----
|
||
# 节点身份=生成图句柄 id(图内唯一)。定位信息另算(enc 携带关卡序号 + 节点深度),
|
||
# 禁再以深度当身份(句柄/位置两轴语义单一)。
|
||
|
||
func _node_id(node: Dictionary) -> int:
|
||
return int(node.get("id", -1))
|
||
|
||
|
||
# 入口节点集 = 全部无入边节点(个数=参数档 entry_count;多入口时首跳即选路)
|
||
func _entry_nodes() -> Array[Dictionary]:
|
||
var inbound := {}
|
||
for node in nodes():
|
||
for tid in _out_ids(node):
|
||
inbound[int(tid)] = true
|
||
var out: Array[Dictionary] = []
|
||
for node in nodes():
|
||
if not inbound.has(_node_id(node)):
|
||
out.append(node)
|
||
return out
|
||
|
||
|
||
# 出边句柄表(生成图恒显式 next)
|
||
func _out_ids(node: Dictionary) -> Array:
|
||
return node.get("next", [])
|
||
|
||
|
||
# 出边候选(节点引用数组——选路口/呈现层共用读面)
|
||
func _out_nodes(node: Dictionary) -> Array[Dictionary]:
|
||
var ids := {}
|
||
for n in nodes():
|
||
ids[_node_id(n)] = n
|
||
var out: Array[Dictionary] = []
|
||
for tid in _out_ids(node):
|
||
if ids.has(tid):
|
||
out.append(ids[tid])
|
||
return out
|
||
|
||
|
||
# 当前选路候选(选路态读面——呈现层渲染下一跳;非选路态 = 空)
|
||
func next_candidates() -> Array:
|
||
return _candidates.duplicate()
|
||
|
||
|
||
# 当前选路候选编号表(pick_next_node 的合法入参域——编号空间解析单一出口)
|
||
func next_candidate_ids() -> Array:
|
||
var out: Array[int] = []
|
||
for c in _candidates:
|
||
out.append(_node_id(c))
|
||
return out
|
||
|
||
|
||
# 当前节点编号(呈现层/日志读面——编号空间解析单一出口)
|
||
func current_node_id() -> int:
|
||
return _node_id(current_node())
|
||
|
||
|
||
# 任意节点编号 / 出边编号表(呈现层整图渲染读面——与行进同源,禁呈现层复算编号空间)
|
||
func node_id_of(node: Dictionary) -> int:
|
||
return _node_id(node)
|
||
|
||
|
||
func node_out_ids(node: Dictionary) -> Array:
|
||
return _out_ids(node)
|
||
|
||
|
||
# 已到节点编号序列(只读投影——编号空间解析单一出口)
|
||
func visited_ids() -> Array:
|
||
var out: Array[int] = []
|
||
for v in visited:
|
||
out.append(_node_id(v))
|
||
return out
|
||
|
||
|
||
# ---- 类型可见性读面(信息迷雾 v2.1:近处见类型,远处起雾)----
|
||
# 可见集 = Boss/终点(type=="boss")∪ 当前候选(下一跳恒可见——生成承诺)∪
|
||
# {id : |层(id) − 锚层| ≤ N};N = db.level.info_visible_layers(禁硬编码);
|
||
# 锚层 = 当前节点层,入口停点(无当前节点)= 0;层 = 入口 BFS 深度(与呈现层分层同源口径)。
|
||
# 纯函数零状态:已过节点不豁免(单圈语义,零累积记忆);断点快照零新增字段。
|
||
func type_visible_ids() -> Array[int]:
|
||
var out: Array[int] = []
|
||
var layers := _bfs_layers()
|
||
var anchor := 0
|
||
var cur := current_node()
|
||
if not cur.is_empty():
|
||
anchor = int(layers.get(_node_id(cur), 0))
|
||
var n: int = int(db.level.info_visible_layers)
|
||
for node in nodes():
|
||
var nid := _node_id(node)
|
||
if String(node.get("type", "")) == "boss" \
|
||
or (layers.has(nid) and absi(int(layers[nid]) - anchor) <= n):
|
||
out.append(nid)
|
||
for cid in next_candidate_ids():
|
||
if not out.has(int(cid)):
|
||
out.append(int(cid))
|
||
return out
|
||
|
||
|
||
# 入口 BFS 层表(id → 层深;入口层 0、出边逐层 +1,与 LevelMapScreen._assign_layers 同源)。
|
||
# 不可达节点不落表(加载期孤儿校验应已拦截)。
|
||
func _bfs_layers() -> Dictionary:
|
||
var layers := {}
|
||
var entries := _entry_nodes()
|
||
if entries.is_empty():
|
||
return layers
|
||
var queue: Array[Dictionary] = []
|
||
for entry in entries:
|
||
layers[_node_id(entry)] = 0
|
||
queue.append(entry)
|
||
var i := 0
|
||
while i < queue.size():
|
||
var cur: Dictionary[Variant, Variant] = queue[i]
|
||
i += 1
|
||
var d := int(layers[_node_id(cur)])
|
||
for nxt in _out_nodes(cur):
|
||
var nid := _node_id(nxt)
|
||
if not layers.has(nid):
|
||
layers[nid] = d + 1
|
||
queue.append(nxt)
|
||
return layers
|
||
|
||
|
||
func awaiting_choice() -> bool:
|
||
return _phase == Phase.AWAITING_CHOICE
|
||
|
||
|
||
func _enter_node(node: Dictionary) -> bool:
|
||
if not _is_running():
|
||
return false
|
||
node_index = nodes().find(node)
|
||
visited.append(node)
|
||
match String(node.get("type", "")):
|
||
"battle", "boss":
|
||
return _start_battle(node)
|
||
"reward":
|
||
# 奖励节点 = 三选一停点(与关卡完成三选一同池同权重同上限);呈现编排归
|
||
# curio_interact_controller.present_node_curio;推进 = 兑付确认后选路(零自动推进)
|
||
node_entered.emit(node, {})
|
||
return true
|
||
"rest":
|
||
var healed := _apply_rest()
|
||
node_entered.emit(node, {"healed": healed, "hp": hero_hp, "max_hp": effective_max_hp()})
|
||
return true
|
||
"shop":
|
||
# 商店房兑付:到达即开店(覆盖层归呈现层),购买出账/
|
||
# 离开归玩家意图——零自动推进;离开确认经 continue_after_node 回选路口
|
||
if _shop_pool_mode:
|
||
_shop.open_pool() # 进店抽 3(恰一 shop_draw scope;买后全重抽同签)
|
||
node_entered.emit(node, {"gp": gp, "shelf": true})
|
||
return true
|
||
node_entered.emit(node, {"gp": gp}) # legacy 固定三件(off 对照承载——零 RNG 零池消费)
|
||
return true
|
||
push_error("[关卡节点地图] 未知节点类型: %s" % str(node))
|
||
return false
|
||
|
||
|
||
func _start_battle(node: Dictionary) -> bool:
|
||
_settlement = BattleOrchestrator.new(db, rng)
|
||
if treasure_inventory == null:
|
||
treasure_inventory = TreasureItems.new(db) # 惰性建——关卡域单实例(跨战斗延续)
|
||
_settlement.treasure_inventory = treasure_inventory # 库存注入(授予路由+use_treasure 编排消费面)
|
||
if _settlement != null:
|
||
_settlement.set_treasure_unlocked(_treasure_unlocked) # 解锁门控下传(装板摇号前)
|
||
# core↔core 接线只许方法 Callable(静态规则 S6——lambda 捕获 self 会延长
|
||
# RefCounted 生命周期乃至成环:settlement 的信号持捕获 runner 的 lambda,runner 又持 settlement)
|
||
_settlement.step_row.connect(_forward_step_row)
|
||
_settlement.round_started.connect(_forward_round_started)
|
||
_settlement.board_changed.connect(_forward_board_changed)
|
||
_settlement.battle_finished.connect(_on_battle_finished)
|
||
# node_entered 先于 begin_battle:呈现层须先拿到 settlement 引用,才能响应 begin 期间发出的
|
||
# board_changed(首板安装)并重建棋盘——后发会导致首战棋盘不渲染
|
||
node_entered.emit(node, {})
|
||
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("depth", 0)),
|
||
"hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp(),
|
||
"hero_armor_class": effective_ac(), "hero_ap_bonus": effective_ap_bonus(),
|
||
"attack_type": _hero_attack_type, "armor_type": _hero_armor_type, # 两根类型轴(spawn 英雄快照;空串=锻造配置默认值回退)
|
||
"attunement": _attunement,
|
||
"ledger": run_ledger, # 充能账瘦壳下传(单引用键——bonus/真视/桶已迁主角账与冒险桶)
|
||
"hero": _hero, # 主角实体账下传(战末 bonus 汇入/真视计数单写原语消费面)
|
||
"adv_bucket": _adv_bucket, # 冒险桶下传(效果引擎 adv_ 计数器宿主字典——跨战持久同一引用)
|
||
"level_counters": level_counters, # 关卡桶下传(同关跨战共享引用、随本类 new 天然空)
|
||
"feats": _feats,
|
||
"tree_rules": _tree_rules,
|
||
"battle_index": _battle_index(),
|
||
"voucher_rap": _voucher_rap,
|
||
"voucher_time_sand": _voucher_time_sand,
|
||
"node_type": String(node.get("type", ""))}):
|
||
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("depth", -1)))
|
||
return false
|
||
_voucher_rap = 0 # 节点级一次性:注入即消费(战斗未启动则保留至下次注入前不清)
|
||
_voucher_time_sand = 0
|
||
return true
|
||
|
||
|
||
func _forward_step_row(row: Dictionary) -> void:
|
||
step_row.emit(row)
|
||
|
||
|
||
# battle_index=第几场战斗(关卡域 1 基序——visited 已含当前节点〔_enter_node :437 先追加后启动〕;
|
||
# 按已到节点 type=battle 计数,与句柄 id / 节点深度两轴解耦〔身份与位置互不兼任〕;
|
||
# 开局姿态组「每关首轮窗」条件面)
|
||
func _battle_index() -> int:
|
||
var n := 0
|
||
for v in visited:
|
||
if String((v as Dictionary).get("type", "")) == "battle":
|
||
n += 1
|
||
return n
|
||
|
||
|
||
func _forward_round_started(ri: int) -> void:
|
||
round_started.emit(ri)
|
||
|
||
|
||
func _forward_board_changed() -> void:
|
||
board_changed.emit()
|
||
|
||
|
||
func _on_battle_finished(payload: Dictionary) -> void:
|
||
var node := current_node()
|
||
var result: String = String(payload.get("result", ""))
|
||
if result == "victory":
|
||
hero_hp = maxi(1, int(payload.get("hero_hp", 0))) # despawn 前捕获值(载荷自包含)
|
||
_gain_wallet(int(payload.get("gp_dropped", 0))) # 战斗完成入账(clamp ≥0——破财可把 escrow 打负,钱包入账时钳制)
|
||
if String(node.get("type", "")) == "boss" or result == "defeat":
|
||
# Boss 胜利 = 关卡完成;任意战斗败北 = 关卡失败(漂移⑦清偿——每战结果上报冒险结构,
|
||
# 失败结算归 AdventureRunner;本类只发 level_finished)
|
||
level_result = "victory" if result == "victory" else "defeat"
|
||
var hp_left := hero_hp if result == "victory" else 0
|
||
if not _external:
|
||
close_wallet() # 单关模式:关卡收束即冒险收束(GP 清零);冒险模式归 AdventureRunner._settle
|
||
_phase = Phase.ENDED
|
||
_settlement = null
|
||
var treasure_cleared := 0
|
||
if treasure_inventory != null:
|
||
treasure_cleared = treasure_inventory.inventory_count() # 快照计数(清空前——「剩余道具随关卡结束清空」可见反馈取数)
|
||
treasure_inventory.reset() # 关卡末库存清空(无论输赢,幂等)
|
||
level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index,
|
||
"node_id": _node_id(node), "node_type": String(node.get("type", "")),
|
||
"aux_cleared": treasure_cleared}) # additive 扩签(不参与派生——node_map 扩签先例)
|
||
return
|
||
# 接口漂移清偿(关卡节点地图 v1.0 ⑦):战斗胜利不再自动推进——确认后进选路口
|
||
_settlement = null
|
||
_present_choices()
|
||
|
||
|
||
# 恢复数学单一路径(漂移③清偿;算术宿主 = 本静态方法,
|
||
# AdventureRunner.apply_rest 同式委托)。floor(上限 × 比例 ÷ 100) 后封顶缺口;
|
||
# 冒险结构 GDD 公式逐位一致
|
||
static func rest_heal(pct: int, hp: int, max_hp: int) -> int:
|
||
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
|
||
|
||
|
||
func rest_heal_amount(pct: int, hp: int, max_hp: int) -> int:
|
||
return rest_heal(pct, hp, max_hp) # 公有成文出口保持(start_level 内置恢复执行体经 Callable 引用)
|
||
|
||
|
||
func _apply_rest() -> int:
|
||
var pct: int = int(level_row.get("rest_heal_pct", 0))
|
||
# 接口③治疗封顶回报:单关模式 = 内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest
|
||
#(执行归冒险结构——回报比例、由其按封顶公式回写共享账本后再由本类叠加)
|
||
var actual: int = int(_rest_handler.call(pct, hero_hp, effective_max_hp()))
|
||
hero_hp += actual
|
||
return actual
|
||
|
||
|
||
# ---- 选路(零自动推进的停点语义:兑付确认 → 候选呈现 → 显式 pick)----
|
||
|
||
# 兑付确认后呈现选路口(战斗胜利 / 奖励·恢复确认统一入口——单候选同样确认,Boss 外无免确认通道)
|
||
func _present_choices() -> bool:
|
||
var cands := _out_nodes(current_node())
|
||
if cands.is_empty():
|
||
push_error("[关卡节点地图] 当前节点无出边且非 Boss(图合法性校验应已拦截)")
|
||
return false
|
||
_phase = Phase.AWAITING_CHOICE
|
||
_candidates = cands
|
||
choices_presented.emit(cands)
|
||
return true
|
||
|
||
|
||
# 玩家选下一跳(候选外编号拒绝;Boss 恒无出边——选路只在中间节点发生)
|
||
func pick_next_node(node_id: int) -> bool:
|
||
if _phase != Phase.AWAITING_CHOICE:
|
||
return false
|
||
for cand in _candidates:
|
||
if _node_id(cand) == node_id:
|
||
_phase = Phase.RUNNING
|
||
_candidates = []
|
||
return _enter_node(cand)
|
||
return false
|
||
|
||
|
||
# ---- 只读出口(E1/E3 封装收口:呈现层/工具经此读当前战斗,禁直读协作对象)----
|
||
|
||
func current_battle() -> Variant:
|
||
return _settlement
|
||
|
||
# 共享充能账瘦壳(RunLedger 瘦身后只承载 attune_charges;构造注入)
|
||
var run_ledger: RunLedger = RunLedger.new()
|
||
|
||
|
||
# 特性持有集(begin_battle enc.feats 求值面;**共享引用不复制**——关内奖励节点兑付的 relic 须当关生效)
|
||
var _feats: Array[Variant] = []
|
||
# 树 rule 键集(begin_battle enc.tree_rules 携带;Fork 激活侧 effects——构造注入活引用)
|
||
var _tree_rules: Array[Variant] = []
|
||
|
||
|
||
# 特性持有集读面(HUD C 区特性段数据源)
|
||
func feats_held() -> Array:
|
||
return _feats.duplicate()
|
||
|
||
|
||
# 主角账只读出口(加算区生产读面承接—— AdventureRunner.hero() 同款组件交出)
|
||
func hero() -> HeroLedger:
|
||
return _hero
|
||
|
||
|
||
# 凭证出箱注入面(储备金袋 RAP/时沙 首轮 AP;节点级一次性,battle begin 消费后归零;缺省 0=零扰动)
|
||
var _voucher_rap := 0
|
||
var _voucher_time_sand := 0
|
||
|
||
|
||
# 同调主动件充能账骑库存面注入(账本住
|
||
# RunLedger;差额注入防续玩双份——resume 路径 restore_inventory
|
||
# 已含断点内 attune 记录)
|
||
func sync_attune_inventory() -> void:
|
||
var charges: Dictionary[Variant, Variant] = run_ledger.attune_charges
|
||
if treasure_inventory == null:
|
||
treasure_inventory = TreasureItems.new(db)
|
||
for iid in charges:
|
||
var existing := 0
|
||
for r in treasure_inventory.snapshot_inventory():
|
||
if String(r.item_id) == String(iid) and String(r.source_class) == "attunement" and not bool(r.used):
|
||
existing += 1
|
||
for i in range(maxi(0, int(charges[iid]) - existing)):
|
||
treasure_inventory.add_item(String(iid), "attunement", Vector2i(-1, -1), 0)
|
||
|
||
# 三选一 GP 袋件入账(钱包收口的「奖励入账」入口;复用战斗完成同一入账原语,
|
||
# wallet_changed 载荷带 source 供呈现归因)
|
||
func grant_curio_gp(amount: int) -> void:
|
||
gp = maxi(0, gp + amount)
|
||
wallet_changed.emit("gain", gp, {"amount": amount, "source": "curio_pick"})
|
||
|
||
|
||
# ---- 玩家输入转发(呈现层只与 runner 对话)----
|
||
|
||
# 奖励/恢复/商店房兑付确认(呈现层在覆盖层上点击"继续/离开"后调用;自动模拟在 node_entered 回调内立即调用)
|
||
# 泛化语义(接口漂移清偿):确认 ≠ 推进——确认后进选路口,下一跳归 pick_next_node
|
||
func continue_after_node() -> bool:
|
||
var t := String(current_node().get("type", ""))
|
||
if t != "reward" and t != "rest" and t != "shop":
|
||
return false
|
||
if t == "shop":
|
||
_shop.leave() # 离开店面货架态清零(断点快照 shop_shelf 键随离开落空架合法态)
|
||
return _present_choices()
|
||
|
||
|
||
# ---- GP 冒险钱包(入账/出账/收束清零三收口)----
|
||
|
||
# 战斗完成入账(gp_dropped 载荷——clamp ≥0;破财可把战斗域 escrow 打负,钱包不透支)
|
||
func _gain_wallet(amount: int) -> void:
|
||
gp = maxi(0, gp + amount)
|
||
wallet_changed.emit("gain", gp, {"amount": amount})
|
||
|
||
|
||
# 冒险收束清零(冒险成功/倒下/放弃——局内货币生命周期终点,不入存档不入摘要;public——冒险模式由
|
||
# AdventureRunner._settle 在冒险收束时调用,单关模式 = 关卡收束即冒险收束)
|
||
func close_wallet() -> void:
|
||
gp = 0
|
||
wallet_changed.emit("clear", 0, {})
|
||
|
||
|
||
# ---- 商店房兑付(GP+商店房实施批:呈现层只读投影 + 购买出账)----
|
||
# 实装归 ShopSession 件(双模式货架/定价/购买/刷新唯一宿主);本类保留同签名委托与件注入/
|
||
# 白盒入口——外部调用面与断点取数面零变化。
|
||
|
||
# 件注入(建件与宿主引用换代时调用——钱包/主角账/图行/装配集为共享引用,出账直接落同一引用)
|
||
func _bind_shop() -> void:
|
||
_shop.bind({
|
||
"hero": _hero,
|
||
"level_row": level_row,
|
||
"tree_rules": _tree_rules,
|
||
"attunement": _attunement,
|
||
}, {
|
||
"wallet_emit": _emit_wallet_event,
|
||
"max_hp_gain": _gain_max_hp_flat,
|
||
"battle": current_battle,
|
||
})
|
||
|
||
|
||
func _emit_wallet_event(event: String, gp_now: int, extra: Dictionary) -> void:
|
||
wallet_changed.emit(event, gp_now, extra)
|
||
|
||
|
||
# 购血(商店 max_hp 商品——当前生命 +Δ 并封顶新上限,与账本置满/关卡完成回血同式)
|
||
func _gain_max_hp_flat(flat: int) -> void:
|
||
hero_hp = mini(hero_hp + flat, effective_max_hp())
|
||
|
||
|
||
func shop_products() -> Array:
|
||
return _shop.shop_products()
|
||
|
||
|
||
func current_price(prow: Dictionary) -> int:
|
||
return _shop.current_price(prow)
|
||
|
||
|
||
func purchased_tier(product_id: String) -> int:
|
||
return _shop.purchased_tier(product_id)
|
||
|
||
|
||
func buy_product(product_id: String) -> bool:
|
||
return _shop.buy_product(product_id)
|
||
|
||
|
||
func player_request_refresh() -> bool:
|
||
return _shop.player_request_refresh()
|
||
|
||
|
||
# 散折价单点(测试取数面沿用本类调用名)
|
||
func _spend_discounted(price: int) -> int:
|
||
return _shop._spend_discounted(price)
|
||
|
||
|
||
# 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 +
|
||
# 池消费 handler + 专长凭证判定 + cap 检查回调(cap 满 kind 剔出货架候选视图)。
|
||
func set_shop_pool(deck_ref: Array, consume_handler: Callable, ticket_consider: Callable = Callable(), cap_check: Callable = Callable()) -> void:
|
||
_shop.set_shop_pool(deck_ref, consume_handler, ticket_consider, cap_check)
|
||
|
||
|
||
# 货架抽签白盒入口(测试取数面沿用本类调用名)
|
||
func _shop_draw_shelf() -> void:
|
||
_shop._draw_shelf()
|
||
|
||
|
||
func pool_stock(kind: String) -> int:
|
||
return _shop.pool_stock(kind)
|
||
|
||
|
||
func pool_price(kind: String) -> int:
|
||
return _shop.pool_price(kind)
|
||
|
||
|
||
func shop_shelf() -> Array:
|
||
return _shop.shop_shelf()
|
||
|
||
|
||
func shop_refresh_free_left() -> int:
|
||
return _shop.shop_refresh_free_left()
|
||
|
||
|
||
func shop_refresh_cost() -> int:
|
||
return _shop.shop_refresh_cost()
|
||
|
||
|
||
func shop_refresh() -> bool:
|
||
return _shop.shop_refresh()
|
||
|
||
|
||
func spend_gp(amount: int) -> bool:
|
||
return _shop.spend_gp(amount)
|
||
|
||
|
||
func buy_pool_item(kind: String) -> bool:
|
||
return _shop.buy_pool_item(kind)
|
||
|
||
|
||
func is_shop_pool() -> bool:
|
||
return _shop.is_shop_pool()
|
||
|
||
|
||
func shop_bp_shelf() -> Array:
|
||
return _shop.shop_bp_shelf()
|
||
|
||
|
||
func shop_bp_visit_buys() -> int:
|
||
return _shop.shop_bp_visit_buys()
|