547 lines
27 KiB
GDScript
547 lines
27 KiB
GDScript
# VERTICAL SLICE - NOT FOR PRODUCTION
|
||
# Validation Question: 线性第一关(战×2→道具→战×2→恢复→Boss)能否承载"数值提升→通关期望改善"的元进度验证
|
||
# Date: 2026-09-11(2026-09-17 分支节点图批:行进器升级图版推进——单链 = 出边恰一的退化形态)
|
||
# 关卡节点地图(v1.0 图版落地——节点+连线,连线只朝前进方向,唯一入口,Boss 恒唯一终点):
|
||
# 关卡域生命周期唯一编排者——HP 跨战斗携带、恢复点节点兑付、关卡末收束。
|
||
# 行进纪律(零自动推进):到达即兑付;兑付后确认才出现选路口——战斗胜利与奖励/恢复确认一律经
|
||
# choices_presented 进入选路态,玩家 pick_next_node 显式选下一跳(接口漂移清偿:战斗胜利不再自动推进、
|
||
# continue_after_node 泛化为兑付确认——选路确认归 pick_next_node)。
|
||
# 2026-09-15 格面资源批:可选类道具与库存整体删除——奖励点节点内容随功能道具渠道(P1,渠道未定)悬置为
|
||
# 直通节点(零兑付);战斗各自持有 地图战斗资源 容器(映射随战斗退役,无跨战斗状态可延续)。
|
||
# Core 层零 UI 依赖(headless 可跑完整关卡——数据配置 Rule 7)。
|
||
# 测试专用:hero_atk_pct/hero_hp_pct 数值覆盖(调整板模拟天赋树——正式侧归元进度,切片不落盘)。
|
||
# E4 收口(2026-09-13):覆盖值经 encounter 显式传入战斗(不再写 db.hero 表——表值运行期不可变,
|
||
# 双基线同步/双写叠加 6→18→54 整类隐患随表写一并移除)。
|
||
# 对应 GDD:关卡节点地图 v1.0(图版结构——本文件为 P1 批实现位;命名规范见 CLAUDE.md)
|
||
# GP+商店房实施批(2026-09-21):GP 冒险钱包(战斗完成入账 clamp ≥0 · 冒险收束清零)+ 统一冒险
|
||
# 加算区 bonus{atk,max_hp,armor_class}(商店购买与关卡大奖励共用账本——战斗开始参数 = 基础表值 +
|
||
# 加算区,TunePanel 百分比在加算之后乘)+ 商店房节点兑付(开覆盖层/购买出账/离开回选路确认——
|
||
# 复用 continue_after_node 兑付确认纪律,零自动推进)。
|
||
# 冒险结构实现批(2026-09-21)——接口漂移清偿登记(06 清单「切片行进代码与定稿的接口漂移」①②③⑥⑦):
|
||
# ① 种子域归冒险结构:start_level 旧路径(单关测试模式)保留自调 rng.new_adventure;新路径
|
||
# start_level_external(冒险模式)不重播种——主种子注入恰一次归 AdventureRunner.start_adventure。
|
||
# ② 生命账本归冒险结构:hero_hp/gp 改属性落 _ledger 字典——冒险模式经 start_level_external 注入
|
||
# AdventureRunner 共享账本(Dictionary 引用共享),单关模式 = 本实例私有账本;战斗结果上报语义 =
|
||
# runner 经共享账本属性直写(接口②——战后生命终值已在本类 _on_battle_finished 回写账本)。
|
||
# ③ 恢复数学抽成单一路径 rest_heal_amount——冒险模式经 _rest_handler 回调(AdventureRunner.apply_rest,
|
||
# 接口③治疗封顶回报执行体归冒险结构);单关模式默认内置。公式逐位一致,零行为变更。
|
||
# ⑥ 收束先清库后宣布——现行无关卡域库存(格面资源批 2026-09-15 可选类删除后为空集),战斗结束
|
||
# retire_mapping + despawn_all 先于 battle_finished 发出即事实清库,不另设空接口(假契约)。
|
||
# ⑦ 倒下的失败结算归冒险结构:本类只发 level_finished(每战结果上报语义),失败结算由
|
||
# AdventureRunner._on_level_finished 执行;GP 冒险收束清零同步归冒险结构(本类冒险模式不关钱包)。
|
||
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 = {"hero_hp": 0, "gp": 0}
|
||
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 bonus := {"atk": 0, "max_hp": 0, "armor_class": 0} # 统一冒险加算区(冒险开始全 0)——商店购买与关卡大奖励写同一本账(冒险模式 = AdventureRunner 共享字典;大奖励接线归 AdventureRunner._on_level_finished)
|
||
var base_override := {"atk": 0, "max_hp": 0, "armor_class": 0} # 永久强化 flat 注入(元进度实现批——冒险模式 = AdventureRunner 共享字典经 start_level_external 注入;单关测试模式 = 私有全零)
|
||
var _shop_purchased := {} # product_id → 已购档数(现价重算输入;冒险开始清零)
|
||
# 协作对象私有(E1/E3 封装收口 2026-09-13):settlement 读取经 current_battle() 成文出口
|
||
var _settlement = null # 当前战斗编排器(关卡结束置 null)
|
||
|
||
var hero_atk_pct := 0 # 测试面板覆盖值(切片专用)
|
||
var hero_hp_pct := 0
|
||
var _running := false
|
||
var level_result := "" # "" / "victory" / "defeat"
|
||
var _awaiting_choice := false # 选路态(兑付确认后、pick 前为零自动推进的可见停点)
|
||
var _candidates: Array[Dictionary] = [] # 当前选路候选(节点引用数组)
|
||
var visited: Array[Dictionary] = [] # 已兑付/到达节点序列(呈现层图条数据源;含当前节点)
|
||
var _graph_mode := false # 图形态缓存(行集任一节点带 next;单链 = false)
|
||
|
||
|
||
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
|
||
db = db_ref
|
||
rng = rng_ref
|
||
|
||
|
||
# ---- 测试面板覆盖(模拟天赋树属性提升——仅测试用,正式侧归 元进度;经 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)先加
|
||
# → TunePanel 百分比后乘(调整板 = 测试工具恒乘最后——代拍板 6,与 GP 批既有次序兼容)
|
||
func effective_atk() -> int:
|
||
var base: int = int(db.hero.atk) + int(base_override.get("atk", 0)) + int(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(bonus.get("max_hp", 0))
|
||
return maxi(1, int(round(float(base) * (100.0 + float(hero_hp_pct)) / 100.0)))
|
||
|
||
|
||
# 护甲等级生效值(永久强化 flat + 加算区 armor_class 轴——无 TunePanel 百分比通道,纯 flat)
|
||
func effective_ac() -> int:
|
||
return maxi(0, int(db.hero.armor_class) + int(base_override.get("armor_class", 0)) + int(bonus.get("armor_class", 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 = {}, template_pick: int = -1) -> bool:
|
||
# 单关测试模式(旧路径——既有语义一字不改):私有账本、内置恢复数学、自调 new_adventure(漂移①
|
||
# 单关=冒险的历史简化在测试模式保留;正式种子域归 AdventureRunner.start_adventure)
|
||
# base_override_in = 永久强化 flat 注入尾参(换算率标定批 2026-09-23——缺省 {} 全零白板,
|
||
# 与既有调用方逐位一致零行为变更;契约同 AdventureRunner.start_adventure params.hero_override:
|
||
# 三键 {atk, max_hp, armor_class}、缺键/-1 = 0、负值 clamp 0。游戏直入口仍恒白板——
|
||
# 注入面只服务数值验证工具,非玩家可达路径)
|
||
_external = false
|
||
_rest_handler = Callable(self, "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 = ""
|
||
_awaiting_choice = false
|
||
_candidates = []
|
||
visited = []
|
||
_graph_mode = false
|
||
gp = 0 # 冒险钱包清零开局(GP+商店房实施批——局内货币生命周期起点)
|
||
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))) # 注入规范化(契约同冒险开始参数面)
|
||
for node in nodes():
|
||
if node.has("next"):
|
||
_graph_mode = true
|
||
break
|
||
hero_hp = effective_max_hp()
|
||
rng.new_adventure(seed_val) # 关卡起点 = 冒险起点(切片口径;跨关接续归 冒险结构)
|
||
level_row.assign(_resolve_template_row(row, template_pick)) # 池解析在播种后(node_map 抽签须已播种——EC7)
|
||
_running = true
|
||
var entry := _entry_node()
|
||
if entry.is_empty():
|
||
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
|
||
_running = false
|
||
return false
|
||
return _enter_node(entry)
|
||
|
||
|
||
# 冒险结构接口①:开始关卡(载荷 = 关卡序号 + 当前生命 + 生效上限——生命经共享账本交接;
|
||
# 种子域归冒险结构:本路径不重播种;账本归冒险结构:本路径不重置 gp/bonus/_shop_purchased,
|
||
# 也不重置 hero_hp——账本当前值即开局生命,AdventureRunner 已在 ledger 写好)。
|
||
# base_override = 永久强化 flat 共享引用(元进度实现批——缺省 {} 全零白板,与 B1 空参数路径逐位一致)。
|
||
# 流程与 start_level 同款(_find_level_row → 状态初始化 → _graph_mode 探测 → _enter_node(entry)),
|
||
# 差异仅在账本/加算区/已购档/恢复执行体的注入与零重置。
|
||
# 停点前移批 2026-09-23: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, bonus_ref: Dictionary,
|
||
purchased_ref: Dictionary, rest_handler: Callable, base_override_ref: Dictionary = {},
|
||
defer_entry: bool = false, template_pick: int = -1) -> 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
|
||
bonus = bonus_ref
|
||
base_override = base_override_ref
|
||
_shop_purchased = purchased_ref
|
||
_rest_handler = rest_handler
|
||
level_row.assign(_resolve_template_row(row, template_pick))
|
||
node_index = -1
|
||
level_result = ""
|
||
_awaiting_choice = false
|
||
_candidates = []
|
||
visited = []
|
||
_graph_mode = false
|
||
for node in nodes():
|
||
if node.has("next"):
|
||
_graph_mode = true
|
||
break
|
||
_running = true
|
||
var entry := _entry_node()
|
||
if entry.is_empty():
|
||
push_error("[关卡节点地图] 入口节点缺失(无入边唯一节点——图合法性校验应已拦截)")
|
||
_running = false
|
||
return false
|
||
if defer_entry:
|
||
_awaiting_choice = true # 入口停点(node_index 保持 -1——进节点前无 current_node)
|
||
_candidates = [entry]
|
||
return true
|
||
return _enter_node(entry)
|
||
|
||
|
||
# 入口停点呈现(停点前移批 2026-09-23——defer_entry=true 的第二步):发出入口候选供呈现层弹
|
||
# 选路层;非停点态(缺省自动进入路径)拒绝。零 RNG 消耗——呈现与行进取号解耦。
|
||
func present_entry_choice() -> bool:
|
||
if not _running or not _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
|
||
|
||
|
||
func _find_level_row(level_index: int) -> Dictionary:
|
||
for row in db.level.rows:
|
||
if int(row.level_index) == level_index:
|
||
return row
|
||
return {}
|
||
|
||
|
||
# ---- 模板池解析(关卡节点图生成 v0.4——M-3 迭代 5 T5)----
|
||
# row.templates 非空 = 池形态:template_pick >= 0 = 强制取该模板(首次脚本模式/冒烟固定图,零 RNG 消费);
|
||
# -1 = 抽签(node_map 用途标签恰一 scope,均匀取模)。空池 = 旧单图形态原样返回(向后兼容零迁移)。
|
||
# 解析产物 = row 副本换 nodes(下游 nodes()/校验/呈现全透明);active_template_name 供 MC 分层出数。
|
||
var active_template_name := ""
|
||
|
||
func _resolve_template_row(row: Dictionary, template_pick: int) -> Dictionary:
|
||
var templates: Array = row.get("templates", [])
|
||
active_template_name = ""
|
||
if templates.is_empty():
|
||
return row
|
||
var idx := 0
|
||
if template_pick >= 0:
|
||
idx = clampi(template_pick, 0, templates.size() - 1)
|
||
else:
|
||
var stream: RandomNumberGenerator = rng.open_stream("node_map")
|
||
idx = stream.randi_range(0, templates.size() - 1)
|
||
var tpl: Dictionary = templates[idx]
|
||
active_template_name = String(tpl.get("display_name", ""))
|
||
var out := row.duplicate(true)
|
||
out["nodes"] = tpl.get("nodes", [])
|
||
return out
|
||
|
||
|
||
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]
|
||
|
||
|
||
# ---- 图版行进面(v1.0:出边表选路 · 零自动推进 · 单链 = 隐式出边退化形态)----
|
||
# 编号空间(与 LevelTable 校验器同一解析):图形态 = node_depth(全节点编号化);单链 = 数组下标。
|
||
# 混用两空间会在单链上碰撞(如 reward 下标 2 vs 战斗 node_depth 2)——节点寻址一律经 _node_id。
|
||
|
||
func _node_id(node: Dictionary) -> int:
|
||
if _graph_mode:
|
||
return int(node.get("node_depth", -1))
|
||
return nodes().find(node)
|
||
|
||
|
||
# 入口节点 = 无入边唯一节点(图合法性校验保证恰一)
|
||
func _entry_node() -> Dictionary:
|
||
var inbound := {}
|
||
for node in nodes():
|
||
for tid in _out_ids(node):
|
||
inbound[tid] = true
|
||
for node in nodes():
|
||
if not inbound.has(_node_id(node)):
|
||
return node
|
||
return {}
|
||
|
||
|
||
# 出边编号表:显式 next 优先;单链(无 next 键)= 数组顺序下一项(退化形态)
|
||
func _out_ids(node: Dictionary) -> Array:
|
||
if node.has("next"):
|
||
return node.get("next", [])
|
||
var nodes: Array = nodes()
|
||
var i := nodes.find(node)
|
||
if i >= 0 and i < nodes.size() - 1:
|
||
return [i + 1]
|
||
return []
|
||
|
||
|
||
# 出边候选(节点引用数组——选路口/呈现层共用读面)
|
||
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
|
||
|
||
|
||
func awaiting_choice() -> bool:
|
||
return _awaiting_choice
|
||
|
||
|
||
func _enter_node(node: Dictionary) -> bool:
|
||
if not _running:
|
||
return false
|
||
node_index = nodes().find(node)
|
||
visited.append(node)
|
||
match String(node.get("type", "")):
|
||
"battle", "boss":
|
||
return _start_battle(node)
|
||
"reward":
|
||
# 奖励点内容待拍板(格面资源批 2026-09-15:可选类道具删除,功能道具渠道 P1 未定——
|
||
# 节点保留占位、零兑付直通);推进 = 兑付确认后选路(零自动推进)
|
||
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":
|
||
# 商店房兑付(GP+商店房实施批 2026-09-21):到达即开店(覆盖层归呈现层),购买出账/
|
||
# 离开归玩家意图——零自动推进;离开确认经 continue_after_node 回选路口
|
||
node_entered.emit(node, {"gp": gp})
|
||
return true
|
||
push_error("[关卡节点地图] 未知节点类型: %s" % str(node))
|
||
return false
|
||
|
||
|
||
func _start_battle(node: Dictionary) -> bool:
|
||
_settlement = BattleOrchestrator.new(db, rng)
|
||
# core↔core 接线只许方法 Callable(ADR-0001 静态规则 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(首板安装)并重建棋盘——后发会导致首战棋盘不渲染(2026-09-11 playtest 缺陷①)
|
||
node_entered.emit(node, {})
|
||
if not _settlement.begin_battle({"level_index": int(level_row.level_index), "node_depth": int(node.get("node_depth", 0)),
|
||
"hero_hp": hero_hp, "hero_atk": effective_atk(), "hero_max_hp": effective_max_hp(),
|
||
"hero_armor_class": effective_ac(), "node_type": String(node.get("type", ""))}):
|
||
push_error("[关卡节点地图] 战斗启动失败(depth %s)" % str(node.get("node_depth", -1)))
|
||
return false
|
||
return true
|
||
|
||
|
||
func _forward_step_row(row: Dictionary) -> void:
|
||
step_row.emit(row)
|
||
|
||
|
||
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
|
||
_running = false
|
||
_settlement = null
|
||
level_finished.emit({"result": level_result, "hp": hp_left, "node_index": node_index,
|
||
"node_id": _node_id(node), "node_type": String(node.get("type", ""))})
|
||
return
|
||
# 接口漂移清偿(关卡节点地图 v1.0 ⑦):战斗胜利不再自动推进——确认后进选路口
|
||
_settlement = null
|
||
_present_choices()
|
||
|
||
|
||
# 恢复数学单一路径(漂移③清偿——同一公式两个落点:本体内置默认 + AdventureRunner.apply_rest,
|
||
# 统一归并归后续批):floor(上限 × 比例 ÷ 100) 后封顶缺口;冒险结构 GDD 公式逐位一致
|
||
func rest_heal_amount(pct: int, hp: int, max_hp: int) -> int:
|
||
return mini(int(floor(float(max_hp) * float(pct) / 100.0)), max_hp - hp)
|
||
|
||
|
||
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
|
||
_awaiting_choice = true
|
||
_candidates = cands
|
||
choices_presented.emit(cands)
|
||
return true
|
||
|
||
|
||
# 玩家选下一跳(候选外编号拒绝;Boss 恒无出边——选路只在中间节点发生)
|
||
func pick_next_node(node_id: int) -> bool:
|
||
if not _awaiting_choice:
|
||
return false
|
||
for cand in _candidates:
|
||
if _node_id(cand) == node_id:
|
||
_awaiting_choice = false
|
||
_candidates = []
|
||
return _enter_node(cand)
|
||
return false
|
||
|
||
|
||
# ---- 只读出口(E1/E3 封装收口:呈现层/工具经此读当前战斗,禁直读协作对象)----
|
||
|
||
func current_battle():
|
||
return _settlement
|
||
|
||
|
||
# ---- 玩家输入转发(呈现层只与 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
|
||
return _present_choices()
|
||
|
||
|
||
# ---- GP 冒险钱包(GP+商店房实施批 2026-09-21:入账/出账/收束清零三收口)----
|
||
|
||
# 战斗完成入账(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 × 该商品已购档数
|
||
func current_price(prow: Dictionary) -> int:
|
||
return 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", "")))
|
||
|
||
|
||
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 = 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 bonus.has(attr):
|
||
push_error("[关卡节点地图] 商品属性键出域: %s(attr=%s)" % [product_id, attr])
|
||
return false
|
||
var price := current_price(prow)
|
||
if gp < price:
|
||
return false # 余额不足(覆盖层按钮禁用为常态防线——此处兜底拒绝)
|
||
gp -= price
|
||
var flat := int(prow.get("flat_value", 0))
|
||
bonus[attr] = int(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()
|