1000 lines
46 KiB
GDScript
1000 lines
46 KiB
GDScript
## VERTICAL SLICE - NOT FOR PRODUCTION
|
||
## 关卡域生命周期编排者:节点图行进、HP 跨战斗携带、商店房与恢复点兑付、关卡末收束(垂直切片验证:一关内数值提升可感知)。
|
||
## Core 层零 UI 依赖(headless 可跑完整关卡);零自动推进——兑付确认后经 choices_presented,推进只走 pick_next_node。
|
||
## 种子域:单关模式 start_level 自播种;冒险模式 start_level_external 不重播种(主种子恰一次归 AdventureRunner)。
|
||
## hero_atk_pct/hero_hp_pct 为测试专用覆盖——经 encounter 显式传入战斗,零表写,db.hero 运行期只读。
|
||
## 信号顺序契约:呈现层须在 level_started 回调重取 runner 实例,choices_presented 才不会被空防御丢弃。
|
||
class_name LevelRunner
|
||
extends RefCounted
|
||
|
||
signal node_entered(node: Dictionary, info: Dictionary) # info 按 type:battle/boss={} · reward={}(内容待定) · 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 _ledger: Dictionary[Variant, Variant] = {"hero_hp": 0, "gp": 0}
|
||
var level_counters: Dictionary[Variant, Variant] = {} # 关卡桶(lvl_ 前缀计数器;每关随 LevelRunner new 天然空=按关清零,替代旧硬编码清除名单)
|
||
var _rest_handler: Callable # 治疗封顶回报执行体(默认内置 rest_heal_amount;冒险模式 = AdventureRunner.apply_rest——接口③)
|
||
var _external := false # 冒险模式标记(钱包收束时机与播种语义分叉点)
|
||
# hero_hp / gp 属性化(既有读写点零改动——值类型经属性落 _ledger)
|
||
var hero_hp: int:
|
||
get: return int(_ledger["hero_hp"])
|
||
set(v): _ledger["hero_hp"] = v
|
||
var gp: int: # GP 冒险钱包(局内货币——冒险开始 = 0、收束清零;不入存档不入摘要)
|
||
get: return int(_ledger["gp"])
|
||
set(v): _ledger["gp"] = v
|
||
var base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat 注入(元进度实现批——冒险模式 = AdventureRunner 共享字典经 start_level_external 注入;单关测试模式 = 私有全零)
|
||
var _shop_purchased := {} # product_id → 已购档数(现价重算输入;冒险开始清零)
|
||
# 协作对象私有(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 令牌池)----
|
||
var _shop_pool_mode := false # pool 模式封印(set_shop_pool 注入时置位——AdventureRunner 按 params.shop_mode 下传)
|
||
var _pool_deck: Array[Variant] = [] # 共享池令牌库共享引用(AdventureRunner._curio_deck——库存=剩余令牌数单点)
|
||
var _pool_consume := Callable() # 池消费 handler(AdventureRunner._consume_pool_purchase——_apply_curio_pick 同口写加算区+账本+摘牌)
|
||
var _pool_ticket := Callable() # 专长凭证展示判定 handler(T5——AdventureRunner._feat_ticket_consider;节点与商店共享计数)
|
||
var _pool_cap_check := Callable() # cap 检查回调(AdventureRunner._curio_cap_ok;cap 满剔出货架候选视图)
|
||
var _shop_shelf: Array[Variant] = [] # 当前货架(进店抽 3/买后全重抽——令牌字典 {"kind","tier"} 序,同 kind 去重每 kind 至多一卡)
|
||
var _shop_visit_buys := 0 # 本次进店已购次数(A 链定价输入——离开清零)
|
||
var _shop_paid_refreshes := 0 # 本次进店已付费刷新次数(MC-1——GP 刷新价曲线输入,离开清零)
|
||
|
||
var hero_atk_pct := 0 # 测试面板覆盖值(切片专用)
|
||
var hero_hp_pct := 0
|
||
# ---- 关卡相位轴(显式 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)
|
||
|
||
|
||
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
|
||
db = db_ref
|
||
rng = rng_ref
|
||
|
||
|
||
# 进行中谓词(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_atk_pct = atk_pct
|
||
hero_hp_pct = hp_pct
|
||
|
||
|
||
# 叠加次序(钉死):表值 → 永久强化 flat(base_override)→ 加算区 flat(bonus)先加
|
||
# → 调试浮窗百分比后乘(调试数值工具恒乘最后)
|
||
func effective_atk() -> int:
|
||
var base: int = int(db.hero.atk) + int(base_override.get("atk", 0)) + int(run_ledger.bonus.get("atk", 0))
|
||
return maxi(1, int(round(float(base) * (100.0 + float(hero_atk_pct)) / 100.0)))
|
||
|
||
|
||
func effective_max_hp() -> int:
|
||
var base: int = int(db.hero.max_hp) + int(base_override.get("max_hp", 0)) + int(run_ledger.bonus.get("max_hp", 0))
|
||
return maxi(1, int(round(float(base) * (100.0 + float(hero_hp_pct)) / 100.0)))
|
||
|
||
|
||
# 护甲等级生效值(入战 AC 三层可加:甲型基础 AC〔forge.tres 选中甲路线 base_ac〕+
|
||
# 甲路线等级加值〔base_override.armor_class——hero_override 锻造分量〕+ 加算区 armor_class 轴;
|
||
# 无调试浮窗百分比通道,纯 flat)
|
||
func effective_ac() -> int:
|
||
return maxi(0, _armor_base_ac() + int(base_override.get("armor_class", 0)) + int(run_ledger.bonus.get("armor_class", 0)))
|
||
|
||
|
||
# 甲型基础 AC(三层之第一层——选中甲路线行 base_ac;未注入/缺行=锻造配置默认值路线=轻甲 10,
|
||
# 与 hero.tres 两轴删除前 armor_class=10 行为连续)
|
||
func _armor_base_ac() -> int:
|
||
var at: String = _hero_armor_type if _hero_armor_type != "" else db.forge_default_armor_type()
|
||
var row: Dictionary[Variant, Variant] = db.forge_armor_route_by_type(at)
|
||
if row.is_empty():
|
||
return int(db.hero.armor_class) # 表缺失防御兜底(现役基准)
|
||
return int(row.get("base_ac", 0))
|
||
|
||
|
||
# 行动点加成生效值(统一冒险加算区第四轴 ap;经战斗开始入参 hero_ap_bonus
|
||
# 传入战斗域,兑现 = 每场战斗开始首轮行动点基线 +加成值〔行动经济 GDD 跨冒险行动点加成轴节〕)
|
||
func effective_ap_bonus() -> int:
|
||
return maxi(0, int(run_ledger.bonus.get("ap", 0)))
|
||
|
||
|
||
# 覆盖复位(关卡结束归零——表值不可变,仅清百分比;调用方兼容保留)
|
||
func restore_hero_base() -> void:
|
||
hero_atk_pct = 0
|
||
hero_hp_pct = 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
|
||
_ledger = {"hero_hp": 0, "gp": 0}
|
||
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 = []
|
||
gp = 0 # 冒险钱包清零开局(局内货币生命周期起点)
|
||
run_ledger.bonus = {"atk": 0, "max_hp": 0, "armor_class": 0} # 加算区冒险开始全 0(单关入口零元进度——注入面见上)
|
||
base_override = {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat:缺省全零白板(单关入口零元进度——注入面见上)
|
||
_shop_purchased = {} # 已购档数清零(跨冒险不延续——递增定价按冒险内累计)
|
||
for k in base_override.keys():
|
||
base_override[k] = maxi(0, int(base_override_in.get(k, 0))) # 注入规范化(契约同冒险开始参数面)
|
||
hero_hp = effective_max_hp()
|
||
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/bonus/_shop_purchased,
|
||
# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好)。
|
||
# base_override = 永久强化 flat 共享引用(缺省 {} 全零白板,与 B1 空参数路径同值)。
|
||
# 流程与 start_level 同款(_find_level_row → 生成图装配 → 状态初始化 → _enter_node(入口)),
|
||
# 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。
|
||
# 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, ledger: Dictionary,
|
||
purchased_ref: Dictionary, rest_handler: Callable, base_override_ref: Dictionary = {},
|
||
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
|
||
_ledger = ledger
|
||
base_override = base_override_ref
|
||
_shop_purchased = purchased_ref
|
||
_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":
|
||
# 奖励点内容未定(可选类道具不在池、功能道具渠道未定——
|
||
# 节点保留占位、零兑付直通);推进 = 兑付确认后选路(零自动推进)
|
||
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, # 共享四账单类下传(单引用键)
|
||
"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
|
||
|
||
# aux 解锁门控集注入(AdventureRunner 自 params 传递)
|
||
func set_treasure_unlocked(ids: Dictionary) -> void:
|
||
_treasure_unlocked = ids
|
||
if _settlement != null:
|
||
_settlement.set_treasure_unlocked(ids)
|
||
|
||
|
||
# 同调装配集注入(冒险开始参数面;AdventureRunner 唯一调用方)
|
||
func set_attunement(ids: Array) -> void:
|
||
_attunement = ids.duplicate()
|
||
|
||
|
||
# 特性持有集注入(AdventureRunner._start_current_level 唯一调用方;begin_battle
|
||
# enc.feats 携带进战斗求值;**共享引用不复制**——关内奖励节点兑付的 relic 须当关后续战斗生效
|
||
# (attune_charges 共享账本同款);空集=零扰动)
|
||
var _feats: Array[Variant] = []
|
||
|
||
|
||
func set_feats(ids: Array) -> void:
|
||
_feats = ids
|
||
|
||
|
||
# 特性持有集读面(HUD C 区特性段数据源)
|
||
func feats_held() -> Array:
|
||
return _feats.duplicate()
|
||
|
||
|
||
# 树 rule 键集注入(begin_battle enc.tree_rules 携带;Fork 激活侧 effects)
|
||
var _tree_rules: Array[Variant] = []
|
||
|
||
|
||
func set_tree_rules(rules: Array) -> void:
|
||
_tree_rules = rules.duplicate()
|
||
|
||
|
||
# 两根类型轴注入(adventure 参数面快照;空串=未注入→锻造配置默认值回退。
|
||
# 消费面:effective_ac 甲型基础 AC 层 + begin_battle enc 携带进 unit_container.spawn 英雄快照)
|
||
func set_hero_axes(attack_type: String, armor_type: String) -> void:
|
||
_hero_attack_type = attack_type
|
||
_hero_armor_type = armor_type
|
||
|
||
|
||
# 共享四本账注入(RunLedger 单类下传,含 bonus/attune_charges/
|
||
# vision_counter/adventure_counters 四账;战斗侧只走账类原语,战末 export/开局 seed
|
||
# 时点语义不变)
|
||
var run_ledger: RunLedger = RunLedger.new()
|
||
|
||
|
||
func set_run_ledger(l: RunLedger) -> void:
|
||
run_ledger = l
|
||
|
||
|
||
# 凭证件三账注入(储备金袋 RAP/时沙 首轮 AP/完美保险 标错豁免;节点级一次性,
|
||
# battle begin 消费后归零;缺省 0=零扰动)
|
||
var _voucher_rap := 0
|
||
var _voucher_time_sand := 0
|
||
|
||
|
||
func set_voucher_grants(rap: int, time_sand: int) -> void:
|
||
_voucher_rap = maxi(0, rap)
|
||
_voucher_time_sand = maxi(0, time_sand)
|
||
|
||
|
||
# 同调主动件充能账骑库存面注入(账本住
|
||
# 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_shelf = [] # 离开店面货架态清零(断点快照 shop_shelf 键随离开落空架合法态)
|
||
_shop_visit_buys = 0
|
||
_shop_paid_refreshes = 0 # MC-1
|
||
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+商店房实施批:呈现层只读投影 + 购买出账)----
|
||
|
||
# 商店覆盖层数据源(只读投影——现价/已购档数在此现算,呈现层禁复算定价公式)
|
||
func shop_products() -> Array:
|
||
var out: Array[Dictionary] = []
|
||
for prow in db.shop_products():
|
||
out.append({
|
||
"product_id": String(prow.get("product_id", "")),
|
||
"display_name": String(prow.get("display_name", "")),
|
||
"attr": String(prow.get("attr", "")),
|
||
"flat_value": int(prow.get("flat_value", 0)),
|
||
"price": current_price(prow),
|
||
"purchased": purchased_tier(String(prow.get("product_id", ""))),
|
||
})
|
||
return out
|
||
|
||
|
||
# 现价 = base_price + price_level_bonus(level_index) + price_increment × 该商品已购档数
|
||
# (散折价:economy_spend 激活时商品标价 ×80%——见 _spend_discounted)
|
||
func current_price(prow: Dictionary) -> int:
|
||
return _spend_discounted(int(prow.get("base_price", 0)) + db.shop_price_level_bonus(int(level_row.level_index)) \
|
||
+ int(prow.get("price_increment", 0)) * purchased_tier(String(prow.get("product_id", ""))))
|
||
|
||
|
||
# 散折价单点(财富之道·散 economy_spend 激活时商品购买价 ×80% provisional,
|
||
# floor 取整偏向玩家、下限 1 防 1 元商品折成 0;范围=商品标价定义边界〔legacy 商品面 current_price
|
||
# + 池货架面 pool_price 同折〕——商店刷新价 shop_refresh_cost 与三选一付费刷新不折)
|
||
func _spend_discounted(price: int) -> int:
|
||
if _tree_rules.has("economy_spend"):
|
||
return maxi(1, int(floor(float(price) * 0.8)))
|
||
return price
|
||
|
||
|
||
func purchased_tier(product_id: String) -> int:
|
||
return int(_shop_purchased.get(product_id, 0))
|
||
|
||
|
||
# 购买(未知商品/属性键出域/余额不足拒绝——返回 false 零副作用):
|
||
# 出账 → 加算区 += flat_value → 购血同步当前生命(封顶新上限)→ 已购档数 +1
|
||
func buy_product(product_id: String) -> bool:
|
||
var prow: Dictionary[Variant, Variant] = db.shop_product_row(product_id)
|
||
if prow.is_empty():
|
||
push_error("[关卡节点地图] 商店房未知商品: %s" % product_id)
|
||
return false
|
||
var attr := String(prow.get("attr", ""))
|
||
if not run_ledger.bonus.has(attr):
|
||
push_error("[关卡节点地图] 商品属性键出域: %s(attr=%s)" % [product_id, attr])
|
||
return false
|
||
var price := current_price(prow)
|
||
if gp < price:
|
||
push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [product_id, price, gp])
|
||
return false # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝+静默留痕)
|
||
gp -= price
|
||
var flat := int(prow.get("flat_value", 0))
|
||
run_ledger.bonus[attr] = int(run_ledger.bonus[attr]) + flat
|
||
if attr == "max_hp":
|
||
hero_hp = mini(hero_hp + flat, effective_max_hp()) # 购血:上限与当前生命同步 +Δ(封顶新上限)
|
||
_shop_purchased[product_id] = purchased_tier(product_id) + 1
|
||
wallet_changed.emit("buy", gp, {"product": product_id, "price": price})
|
||
return true
|
||
|
||
|
||
func player_request_refresh() -> bool:
|
||
if _settlement == null:
|
||
return false
|
||
return _settlement.player_request_refresh()
|
||
|
||
|
||
# ---- 商店共享池模式(legacy 固定三件路径保留为 off 对照承载)----
|
||
|
||
# 注入(AdventureRunner._start_current_level 唯一调用方——pool 模式专用):令牌库共享引用 +
|
||
# 池消费 handler。legacy 模式不注入(_shop_pool_mode 恒 false——off 态零 RNG 零池消费零扰动)。
|
||
# cap 检查回调(cap 满 kind 剔出货架候选视图)。
|
||
func set_shop_pool(deck_ref: Array, consume_handler: Callable, ticket_consider: Callable = Callable(), cap_check: Callable = Callable()) -> void:
|
||
_pool_deck = deck_ref
|
||
_pool_consume = consume_handler
|
||
_pool_ticket = ticket_consider # T5——专长凭证展示判定(AdventureRunner._feat_ticket_consider;同流取数复用 shop_draw scope)
|
||
_pool_cap_check = cap_check
|
||
_shop_pool_mode = true
|
||
|
||
|
||
# 进店开店(_enter_node shop 分支 pool 模式):进店购买次计数清零 + 抽 3 落架(恰一 shop_draw scope)
|
||
func _shop_open_pool() -> void:
|
||
_shop_visit_buys = 0
|
||
_shop_paid_refreshes = 0 # MC-1——进店付费刷新计数清零
|
||
_shop_draw_shelf()
|
||
|
||
|
||
# 货架抽签(进店 + 每次买后全重抽共用;TP0 令牌级货架):牌库 = 令牌库中货架词表内 kind 的剩余
|
||
# 令牌(库存=剩余令牌数——池消耗即上限,与奖励三选一兑付摘牌同库承载);部分 Fisher-Yates 等概率
|
||
# 洗牌不放回(逐枚等概率——kind 出现率∝剩余令牌数,无独立权重 knob;同 kind 去重每 kind 至多一卡,
|
||
# 购买身份 kind 单键无歧义);消费 shop_draw 封闭流恰一 scope(每次抽 3 恰一取号)。牌库空 = 空货架
|
||
# 合法态(池空硬闸——买光即枯竭既定后果)。
|
||
func _shop_draw_shelf() -> void:
|
||
_shop_shelf = []
|
||
var sellable: Array[Variant] = []
|
||
for row in db.shop_pool_rows():
|
||
var shelf_kind := String(row.get("kind_id", ""))
|
||
if _pool_cap_check.is_valid() and not bool(_pool_cap_check.call(shelf_kind, "")):
|
||
continue # cap 满剔除(货架候选视图剔除;牌库本体不动)
|
||
for t in _pool_deck:
|
||
var tok: Dictionary[Variant, Variant] = t as Dictionary
|
||
if String(tok.get("kind", "")) == shelf_kind:
|
||
sellable.append({"kind": shelf_kind, "tier": String(tok.get("tier", ""))})
|
||
if sellable.is_empty():
|
||
return
|
||
var stream: RandomNumberGenerator = rng.open_stream("shop_draw")
|
||
for i in sellable.size():
|
||
var j := stream.randi_range(i, sellable.size() - 1)
|
||
var tmp = sellable[i]
|
||
sellable[i] = sellable[j]
|
||
sellable[j] = tmp
|
||
var seen: Array[Variant] = []
|
||
for tok in sellable:
|
||
var k := String((tok as Dictionary).get("kind", ""))
|
||
if seen.has(k):
|
||
continue
|
||
seen.append(k)
|
||
_shop_shelf.append(tok)
|
||
if _shop_shelf.size() >= 3:
|
||
break
|
||
# T5 专长凭证注入(非 Boss 渠道唯一专长入口——货架与节点共享保底计数;判定含 pity 记账,替换末位占一席。
|
||
# 取数复用本函数 shop_draw scope 的 stream——零新 scope,确定性序列不漂移)
|
||
if _pool_ticket.is_valid() and not _shop_shelf.is_empty() and bool(_pool_ticket.call(stream)):
|
||
_shop_shelf[_shop_shelf.size() - 1] = {"kind": "feat_ticket", "tier": "excellent"} # 专长凭证占位档名 = excellent
|
||
# 商店渠道无专长直接投放(渠道白名单:商店渠道只出属性值提升+资源,专长在非 Boss
|
||
# 渠道只以专长凭证形态出现——奇物.md v2.7/A4 断言;专长商店入口=T5 专长凭证)。
|
||
|
||
|
||
# 库存余量(TP0——令牌库内该 kind 剩余枚数单点读;未注入/防御读 = 0)
|
||
func pool_stock(kind: String) -> int:
|
||
var n := 0
|
||
for t in _pool_deck:
|
||
if String((t as Dictionary).get("kind", "")) == kind:
|
||
n += 1
|
||
return n
|
||
|
||
|
||
# 现价 = ceil(基价 × b^(关序−1) × A^(本次进店已购次数))(P4 甲 ceil 向上单一取整——玩家不利向;
|
||
# A/b 表 knob provisional;核心现算,呈现层禁复算——契约 5)。relic 卡(保底占位——shop 词表外)
|
||
# 基价 provisional 15 固定锚(台账披露待追认——shop.tres 词表扩列归数值批)。
|
||
func pool_price(kind: String) -> int:
|
||
var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind)
|
||
if row.is_empty():
|
||
if kind == "relic":
|
||
return _spend_discounted(int(ceil(15.0 * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) * pow(db.shop_price_a(), float(_shop_visit_buys)))))
|
||
return 999999
|
||
var base := float(maxi(1, int(row.get("base_price", 1))))
|
||
var price_f: float = base * pow(db.shop_price_b(), float(maxi(0, int(level_row.level_index) - 1))) \
|
||
* pow(db.shop_price_a(), float(_shop_visit_buys))
|
||
return _spend_discounted(int(ceil(price_f)))
|
||
|
||
|
||
# 货架只读投影(呈现层数据源——kind/显示名/件值/现价/库存余量齐备,UI 零复算)。
|
||
# 件值=在架令牌本体值(TP0——令牌级货架,kind+tier 定值;价格公式 tier-blind〔不随档位变〕)。
|
||
func shop_shelf() -> Array:
|
||
var out: Array[Dictionary] = []
|
||
for tok in _shop_shelf:
|
||
var kind := String((tok as Dictionary).get("kind", ""))
|
||
var tier := String((tok as Dictionary).get("tier", ""))
|
||
var row: Dictionary[Variant, Variant] = db.shop_pool_row(kind)
|
||
var display := String(row.get("display_name", kind))
|
||
var value := int(db.adventure.curio_value(kind, tier))
|
||
if row.is_empty() and kind == "relic":
|
||
var rrow: Dictionary[Variant, Variant] = db.feat_row(String(tok.get("feat_id", "")))
|
||
display = String(rrow.get("display_name", "特性"))
|
||
value = 0
|
||
out.append({
|
||
"kind_id": kind,
|
||
"tier": tier,
|
||
"feat_id": String(tok.get("feat_id", "")),
|
||
"display_name": display,
|
||
"value": value,
|
||
"price": pool_price(kind),
|
||
"stock_left": pool_stock(kind),
|
||
})
|
||
return out
|
||
|
||
|
||
# 商店独立刷新(MC-1):免费次数(天赋增益位,基础
|
||
# knob=0=「基础免费 0 次」)用尽后 GP 付费刷新;刷新=货架整架重抽(shop_draw 恰一新 scope,
|
||
# 与进店/买后重抽同签——重抽同源共享池);池空/非池模式=不可刷新合法态。
|
||
func shop_refresh_free_left() -> int:
|
||
# 刷新执照 shop_refresh_bonus 装配合计叠加(进店入账语义=读数即本店可用,
|
||
# 与基础 knob 同生命周期;未装配=基值)
|
||
return maxi(0, int(db.adventure.shop_refresh_free) + _attune_shop_refresh_bonus())
|
||
|
||
|
||
# 刷新执照装配合计(装配集 kind 分发扫描;零装配=0)
|
||
func _attune_shop_refresh_bonus() -> int:
|
||
var total := 0
|
||
for iid_v in _attunement:
|
||
var arow: Dictionary[Variant, Variant] = db.treasure_item_row(String(iid_v))
|
||
if (arow.get("effect_kinds", []) as Array).has("shop_refresh_bonus"):
|
||
total += int(arow.get("magnitude", 0))
|
||
return total
|
||
|
||
|
||
func shop_refresh_cost() -> int:
|
||
return int(ceil(float(db.adventure.shop_refresh_cost_base)
|
||
* pow(db.adventure.shop_refresh_cost_curve, float(_shop_paid_refreshes))))
|
||
|
||
|
||
func shop_refresh() -> bool:
|
||
if not _shop_pool_mode or _shop_shelf.is_empty():
|
||
return false
|
||
if shop_refresh_free_left() <= 0:
|
||
var cost := shop_refresh_cost()
|
||
if gp < cost:
|
||
return false
|
||
gp -= cost
|
||
_shop_paid_refreshes += 1
|
||
_shop_draw_shelf()
|
||
return true
|
||
|
||
|
||
# GP 冒险钱包单口支出(三选一 GP 付费刷新与商店刷新共用同一钱包;余额不足 false 零副作用)
|
||
func spend_gp(amount: int) -> bool:
|
||
if amount <= 0 or gp < amount:
|
||
return false
|
||
gp -= amount
|
||
return true
|
||
|
||
|
||
# 购买(pool 模式唯一购买口):在架(kind→在架令牌)/余额前置校验零副作用 → handler 消费在架
|
||
# 令牌本体(kind+tier——_apply_curio_pick 同口写加算区+账本+摘牌,跨渠道共库)→ 出账 → 进店购买
|
||
# 次计数 → wallet 事件 → 三件全重抽(未买不保留——重抽绑购买,无独立刷新动作)
|
||
func buy_pool_item(kind: String) -> bool:
|
||
var shelf_tok := {}
|
||
for tok in _shop_shelf:
|
||
if String((tok as Dictionary).get("kind", "")) == kind:
|
||
shelf_tok = tok
|
||
break
|
||
if not _shop_pool_mode or shelf_tok.is_empty():
|
||
push_error("[关卡节点地图] 商店购买拒绝——非池模式或件不在架: %s" % kind)
|
||
return false
|
||
var price := pool_price(kind)
|
||
if gp < price:
|
||
push_warning("[关卡节点地图] 购买拒绝——余额不足: %s(需 %d,钱包 %d)" % [kind, price, gp])
|
||
return false
|
||
var tier := String(shelf_tok.get("tier", ""))
|
||
if _pool_consume.is_null() or not bool(_pool_consume.call(kind, tier)):
|
||
push_error("[关卡节点地图] 购买拒绝——池库存消费失败: %s|%s" % [kind, tier])
|
||
return false
|
||
gp -= price
|
||
_shop_visit_buys += 1
|
||
wallet_changed.emit("buy", gp, {"product": kind, "price": price})
|
||
_shop_draw_shelf() # 买 1 → 三件全重抽(恰一 shop_draw scope——同进店取号签)
|
||
return true
|
||
|
||
|
||
# 池模式谓词(呈现层组合根分发用——货架面 vs legacy 商品面)
|
||
func is_shop_pool() -> bool:
|
||
return _shop_pool_mode
|
||
|
||
|
||
# 断点快照读面(P2 甲货架+账本入断点——AdventureRunner._snapshot_breakpoint 消费;离开店即空架)
|
||
func shop_bp_shelf() -> Array:
|
||
return _shop_shelf.duplicate()
|
||
|
||
|
||
func shop_bp_visit_buys() -> int:
|
||
return _shop_visit_buys
|