Files
gplan-2/game/core/battle_orchestrator.gd
T

1035 lines
58 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
## 战斗结算编排中枢:轮次生命周期唯一 owner,五段结算(授予→增益/减益→战斗→衰减→判定)与明细行协议发射(垂直切片验证:一场完整战斗逐行归因)。
## 效果引擎施加面归 BattleFxHost、奖励授予管线归 RewardGrant(两拆件,经组件出口 fx_host()/
## reward_grant() 取件,禁链式穿透其内部);本类保留宿主职责面=状态机与生命周期、轮 facts 缓冲、
## A2 三事件订阅、五段驱动、attune 族、行发射基建(_emit_row/_assert_row_source/_fatal)、呈现投影、刷新面。
## 零 await;随机只经 rng 封闭流(encounter/behavior_roll/armor_roll/gp_drop)——同种子同输入逐位复现。
## 呈现层读取唯一出口 = presentation_snapshot()/diagnostic_snapshot(),禁链式穿透内部协作对象。
## 伤害只向 DamageController 开单(何时开单归编排、怎么算归控制器);刷新唯一通道 player_request_refresh,禁 RAP 满自动刷。
class_name BattleOrchestrator
extends RefCounted
signal step_row(row: Dictionary)
signal battle_finished(payload: Dictionary) ## {result, hero_hp, gp_dropped}——hero 在 despawn 前捕获(呈现层销毁后读状态的时序防护);gp_dropped = 战斗掉落合计
signal round_started(round_index: int)
signal board_changed()
signal settlement_snapshot(snap: Dictionary) ## 每结算末尾的全量状态快照(playtest_logger 消费——JSONL 数值分析)
# 明细流水行型协议(战斗结算 Core Rule;两层词表——基础 9 类封闭+扩展行 1 类;
# 清单改动须同步 battle_log_view 的 _format_row 与本文档头注):
# 基础 9 类:效果行 effect · 伤害行 damage · 治疗行 heal ·
# 奖励未生效行 reward_ineffective · 封印未中行 seal_miss · 施放行 cast · 衰减行 decay ·
# 判定行 verdict · 高光行 highlight
# 扩展行:行型值=extended(单一扩展行型),效果身份由 effect_id 载荷携带(_emit_row 强制必载,
# 缺载即断言失败零发射)。现役效果身份与发射点:
# fx_reveal(专长揭示掷格——battle_fx_host flush_pending_reveals 三发射点:兑付行+落空行×2)
# mine_neutralized(封印匠·危险格中立移除——grant_segment 封印命中 remove_mines 面)
# fx_preview_area(示警烟火·周围资源类型——grant_segment 封印命中 preview_area 面)
# insurance_used(完美保险·标错豁免——grant_segment 标错 forgive_seal_miss 面)
# 新增专长效果只新增 effect_id 身份不再新增行型;行型词表改动须同步 owner 文档「扩展行」节。
# 行来源断言豁免集合(行型层)——判定段 / 衰减段本就无棋盘来源,勿一刀切误炸
const SOURCE_EXEMPT_TYPES := ["decay", "verdict"]
# 行来源断言豁免集合(来源类别层)——事实来源是单位 / 清图而非棋盘格;
# fx_reveal=专长揭示掷格(落空行无真实格);
# fx_grant_gp=专长·GP 入账(钱包面而非棋盘面,行无格子可指)
const SOURCE_EXEMPT_CLASSES := ["hero_attack", "enemy_action", "clear_reward", "fx_reveal", "fx_grant_gp"]
var db: ConfigDb
var rng: RngServiceCore
# E1 封装收口:协作对象 board_generator/enemy_system/status_effects/reward_economy 私有化——跨层读取一律经 presentation_snapshot()/diagnostic_snapshot() 成文出口。
# minesweeper/action_economy 保持公有 = 架构文档 API Boundaries 的 HUD框架消费面(意图提交 settle_batch + compute_move_path
# 只读;ap/rap/相位读取)——非本文档登记的契约面,勿增新用途。
var _board_generator: BoardGenerator
var minesweeper: MinesweeperRules
var action_economy: ActionEconomy
var _enemy_system: UnitContainer
var _status_effects: StatusContainer
var _reward_economy: RewardEconomy
var treasure_inventory: TreasureItems = null:
set(v):
treasure_inventory = v
if _reward_grant != null:
_reward_grant.treasure_inventory = v # 库存注入转发(注入时序在宿主构造之后——外部赋值路径零改址)
var _damage: DamageController # 伤害控制器(单据唯一兑付管线)
var _fx_host: BattleFxHost # 效果引擎宿主件(fx 族持有与兑付——拆件定稿轴件一)
var _reward_grant: RewardGrant # 奖励授予件(授予管线与秘宝使用——拆件定稿轴件二)
var encounter: Dictionary[String, Variant] = {}
var round_index := 0
var battle_result := "" # "" / "victory" / "defeat" / "fatal_error"
var _facts_reveals: Array[Dictionary] = []
var _facts_seals: Array[Dictionary] = []
var _facts_path := ""
var _settling := false
# 结算五段 Segment(段边界=审计五段,段序骨架冻结于
# SettlementTable.segment_order 语义:授予→增益/减益→战斗→衰减→判定。本类做段序编排+
# 行发射/开单原语单点——段体逻辑归 game/core/settlement/ 五类)
var _grant_seg = null # GrantSegment(①授予段——含封印判与 counter/grant 助手单点)
var _aura_seg = null # AuraSegment(②增益/减益段)
var _combat_seg = null # CombatSegment(③战斗段)
var _decay_seg = null # DecaySegment(④衰减段)
var _verdict_seg = null # VerdictSegment(⑤判定段——返回裁定,终局早退语义归本类)
var _refresh_used_this_round := 0 # 每轮刷新计数(Active 相位合计,兜底上限 = ActionEconomyTable.refresh_cap_per_round)
var _board_seq := 0 # 板序号(本战斗第几张板——快照归因用)
var _board_from_midround_refresh := false # 装板 provenance:轮内刷新换来的图 = true(清图奖励守卫——刷新图不发清图奖励)
var _seal_consumed := {} # 兑付资格以危险格实例为键(整图;换图重置)
# 同调装配运行态(begin_battle 经 enc.attunement 注入;off=空集全部效果零扰动)
var attunement: Array[Variant] = [] # 装备中同调件 item_id 集
var _mines_hits: Dictionary[Variant, Variant] = {"total": 0, "last": 0, "round": 0} # 踩雷计数(触发条件事实面——本场累计/上轮转存/当轮累计三读数合一账)
# 转存时点=_roll_round_actions 轮末(轮内部分结算不更新 last);开战清零归 begin_battle
var _attune_safe_seen := 0 # 积点制件 battle 域安全开格计数(装配集 ap_charge_safe 行扫描;满 charge_n 自动兑付当轮 AP+1,无玩家发起入口)
var nest_preview_count := -1 # 巢穴预告读数口(board_installed 命中落值;-1=未装配/未发射;呈现桥消费)
# 特性运行态(relic 持有集经 enc.feats 注入:效果行为=数据行 triggers 经
# 效果引擎分发;魔矢每战首次计数 battle 域。空集=零扰动)
var _feat_first_dig_used := false
# 加算日记读面(begin_battle 经 enc["journal"] 派生——encounter.assign 后全生命期可读;
# null=调试/单场直构面,账面消费跳过。领域拆分批成员删除后的引用承接,读面即引用、无第二账)
func _journal() -> GainJournal:
return encounter.get("journal", null) if encounter.get("journal", null) is GainJournal else null
# 同调配装件读面(begin_battle 经 enc["loadout"] 派生——充能单源化的 reset_cycle/consume_charge
# 消费面;null=直构零装配,充能面跳过)
func _attune_loadout() -> AttuneLoadout:
return encounter.get("loadout", null)
# 授予件战斗域回调束(bind_battle 注入——键集封闭三键:充能消耗/真视计数/负向踩雷计数;
# 宿主侧各转 _attune_loadout/_journal/_mines_hits 单点,授予件对账本零强引用)
func _battle_hooks() -> Dictionary[Variant, Variant]:
return {
"attune_consume": _hook_attune_consume,
"vision_add": _hook_vision_add,
"negative_blast": _book_negative_blast,
}
func _hook_attune_consume(item_id: String) -> void:
if _attune_loadout() != null and treasure_inventory != null:
_attune_loadout().consume_charge(item_id, treasure_inventory)
func _hook_vision_add() -> void:
if _journal() != null:
_journal().record_vision_use() # 受理成功恰计一次(未注入=调试直构不计——空账守卫同语义)
# 负向 blast(雷)踩雷计数入账(触发条件事实面——正向 spark_aoe/清图奖励不计入;
# 极性判定在授予件 _flat_blast 传点,本口只记账)
func _book_negative_blast() -> void:
_mines_hits["round"] += 1
_mines_hits["total"] += 1
# 效果引擎宿主侧事实单点(fx_host facts_cb 注入——round_index/battle_index/seals_count/nest_count/hp_ratio 五事实;charge_layers 归 fx_host 自答;未知事实回落 null)
func _fx_host_fact(fact: String) -> Variant:
match fact:
"nest_count":
var n := 0
for eff in _status_effects.query_active("enemy"):
if String(eff.effect_id) == "weaken":
n += 1
return n
"round_index":
return round_index
"battle_index":
return int(encounter.get("battle_index", 1)) # 关卡域战斗序(1 基——level_runner enc 携带;开局姿态窗条件面)
"seals_count":
var hits_n := 0
for fevt in _facts_seals:
if bool(fevt.get("hit", false)):
hits_n += 1
return hits_n
"hp_ratio":
var hu: Dictionary[Variant, Variant] = _enemy_system.hero_unit()
return int(round(100.0 * float(int(hu.get("hp", 0))) / float(maxi(1, int(hu.get("max_hp", 1)))))) # 百分比 int(0-100——when lte 算子 int 截断比较口径)
return null
# attune 积点读投影(RewardGrant attune_safe_seen_cb 注入面——use_treasure 拒绝文案读数;经读投影出户防组件回调宿主私有族)
func _attune_safe_seen_read() -> int:
return _attune_safe_seen
# 组件出口(拆件后取件面——呈现与测试经此取件,禁链式穿透协作对象内部)
func fx_host() -> BattleFxHost:
return _fx_host
func reward_grant() -> RewardGrant:
return _reward_grant
func _attune_has(item_id: String) -> bool:
return attunement.has(item_id)
# 装配集只读出口(_attune_has 契约化出口,save v6 只读访问器先例。只读零副作用——
# 呈现侧判定专用,非结算写入口)
func attune_has(item_id: String) -> bool:
return _attune_has(item_id)
# 装备件表值读取(charge_n 等——TreasureItemTable 行;未装配不读表。
# 行值经 value_from 在 rebuild 定影,零运行期按件取值)
# 装配集参数合计(常时参数面 kind 的装配集 Σmagnitude 读数口;
# kind 分发查询先例=_attune_refresh_x_total bonus_refresh。零装配=0)
func attune_param_sum(kind: String) -> int:
var total := 0
for iid_v in attunement:
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has(kind):
total += int(arow.get("magnitude", 0))
return total
# 装配集内 ap_charge_safe 行读取(取数源=装配集行扫描;
# 首行语义=装配集内至多一件积点制〔试炼镐〕)
func _attune_charge_safe_row() -> Dictionary:
for iid_v in attunement:
var arow: Dictionary[Variant, Variant] = db.crossrun.treasure_items.row_by_id(String(iid_v))
if (arow.get("effect_kinds", []) as Array).has("ap_charge_safe"):
return arow
return {}
# 充能重置(按周期回满:账本回 charges_per_adventure+骑库存差额补注入
# (used handle 不回收=本轮已耗充能作废,补新 handle=「回满」;差额注入防双份=level_runner
# set_attune_charges 同构)。reset ∈ {battle, level}——adventure 周期归冒险开始种子面)
func _reseed_attune_charges(reset: String) -> void:
if _attune_loadout() == null or treasure_inventory == null:
return # 直构零装配/库存未就位——充能回满跳过(空装配同语义)
_attune_loadout().reset_cycle(reset, treasure_inventory) # 写时种行/补行(按周期筛——语义逐字承原循环体)
# 积点制件进度读数(自动兑付无玩家发起入口,读数口=C 区秘宝槽悬停浮窗进度行:
# assembled=装配在册/points=本战安全格进度(charge_n 取余)/need=charge_n/item_id=装配行 id。
# 未装配=assembled false)
func attune_convert_state() -> Dictionary:
var row := _attune_charge_safe_row()
if row.is_empty() or String(row.get("form", "")) != "active_charged":
return {"assembled": false, "points": 0, "need": 0, "display_name": "", "item_id": ""}
var cn := int(row.get("charge_n", 0))
return {"assembled": true, "points": _attune_safe_seen % cn if cn > 0 else 0, "need": cn,
"display_name": String(row.get("display_name", "")), "item_id": String(row.get("item_id", ""))}
# 特性求值(效果行为=数据行 triggers 订阅,效果引擎 dispatch 分发;
# 流派档位质变=FeatsEngine 纯函数求值
# (档位机制本身系引擎基建非内容效果),两运行态注伤害控制器:档一 rider+1 / 档二 rider 可暴击)
var _seal_miss_seen := {} # 封印未中行去重(每安全格实例一次)
var _perfect_hit_true := 0 # 整图完美封印计数(install 重置)
var _perfect_hit_false := 0
var _perfect_forgiven := 0 # 完美保险豁免累计(单向累加自有账;契约 2 禁改写 _perfect_hit_false)
var _hero_atk := 0 # 主角攻击基值快照(encounter.hero_atk;E4 收口——表值运行期不可变)
func _init(db_ref: ConfigDb, rng_ref: RngServiceCore) -> void:
db = db_ref
rng = rng_ref
_board_generator = BoardGenerator.new()
action_economy = ActionEconomy.new(db)
minesweeper = MinesweeperRules.new(db, action_economy)
_enemy_system = UnitContainer.new(db)
_status_effects = StatusContainer.new(db)
_reward_economy = RewardEconomy.new(db, rng)
_board_generator.fatal_handler = _fatal; _reward_economy.fatal_handler = _fatal # 协作件违约→本类 _fatal 封印(单点接线)
_damage = DamageController.new(db, rng, _enemy_system, _status_effects)
_damage.emit_row_cb = _emit_row # 行协议归属本类(9 类行协议 + 来源断言不外泄)
_fx_host = BattleFxHost.new({"db": db, "rng": rng, "minesweeper": minesweeper, "action_economy": action_economy,
"damage": _damage, "emit_cb": _emit_row, "facts_cb": _fx_host_fact})
_reward_grant = RewardGrant.new({"db": db, "reward_economy": _reward_economy, "damage": _damage, "fx_host": _fx_host,
"action_economy": action_economy, "status_effects": _status_effects, "units": _enemy_system, "emit_cb": _emit_row,
"fatal_cb": _fatal, "attune_safe_seen_cb": _attune_safe_seen_read, "pool_effective_cb": _pool_effective})
_fx_host.bind_grant(_reward_grant.reveal_redeem) # 构造序禁环:两件建齐后接线(fx 件对授予面走注入回调)
_damage.set_fx(_fx_host.engine, _fx_host.counters_ctx) # 效果引擎注入(attack_commit/clamp_check/armor_roll/kill/damage_taken 发射面)
action_economy.set_fx(_fx_host.engine, _fx_host.counters_ctx) # dig_paid/charge_full 发射面
_reward_economy.luck_bias_query = _fx_host.luck_bias_for # 偏运查询口注入(reward_roll 发射归 fx 宿主)
minesweeper.cell_revealed.connect(_on_cell_revealed)
minesweeper.seal_judged.connect(_on_seal_judged)
minesweeper.round_end_board.connect(_on_round_end)
action_economy.round_end_stopped.connect(_on_round_end)
# 结算五段接线(授予/战斗两段持三引用=宿主+两协作件;信号连接纪律不涉段〔段零信号连接〕)
_grant_seg = GrantSegment.new(self, _fx_host, _reward_grant)
_aura_seg = AuraSegment.new(self)
_combat_seg = CombatSegment.new(self, _fx_host, _reward_grant)
_decay_seg = DecaySegment.new(self)
_verdict_seg = VerdictSegment.new(self)
# ---- 战斗开始序列(编排专章①:门 → 增益状态 → 敌人系统 → install+roll → 行动经济)----
# 段序=受理与敌人 spawn → 装板 → 同调/fx 运行态与订阅重建 → 常时参数与充能重置 → 巢穴预告与经济注入 → 轮始发射;各段=_begin_<段> 私有族。
func begin_battle(enc: Dictionary) -> bool:
if round_index != 0:
push_warning("[战斗结算] begin_battle 重复受理")
return false
if not _begin_battle_accept(enc):
return false
if not _install_new_board():
return false
_reset_round_facts() # 首板 install 的初始展开事件不入轮 facts(resource 恒 false 的双保险)
_begin_battle_fx_state(enc)
_begin_battle_attune_params(enc)
_begin_battle_preview_economy(enc)
# 工兵晨令=round_start grant_ap(mode:queue) 订阅行承载(when round_index ≤ effective_rounds
# value_from 定影);round_start 发射先于行动经济轮切换=树规则 queue 型当轮入队当轮兑现
# (先手压制=首轮 AP+1 语义锚);计数器 round_start 重置点同点生效
round_index = 1
_fx_host.emit_round_start()
if not action_economy.notify_round_start():
_fatal("行动经济 首轮 notify_round_start 被拒")
return false
_roll_round_actions() # 开轮摇号(首轮同样摇;先于 round_started,挖掘期动画位可读)
round_started.emit(round_index)
return true
# 受理段:encounter 装载+缓冲清零+护甲合流+重载闸+开域门+遭遇骰与敌人系统 spawn 受理门。
func _begin_battle_accept(enc: Dictionary) -> bool:
encounter.assign(enc)
_reward_grant.reset_battle() # 战斗域零 GP 持久(每场战斗缓冲从零起算;案C 闸复位)
_damage.set_hero_ac_override(int(enc.get("hero_armor_class", -1))) # 主角护甲合流入参(伤害控制器 defender_ac 消费)
db.set_reload_gate(true)
if not _status_effects.notify_battle_start():
_fatal("增益状态 开域被拒(编排违例)")
return false
var dice := _begin_battle_dice(enc)
if dice.is_empty():
_fatal("敌人表 行集解析为空(%d,%d,%s)" % [int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle"))])
return false
# E4 收口:加成经 encounter 显式携带(hero_atk/hero_max_hp;缺省 = 表值——冒烟/工具直呼路径)
_hero_atk = int(enc.get("hero_atk", int(db.combat.hero.atk)))
if not _enemy_system.spawn({"level_index": int(enc.level_index), "node_depth": int(enc.node_depth), "hero_hp": int(enc.hero_hp), "unit_count_overrides": dice[0], "hero_atk_override": _hero_atk, "hero_max_hp_override": int(enc.get("hero_max_hp", -1)), "enemy_atk_overrides": dice[1], "hero_attack_type": String(enc.get("attack_type", "")), "hero_armor_type": String(enc.get("armor_type", "")), "row_kind": String(enc.get("node_type", "battle"))}): # 两根类型轴(空串=锻造配置默认值回退,行为连续)+行类维(battle/boss)
_fatal("敌人系统 spawn 被拒")
return false
return true
# 遭遇骰(难度曲线与Boss GDD Core Rule 8):行有 unit_count_max 时按 [unit_count, max] 均匀骰;
# 行有 atk_min 时按 [atk_min, atk_max] 骰 spawn 期整战持久值
# encounter 封闭流恰一 scope(数量骰先、atk 骰后——区间宽度为零的行不掷=「定值不骰」既有口径);
# 敌人系统零随机纪律保持(收 override 即确定值)。
# 返回 [count_overrides, atk_overrides];行集为空 = 返回 [](fail 判据交受理段 _fatal)。
func _begin_battle_dice(enc: Dictionary) -> Array:
# 行类维度:节点身份 battle/boss 决定行集键域(缺省 battle 只服务测试直呼面——生产路径恒传 node_type)
var erowset_pre: Array[Variant] = db.combat.enemy.resolve_rowset(int(enc.level_index), int(enc.node_depth), String(enc.get("node_type", "battle")))
if erowset_pre.is_empty():
return []
var needs_count := false
var needs_atk := false
for r in erowset_pre:
if int(r.get("unit_count_max", int(r.get("unit_count", 1)))) > int(r.get("unit_count", 1)):
needs_count = true
if r.has("atk_min"):
needs_atk = true
if not (needs_count or needs_atk):
return [[], []] # 区间全定值 = 零骰合法态(不开流零取号)
var estream: RandomNumberGenerator = rng.open_stream("encounter")
return [_begin_battle_dice_roll(erowset_pre, estream, false), _begin_battle_dice_roll(erowset_pre, estream, true)]
# 区间骰单轴(roll_atk=false=数量骰〔宽度为零的行不掷=「定值不骰」〕/true=atk 骰〔行缺 atk_min 记 -1=无覆盖表值〕;消费序=数量骰先、atk 骰后)
func _begin_battle_dice_roll(rows: Array, estream: RandomNumberGenerator, roll_atk: bool) -> Array:
var out: Array[Variant] = []
for r in rows:
if roll_atk:
if r.has("atk_min"):
out.append(int(r.atk_min) + estream.randi_range(0, int(r.atk_max) - int(r.atk_min)))
else:
out.append(-1)
else:
var lo: int = int(r.get("unit_count", 1))
var hi: int = int(r.get("unit_count_max", lo))
out.append((lo + estream.randi_range(0, hi - lo)) if hi > lo else lo)
return out
# 淬毒/偏运=triggers 订阅行(淬毒=clamp_check
# modify 窗口、偏运=reward_roll bias_rewards;value 行字段 value_from 定影,begin_battle 不注入)
# 特性运行态(enc.feats 携带持有集:效果行为=数据行 triggers 经效果
# 引擎分发;流派档位=FeatsEngine 纯函数求值两运行态注伤害控制器。魔矢每战首次开格计数同步归零)
# 扩签运行态就位(归 fx_host)+账本挂接+桶挂引用+揭示/AC 两回调+rider 档位恒零+订阅重建。
func _begin_battle_fx_state(enc: Dictionary) -> void:
# 同调装配运行态注入(enc.attunement 携带;off=空集零扰动。淬毒 N 注入
# 伤害控制器、偏运 N 注入地图战斗资源——均须先于首板 install)
attunement = (enc.get("attunement", []) as Array).duplicate()
_attune_safe_seen = 0
_fx_host.reset_battle_state(int(enc.get("hero_armor_class", -1)))
_mines_hits = {"total": 0, "last": 0, "round": 0} # 踩雷计数开战清零(事实面生命周期同款)
var adv_bucket: Variant = enc.get("adv_bucket", null)
_fx_host.mount_buckets(adv_bucket if adv_bucket is Dictionary else {}, enc.get("level_counters")) # 冒险桶宿主字典(直构兜底自有空字典);关卡桶 LevelRunner 每关 new 下传、非 Dictionary 即不挂
_reward_grant.bind_battle(_battle_hooks()) # 授予件战斗域回调束(充能消耗/真视计数/踩雷计数)
_damage.fx_calls["reveal"] = _fx_host.enqueue_reveal
_damage.fx_calls["ac"] = _fx_host.apply_ac_bonus
var feat_ids: Array[Variant] = enc.get("feats", []) as Array
# 流派档位质变不设(奇物.md v1「不设流派标签、
# 不设档位机制」):rider_tier 两注入恒零,散件单体效果=唯一形态
_damage.inject["rider_tier_bonus"] = 0
_damage.inject["rider_tier_crit"] = false
_feat_first_dig_used = false
# 树 rule 键消费(Fork 激活侧效果=效果引擎合成
# 订阅(rebuild 注入——round_start 首轮 AP+1 / damage_taken 减伤+1)。其余 rule 键
# =stat 面投影不入战斗(T8 表内容 provisional 口径披露))
var tree_rules: Array[Variant] = enc.get("tree_rules", []) as Array
# 效果引擎订阅重建(装载序=feats 表序→treasure 表序→树规则,写死保种子复放——细则见 battle_fx_host.rebuild_subscriptions)
_fx_host.rebuild_subscriptions(feat_ids, attunement, tree_rules)
# 秘宝常时参数注入(装配集 kind 分发:远程镐挖距放宽/信标网络封印可达豁免;
# 缺省 0/false=零装配零扰动。注入在首板 install 之后〔板在早段〕——板生成不读两参数,
# 参数只影响挖掘/插棋动作判据,时序安全)+充能重置周期(battle=每战回满/level=每关首战回满
# 〔battle_index==1〕;adventure 周期维持冒险开始 _seed_attune_charges 单次)
func _begin_battle_attune_params(enc: Dictionary) -> void:
minesweeper.dig_range_bonus = attune_param_sum("dig_range_bonus")
minesweeper.seal_range_free = attune_param_sum("seal_range_free") > 0
_reseed_attune_charges("battle")
if int(enc.get("battle_index", 1)) == 1:
_reseed_attune_charges("level")
# board_installed 发射+施加(巢穴预告:装板后一次报本板危险格数量;
# 只报数量不标位置=信息红线,不碰分布=棋盘事实不可变契约 3。命中=读数口落值,呈现归
# 呈现桥消费(无格来源不进结算明细行——行来源纪律);板已就绪〔早段 install〕、订阅行已在 rebuild 装载)+
# 跨冒险行动点加成轴(统一冒险加算区第四轴):经排队修正通道兑现
# 「每场战斗开始首轮 AP +加成值」(queue_ap_delta = 下轮开轮基线加算、notify_round_start 兑现后
# 清零——首轮即消费,后续轮次不重复兑现;语义锚 = 行动经济 GDD「跨冒险行动点加成轴」节)+
# 储备金袋(节点级 RAP 注入)接收(完美保险注入= feats.tres 订阅承载,不经此通道)
func _begin_battle_preview_economy(enc: Dictionary) -> void:
nest_preview_count = -1
var bi_hits := _fx_host.fx_emit("board_installed", {})
for h in bi_hits:
if String(h.get("kind", "")) == "mine_count_preview":
nest_preview_count = minesweeper.mine_total()
var ap_bonus: int = int(enc.get("hero_ap_bonus", 0)) + int(enc.get("voucher_time_sand", 0)) # 时沙同通道(首轮池增量,不改上限)
if ap_bonus > 0:
action_economy.queue_ap_delta(ap_bonus)
var voucher_rap: int = int(enc.get("voucher_rap", 0))
if voucher_rap > 0:
action_economy.grant_reserve_ap(voucher_rap)
func _install_new_board(during_round := false) -> bool:
var stream: RandomNumberGenerator = rng.open_stream("board_gen")
var board: BoardData = _board_generator.generate(int(encounter.level_index), int(encounter.node_depth), stream, db)
if board == null:
return false
# 全局作用域变异决议(装板序内前置于 roll_board:图级偏斜旋钮须在资源摇号前定型——详见 _resolve_global_variant)
_resolve_global_variant(board)
var res: Dictionary[Variant, Variant] = minesweeper.install_board(board, during_round)
if not res.accepted:
_fatal("排雷玩法 install 拒绝(不变式违例): %s" % str(res))
return false
# 地图战斗资源 义务⑥:roll_board 恰一次(install 后、挖掘前);资格格 = 全部非封锁格(安全格 + 危险格,雷安同池)
var r0 := {}
for c in board.compute_r0():
r0[c] = true
var eligible: Array[Dictionary] = []
for c in board.safe_cells():
if not r0.has(c):
eligible.append({"cell": c})
for c in board.mines.keys():
if not r0.has(c):
eligible.append({"cell": c})
if not _reward_economy.roll_board(eligible):
_fatal("地图战斗资源 roll_board 被拒")
return false
# 地形变异 布置段(棋盘设置顺序第 4 步:生成棋盘 → 打开状态初始化 →
# 战斗资源格设置 → 变异格设置)——布置在初始展开与资源格之后,变异天然不落已开区
_place_variants(board)
_seal_consumed.clear() # 换图新雷 = 新实例,资格自然重置(行动经济 Rule 8)
_seal_miss_seen.clear()
_perfect_hit_true = 0 # 整图完美计数随 install 重置
_perfect_hit_false = 0
_perfect_forgiven = 0 # 豁免累计随 install 重置(domain=battle 每战清零)
_board_from_midround_refresh = during_round # 轮内刷新装板 = 刷新图(清图奖励不发);常规换图/开战装板 = false
_board_seq += 1
board_changed.emit()
if treasure_inventory != null:
treasure_inventory.retire_overlays() # 叠加层随图消亡(换图/刷新图装板点;已花道具不返还)
return true
# ---- 全局作用域变异 决议(装板序内 roll_board 之前)----
# boss/elite 图命中全局型:写整图 board_variants(恰一行)+ 注入资源面偏斜旋钮(图级投影在
# RewardEconomy.roll_board 消费);不消费 variant_place 流(全局取用确定性——零取号合法态)。
# 全局池空(含 L1 Boss 免疫/unlock 门控未达):字段留空、旋钮归零——_place_variants 原路走
# region 布置 = 兜底回落合法态(防配置缺行静默裸奔——provisional)。
func _resolve_global_variant(board: BoardData) -> void:
board.board_variants.clear()
_reward_economy.board_skew = 0
var row: Dictionary[Variant, Variant] = db.combat.variation.global_variant_row(int(encounter.level_index), String(board.board_type),
String(encounter.get("node_type", "")))
if row.is_empty():
return
board.board_variants = [String(row.variant_id)]
_reward_economy.board_skew = int(row.get("effect_value", 0))
# ---- 地形变异 布置段(棋盘设置顺序第 4 步)----
# 候选池门控(unlock_level ≤ 关卡序号 + board_types 与本地形交集,∅ 通配)经 config_db.variant_pool;
# 池空 = 合法态,静默零布置(框架 P0 口径不变)。零特判:本段只写格子变异数据快照,不改扫雷规则本体。
# 候选格域 = 安全格 − 初始展开区(伤害型 placement=closed_only 的结构保障——布置发生在初始展开之后,
# 已开格天然排除);危险格不吃变异(挖雷一层惩罚 + 变异伤害双层叠罚污染逐行归因,首版排除——provisional)。
# 每图至多一种类型(候选池内均匀选一);数量 = round(候选池格数×percent) +
# (关卡序号−1)×per_level_bonus——基数为候选池而非总格数(percent 语义 = 占可挖格比例,
# 关卡递增由 per_level_bonus 绝对格数承载);连通片生长(8 邻),单片 ≤ pack_max。
# 替换制:整图 board_variants 非空 = 本图已带全局型,跳过 region 布置(详见 _resolve_global_variant)。
func _place_variants(board: BoardData) -> void:
if not board.board_variants.is_empty():
return
var pool: Array[Variant] = db.combat.variation.variant_pool(int(encounter.level_index), String(board.board_type))
if pool.is_empty():
return
var r0 := {}
for c in board.compute_r0():
r0[c] = true
var candidates: Array[Vector2i] = []
for c in board.safe_cells():
if not r0.has(c):
candidates.append(c)
if candidates.is_empty():
return
var stream: RandomNumberGenerator = rng.open_stream("variant_place") # 一板恰一 scope(换图/刷新图各取号一次)
var vrow: Dictionary[Variant, Variant] = pool[stream.randi_range(0, pool.size() - 1)]
var total: int = int(round(float(candidates.size()) * float(db.combat.variation.percent_of_cells) / 100.0)) \
+ (int(encounter.level_index) - 1) * int(db.combat.variation.per_level_bonus)
total = mini(total, candidates.size())
_place_variants_apply(board, candidates, stream, vrow, total)
# 变异片生长施加面(布置域=候选格,数量=total,vrow 单型贯穿;取号序=stream 顺序消费)
func _place_variants_apply(board: BoardData, candidates: Array[Vector2i], stream: RandomNumberGenerator, vrow: Dictionary, total: int) -> void:
var placed := {}
while placed.size() < total:
var pack_size: int = mini(total - placed.size(), stream.randi_range(2, int(db.combat.variation.pack_max)))
var free: Array[Vector2i] = []
for c in candidates:
# 新片种子/生长格禁与其他已布片 8 邻贴接——否则两片粘连成一个超 pack_max 的连通分量
# (R3 随机流下实测暴露:独立生长的相邻片合流击穿单片上限断言)
if placed.has(c) or _adjacent_to_packs(board, placed, c):
continue
free.append(c)
if free.is_empty():
break # 生长域耗尽——该片就地收束(数量不足总目标为合法态,候选域碎片化时触发)
var seed_c: Vector2i = free[stream.randi_range(0, free.size() - 1)]
placed[seed_c] = true
var pack: Array[Variant] = [seed_c]
var pack_set := {seed_c: true}
while pack.size() < pack_size:
var fringe := _variants_fringe(board, pack, candidates, placed, pack_set)
if fringe.is_empty():
break # 生长域耗尽——该片就地收束(数量不足总目标为合法态,候选域碎片化时触发)
var nxt: Vector2i = fringe[stream.randi_range(0, fringe.size() - 1)]
placed[nxt] = true
pack_set[nxt] = true
pack.append(nxt)
for c in placed.keys():
board.variants[c] = [String(vrow.variant_id)]
# 片生长待选格收集(本片内各生长格的 8 邻候选并集——未布置、未在途、不与他片贴接)
func _variants_fringe(board: BoardData, pack: Array, candidates: Array[Vector2i], placed: Dictionary, pack_set: Dictionary) -> Array[Vector2i]:
var fringe: Array[Vector2i] = []
for c in pack:
for n in board.neighbors8(c):
if candidates.has(n) and not placed.has(n) and not fringe.has(n) \
and not _adjacent_to_packs(board, placed, n, pack_set):
fringe.append(n)
return fringe
# 候选格是否与其他已布片(own_pack 之外的 placed 格)8 邻贴接——片间隔离保证连通分量 = 单片 ≤ pack_max
func _adjacent_to_packs(board: BoardData, placed: Dictionary, c: Vector2i, own_pack: Dictionary = {}) -> bool:
for m in board.neighbors8(c):
if placed.has(m) and not own_pack.has(m):
return true
return false
# ---- 事件缓冲(轮 facts 收集;结算触发 = Waiting 相位编排点,不依赖到达序)----
func _on_cell_revealed(evt: Dictionary) -> void:
# 轮 facts 缓冲不设相位守卫——轮末最后一挖的揭示事件在 request_dig 翻相之后才到(事件序:
# cell_revealed 批 → seal_judged 批 → round_end),phase==Active 守卫会丢最后一挖的事实;
# initial 通道事件(install 初始展开——封锁格)resource=false,进入授予段被资格检查自然跳过。
# fx_reveal 通道事件旁路缓冲(专长揭示的奖励由 fx_host flush_pending_reveals 兑付点
# 直接入账,此处缓冲会导致二次兑付)。
if battle_result == "" and not _fx_host.reveal_busy():
# 积点自动兑付:
# 满 charge_n 直接当轮 grant_ap,无玩家发起入口、无积点账中转〔账即时清空,battle 域重置
# 自然无感〕。落位=揭示缓冲点——事件序 cell_revealed 批先于本格扣费,AP 归零恰逢积点满时
# 先兑再扣,轮次不因本格结束;授予段已无积点段(本段唯一计数点)。fx_reveal 旁路不计数=
# 缓冲路径口径〔天眼开格不计积点〕。grant_ap 相位防御在 ACTIVE 挖格链内恒过)
if String(evt.get("kind", "")) == "safe":
var cs_row: Dictionary[Variant, Variant] = _attune_charge_safe_row()
if not cs_row.is_empty():
_attune_safe_seen += 1
var cn_b: int = int(cs_row.get("charge_n", 999))
if cn_b > 0 and _attune_safe_seen % cn_b == 0:
action_economy.grant_ap(int(cs_row.get("magnitude", 1)))
if bool(evt.get("resource", false)):
# 身份快照随缓冲捕获(批次隔离——轮内刷新后 mapping 已换批,授予必须用缓冲期身份,
# 防旧图已挖格在轮末错拿新图同坐标身份)。危险格同样携带(雷安同池——负向兑付的来源格)
var snap = _reward_economy.query_full_snapshot(evt.cell)
if snap != null:
evt = evt.duplicate()
evt["reward_snapshot"] = snap
_facts_reveals.append(evt)
func _on_seal_judged(evt: Dictionary) -> void:
_facts_seals.append(evt)
func _on_round_end(_payload: Dictionary) -> void:
_facts_path = String(_payload.get("path_tag", ""))
_maybe_settle()
func _maybe_settle() -> void:
if _settling or battle_result != "":
return
if action_economy.phase != action_economy.PHASE_WAITING:
return
_settle()
# ---- 每轮结算序列(五段——段序骨架冻结于 SettlementTable.segment_order)----
func _settle() -> void:
_settling = true
var ri := round_index
# 段序编排(五段——段体归 game/core/settlement/,本类只做段序+行发射/开单原语单点)
_grant_seg.run(ri) # ① 授予段(案C 闸随段装载)
_aura_seg.run(ri) # ② 增益/减益段(续存实例组合行)
_combat_seg.run(ri) # ③ 战斗段(主角先手 → 逐敌还手 → 地图伤害账殿后〔案C〕)
_decay_seg.run(ri) # ④ 衰减段(恰一次恒执行含末轮——先兑付后衰减)
var verdict: String = _verdict_seg.run(ri) # ⑤ 判定段(防御性优先级 hero 先判)
if verdict != "continue":
return # 终局:_settling 不回置——_battle_over 已接管
_settling = false
# ---- 授予段 helpers(极性路由)----
# 开格产出乘区(贪婪矿灯/矿脉直觉/赌徒的血签事件收集器注入):
# positive=power ×(1+静态 mult%);negative=power ×(1+cost%)。
# 全零收集器=原快照原样返回(零扰动)。
func _feat_yield_adjust(snap: Dictionary, positive: bool, pos_m: int, neg_m: int) -> Dictionary:
if positive and pos_m <= 0:
return snap
if not positive and neg_m <= 0:
return snap
var out: Dictionary[Variant, Variant] = snap.duplicate()
var power := int(out.get("power", int(_reward_economy.polarity_row(String(out.get("cell_type", "")), "positive" if positive else "negative").power)))
var pct := pos_m if positive else neg_m
out["power"] = maxi(1, int(round(float(power) * (1.0 + float(pct) / 100.0))))
return out
# 伤害型变异过滤(terrain_kind fact 取数面——variation 表 effect=flat_damage 判据;
# 🔴 R4 甲永不对冲边界=仅限伤害型,信息型(hide_number 族)永不出现在本集)
func _harmful_variants(vids: Array) -> Array:
var out: Array[Variant] = []
for vid_v in vids:
var vid := String(vid_v)
var vrow: Dictionary[Variant, Variant] = db.combat.variation.row_by_id(vid)
if String(vrow.get("effect", "")) == "flat_damage":
out.append(vid)
return out
# 邻接伤害型变异收集(terrain_kind fact 取数面——seal_hit 发射点用:封印命中相邻八格的伤害型变异集;
# 🔴 R4 甲永不对冲边界=仅限伤害型,信息型〔hide_number 族〕不入集)
func _harmful_adjacent(cell: Vector2i) -> Array:
var out: Array[Variant] = []
var board: BoardData = minesweeper.current_board()
if board == null:
return out
for n in board.neighbors8(cell):
out.append_array(_harmful_variants(board.variant_ids(n)))
return out
# 效果命中过滤(按效果类型取命中子集——发射点分桶施加面)
func _hits_of(hits: Array, kind: String) -> Array:
var out: Array[Variant] = []
for h in hits:
if String((h as Dictionary).get("kind", "")) == kind:
out.append(h)
return out
# aux 解锁门控转发(LevelRunner 注入;E1/E3 封装:外部经公有面,禁直读 _reward_economy)
func set_treasure_unlocked(ids: Dictionary) -> void:
_reward_economy.set_treasure_unlocked(ids)
# ---- 开轮摇号(共识点 B:技能选择前置到轮次开始,数值计算留结算段)----
# 每实例:保底判定(连续执行普攻 ≥ M → 必出特殊技、特殊技内按权重、不受冷却限制)→ 排除冷却中技能
# (中签率 = 权重 ÷ 可摇权重合计——份额归一)→ 按行序逐敌从统一流顺序取号摇定本拍动作。
# 随机消费只在换算层(本方法 + SkillLottery);单位容器收确定值(零随机纪律——armor_roll 先例同构)。
# 统一流:behavior_roll 无子流派生向量(位置交错,单点定位由事件日志承载)。
# 摇定后、兑现前死亡 → 本拍技能作废。
func _roll_round_actions() -> void:
var pity_m: int = int(db.combat.skill.pity_threshold)
# facts 组包(开轮摇号前一次成型——last_round_end 空字典首轮映射 none 唯一落点;hero 事实不进 facts,
# 容器内直读主角行)
var facts := {
"round_index": round_index,
"last_round_end": "none" if action_economy.last_round_end().is_empty() else String(action_economy.last_round_end().get("path_tag", "none")),
"mines_hit_last": int(_mines_hits["last"]),
"mines_hit_total": int(_mines_hits["total"]),
}
for u in _enemy_system.query_alive("enemy"):
# Boss 半血解锁闩(迭代14④ T4——开轮摇号前逐敌检查;一次过门单向闩;解锁出一次归因明细行
# 「XX 解锁了 YY」带 skill_unlock 高光键〔type=highlight 既有行型零新增〕,呈现层弹一次性
# 技能名提示〔轻量 toast 非演出——D8 呈现面〕。门=确定性谓词,机制面零随机,摇号随机
# 消费仍在 behavior_roll 既有流——同种子逐位复现不破坏)
for sid in _enemy_system.check_trigger_unlocks(u, facts):
var srow_u: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(sid)
_emit_row({"type": "highlight", "source_class": "enemy_action", "origin_cell": Vector2i(-1, -1), "highlight": "skill_unlock"},
round_index, "%s 解锁了 %s" % [String(u.display_name), String(srow_u.get("display_name", sid))],
0, {"skill_id": sid})
# enemy_hp_threshold 发射点(内容数据驱动扩展批 T1——血量阈值沿;对所有带该字段敌人逐个发射,
# 非 Boss 专属——Boss 语义留给订阅者 when 判定。pct=本次解锁条目的 trigger 行值)
var pct_v := 0
for pe in (u.get("pool", []) as Array):
var pe_d: Dictionary[Variant, Variant] = pe
if String(pe_d.get("skill_id", "")) == sid and (pe_d.get("trigger", {}) as Dictionary).has("value"):
pct_v = int((pe_d.get("trigger", {}) as Dictionary).get("value", 0))
break
# target_attack_bonus 施加面(处刑台:敌血量阈值沿挂单位字典标记,
# 主角攻击面 _resolve_hero_attack 通用消费——fx_atk_bonus 键加算进 base_value)
var eht_hits := _fx_host.fx_emit("enemy_hp_threshold", {"unit": String(u.enemy_id), "pct": pct_v,
"is_boss": String(encounter.get("node_type", "")) == "boss"})
for h in eht_hits:
if String(h.get("kind", "")) == "target_attack_bonus":
u["fx_atk_bonus"] = int(u.get("fx_atk_bonus", 0)) + int(h.get("value", 0))
var entries: Array[Variant] = _enemy_system.rollable_skills(u, facts)
if _enemy_system.basic_streak(u) >= pity_m:
var specials: Array[Variant] = _enemy_system.special_skills(u, facts)
if not specials.is_empty():
entries = specials # 保底命中——普攻恒可摇 → 非保底路径池恒非空,此为唯一替代集合
var stream: RandomNumberGenerator = rng.open_stream("behavior_roll")
_enemy_system.set_pending_action(u, SkillLottery.draw(entries, stream))
_enemy_system.tick_cooldowns() # 递减在摇号读值之后——"施放后连续 N 拍不可摇中"的等价实现(单位容器 注记)
_mines_hits["last"] = _mines_hits["round"] # 轮次真正结束转存(轮内部分结算不更新)
_mines_hits["round"] = 0
# ---- 战斗段/算术读数(段体与掷点/折扣/暴击/克制/池乘区/下限/交付管线归 damage_controller.gd
# 与 settlement/combat_segment.gd;本类只保留薄委托——_defender_ac 供结算/呈现快照消费)----
# 防守方护甲等级读数——实现归 伤害控制器 defender_ac(本委托供结算快照/呈现快照)
func _defender_ac(unit: Dictionary) -> int:
return _damage.defender_ac(unit)
func _pool_effective(effect_id: String) -> int:
var side := "hero"
var row: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(effect_id)
if not row.is_empty():
side = String(row.target_side)
for eff in _status_effects.query_active(side):
if eff.effect_id == effect_id:
return eff.effective_value
return 0
func _emit_composition_effects(side: String, ri: int) -> void:
for eff in _status_effects.query_active(side):
if eff.round_index < ri:
# 续存实例(前轮授予)——每结算兑付一次的可见面(合并实例只持首授归因——增益状态 义务⑦)
_emit_row({"type": "effect", "source_class": eff.source_class, "origin_cell": eff.origin_cell}, ri,
eff.display_name, eff.snap_magnitude, {"effect_id": eff.effect_id, "pool_value": eff.effective_value})
# ---- 结算快照(playtest 数值分析数据面——每次结算全量状态;运行时零决策读取)----
func _emit_settlement_snapshot(verdict: String) -> void:
var enemies: Array[Dictionary] = []
for u in _enemy_system.query_alive("enemy"):
enemies.append({"id": String(u.enemy_id), "hp": int(u.hp), "max_hp": int(u.max_hp), "atk": int(u.atk), "pending_skill": _enemy_system.pending_action(u), "armor": String(u.get("armor_type", "light")), "armor_class": int(u.get("armor_class", 0))})
var buffs: Array[Dictionary] = []
for eff in _status_effects.query_active("hero"):
buffs.append({"id": eff.effect_id, "side": "hero", "layers": eff.layers, "left": eff.remaining_settlements})
for eff in _status_effects.query_active("enemy"):
buffs.append({"id": eff.effect_id, "side": "enemy", "layers": eff.layers, "left": eff.remaining_settlements})
var mines_hit := 0
var safe_n := 0
for evt in _facts_reveals:
if evt.kind == "mine":
mines_hit += 1
elif evt.kind == "safe":
safe_n += 1
var board_stats := {"seq": _board_seq}
if minesweeper.current_board() != null:
board_stats["w"] = minesweeper.current_board().width
board_stats["h"] = minesweeper.current_board().height
board_stats["revealed"] = minesweeper.revealed_cells().size()
board_stats["seals"] = minesweeper.seal_count()
settlement_snapshot.emit({
"kind": "settlement",
"level": int(encounter.get("level_index", 0)),
"depth": int(encounter.get("node_depth", 0)),
"round": round_index,
"verdict": verdict,
"unit_count": enemies.size(),
"spawn_count": _enemy_system.all_enemies().size(), # 本战斗实例化总数(含死亡残位)——unit_count=0 时区分「首轮全灭」vs「空场」
"hero": {"hp": int(_enemy_system.hero_unit().get("hp", 0)), "max_hp": int(_enemy_system.hero_unit().get("max_hp", 0)), "atk": int(_enemy_system.hero_unit().get("atk", 0)), "armor_class": _defender_ac(_enemy_system.hero_unit())},
"economy": {"phase": String(action_economy.phase), "ap": int(action_economy.ap), "rap": int(action_economy.rap), "refresh_used": _refresh_used_this_round},
"board": board_stats,
"enemies": enemies,
"buffs": buffs,
"round_facts": {"path": _facts_path, "safe": safe_n, "mines": mines_hit, "seal_events": _facts_seals.size(), },
})
# ---- 出口分发与战斗结束序列 ----
func _exit_distribution() -> void:
var mode := "same_board"
if _facts_path == "cleared":
mode = "new_board" # cleared 轮恒走 new_board(契约义务);非清图轮恒同图(等待期刷新通道已移除)
if mode == "new_board":
_reward_economy.retire_mapping()
minesweeper.retire_board()
if not _install_new_board():
_fatal("换图编排失败")
return
# 工兵晨令续队直调拆除(内容数据驱动扩展批 T4——round_start 订阅行承载,见 begin_battle 注)
action_economy.notify_settlement_done(mode)
# round_start 发射先于行动经济轮切换(queue 型 grant_ap 当轮入队当轮兑现——与首轮同口径)
round_index += 1
_fx_host.emit_round_start()
action_economy.notify_round_start()
_reset_round_facts()
_refresh_used_this_round = 0
_roll_round_actions() # 开轮摇号——每轮恰摇一次(轮内刷新换图不重摇:refresh 不走本通道)
round_started.emit(round_index)
# 呈现层查询面(HUD 刷新按钮可用性)——挖掘期唯一通道
func can_refresh() -> bool:
return battle_result == "" \
and _refresh_used_this_round < int(db.run.action_economy.refresh_cap_per_round) \
and action_economy.phase == action_economy.PHASE_ACTIVE \
and action_economy.rap >= int(db.run.action_economy.rap_refresh_cost)
# 玩家经 HUD框架发起、战斗结算 代理调用(禁止 RAP 满自动刷)。
# 挖掘期唯一通道:Active 相位立即换图
# 继续挖(RAP -8;AP 保留、相位不动、不触发结算);非挖掘期请求一律拒绝——非清图轮恒同图。
func player_request_refresh() -> bool:
if not can_refresh():
return false
if not action_economy.request_refresh():
return false
_refresh_used_this_round += 1
_reward_economy.retire_mapping() # 旧图批次退役(地图战斗资源 Ready→retired;E6 新批重置前提)
minesweeper.retire_board(true) # 旧板退役(轮内通道)
return _install_new_board(true) # 当场换图——失败即编排违例 fail-fast
func _battle_over(result: String) -> void:
battle_result = result
# battle_won 发射+施加(战地包扎 heal 订阅面;败北不发射。heal=英雄单位回血
# 〔封顶单位上限〕,战后生命经 hero_hp_left 回写冒险账本——时点在捕获前=回血计入带入下一层)
if result == "victory":
for h in _fx_host.fx_emit("battle_won", {"round": round_index}):
if String(h.get("kind", "")) == "heal":
var hu_w: Dictionary[Variant, Variant] = _enemy_system.hero_unit()
hu_w["hp"] = mini(int(hu_w.get("hp", 0)) + int(h.get("value", 0)), maxi(1, int(hu_w.get("max_hp", 1))))
# bonus_delta 战末汇入(血税双向账:本战累积经加算日记单写原语汇入获得日记三层后清零)
var drained := _fx_host.bonus_delta_drain()
if _journal() != null:
for axis in drained.keys():
_journal().gain_bonus(String(axis), int(drained[axis]), "battle_export", String(axis))
# 映射随战斗结束退役(地图战斗资源 义务④——下一场战斗 = 新板新 scope 批,roll_board 相位守卫的前提)
var hero_hp_left := int(_enemy_system.hero_unit().get("hp", 0)) # despawn 前捕获(信号载荷供呈现层"HP 带入下一层")
var gp_dropped := _roll_gp_drop() # 战斗掉落(escrow 随此值上抛即弃)
_reward_economy.retire_mapping()
_enemy_system.despawn_all()
_status_effects.notify_battle_end()
if treasure_inventory != null:
treasure_inventory.retire_overlays() # 叠加层随战斗结束清空(库存不动——关卡域跨战斗)
action_economy.notify_combat_end() # → Idle,RAP 清零
db.set_reload_gate(false)
_reset_round_facts()
battle_finished.emit({"result": result, "hero_hp": hero_hp_left, "gp_dropped": gp_dropped})
# 战斗掉落掷定:tier = Boss 节点 ? "boss" : "normal"(elite 留分支位);
# gp_dropped = gp_drop 封闭流掷定(tier 行的 roll_min..roll_max 均匀整数——每战斗末恰一 scope)
# + level_gp_bonus(level_index) + escrow。掷定行缺位 = 数据面缺行(fail 报错按 0 兑付——不哑掉)。
func _roll_gp_drop() -> int:
var tier := "boss" if String(encounter.get("node_type", "")) == "boss" else "normal"
var drops: Dictionary[Variant, Variant] = db.run.shop.gp_drop_row(tier)
var roll := 0
if drops.is_empty():
push_error("[战斗结算] gp_drops 缺行 tier=%s——掉落掷定按 0 兑付" % tier)
else:
var stream: RandomNumberGenerator = rng.open_stream("gp_drop")
roll = stream.randi_range(int(drops.get("roll_min", 0)), int(drops.get("roll_max", 0)))
var drop := roll + db.run.shop.level_gp_bonus_at(int(encounter.get("level_index", 0)))
# 财富之道·聚(战斗掉落 GP 乘算·入账前:只乘战斗掉落两成分〔掷定+关卡加成〕,
# 店内 escrow 不乘〔范围=仅战斗掉落渠道〕;本函数返回值=结算框「GP 增量」行
# 显示源,落在入账前=显示即实得;乘数 provisional 150%)
if (encounter.get("tree_rules", []) as Array).has("economy_hoard"):
drop = int(round(float(drop) * 1.5))
return drop + _reward_grant.escrow
# ---- 呈现状态面(E1 封装收口):呈现层 / headless 自动玩家的唯一读取出口——
# 协作对象(_enemy_system/_status_effects/_reward_economy)私有化后,跨层读取一律经本快照或下列命名面(diagnostic_snapshot 同款模式,
# 禁再链式穿透 settlement.enemy_system.hero 一类路径)。载荷 schema 成文(code-conventions 2.1):
# round / battle_result / phase / ap / rap / can_refresh / hero{hp,max_hp,atk,alive,armor_class} /
# enemies[{order,display_name,hp,max_hp,enemy_id,armor_type,armor_class,pending_skill,pending_type,pending_name,effects[],alive}] /
# (字段面=pending_* 三字段 + 敌方增益 effects 投影;池与权重不公开;警觉走 effects 投影)
# hero_effects[](增益状态 query_active 投影)
func presentation_snapshot() -> Dictionary:
var enemies: Array[Dictionary] = []
var all: Array[Variant] = _enemy_system.all_enemies()
# 敌方增益投影(side-wide 状态——蓄力/硬化/削弱/暴走 percent 行 + 警觉 ac 行)
var enemy_effects: Array[Dictionary] = []
for eff in _status_effects.query_active("enemy"):
var erow: Dictionary[Variant, Variant] = db.combat.status_effects.row_by_id(eff.effect_id)
if not erow.is_empty() and String(erow.unit) in ["percent", "ac"]:
enemy_effects.append({"effect_id": eff.effect_id, "display_name": String(eff.display_name), "layers": int(eff.layers)})
for i in all.size():
var u: Dictionary[Variant, Variant] = all[i]
var alive: bool = bool(u.get("alive", false))
var pending: String = String(u.get("pending_skill", "")) if alive else ""
var pending_row: Dictionary[Variant, Variant] = db.combat.skill.row_by_id(pending)
enemies.append({
"order": i + 1,
"display_name": String(u.display_name),
"hp": int(u.hp), "max_hp": int(u.max_hp),
"enemy_id": String(u.enemy_id),
"armor_type": String(u.get("armor_type", "light")),
"armor_class": int(u.get("armor_class", 0)),
# 敌人技能池批(批 2 定稿):摇定动作三字段——普通攻击零表示 / 特殊技类型级动画占位。
# 池与权重不公开(proposal D4/D5)——实例池的权重/冷却不投影,实例池可见面 = 摇定结果本身。
"pending_skill": pending,
"pending_type": String(pending_row.get("type", "")) if not pending_row.is_empty() else "",
"pending_name": String(pending_row.get("display_name", "")) if not pending_row.is_empty() else "",
"effects": enemy_effects,
"alive": alive,
})
var hero := {}
var h: Dictionary[Variant, Variant] = _enemy_system.hero_unit()
if not h.is_empty():
hero = {"hp": int(h.hp), "max_hp": int(h.max_hp), "atk": int(h.atk), "alive": bool(h.get("alive", false)), "armor_class": _defender_ac(h)}
return {
"round": round_index,
"battle_result": battle_result,
"phase": String(action_economy.phase),
"ap": int(action_economy.ap), "rap": int(action_economy.rap),
"can_refresh": can_refresh(),
"refresh_used": _refresh_used_this_round, # 受理计数入呈现成文面(HUD 余量单源读此处)
"hero": hero,
"enemies": enemies,
"hero_effects": _status_effects.query_active("hero").duplicate(true), # 值拷贝出户(快照消费方直改不渗入状态容器)
# 整图变异投影(呈现横幅/常驻角标唯一读取面;
# 空数组 = 无全局型合法态:普通图与全局池空回落图恒空)
"board_variants": (minesweeper.current_board().board_variant_ids().duplicate()
if minesweeper.current_board() != null else ([] as Array[String])),
}
# 战斗进行中谓词(hero 在场且存活)——组合根高频相位判断的低成本面
func is_battle_live() -> bool:
return battle_result == "" and not _enemy_system.hero_unit().is_empty()
# 奖励身份解析(组合根注入 BoardView 徽章回调的只读代理——Ready 相位守卫归 地图战斗资源)
func query_reward_identity(cell: Vector2i) -> Variant:
return _reward_economy.query_identity(cell)
# 诊断观测面(组合根诊断转储 / 行为探针消费):私有编排状态的成文只读出口——
# 消费方禁止再直读本类下划线字段(封装收口)
func diagnostic_snapshot() -> Dictionary:
return {
"battle_result": battle_result,
"round": round_index,
"settling": _settling,
"refresh_used_round": _refresh_used_this_round,
"board_seq": _board_seq,
"facts_path": _facts_path,
"facts_reveals": _facts_reveals.size(),
"facts_seals": _facts_seals.size(),
"action_economy": {"phase": String(action_economy.phase), "ap": action_economy.ap, "rap": action_economy.rap},
"minesweeper_board_installed": minesweeper.current_board() != null,
"minesweeper_revealed": minesweeper.revealed_cells().size() if minesweeper.current_board() != null else 0,
"minesweeper_seals": minesweeper.seal_count() if minesweeper.current_board() != null else 0,
"reward_economy_phase": _reward_economy.phase(),
"enemies_alive": _enemy_system.query_alive("enemy").size(),
"hero_hp": int(_enemy_system.hero_unit().get("hp", -1)) if not _enemy_system.hero_unit().is_empty() else -1,
}
func _reset_round_facts() -> void:
_facts_reveals.clear()
_facts_seals.clear()
_facts_path = ""
# ---- 行协议(Core Rule:公共字段 + 行类型特有字段)----
func _emit_row(head: Dictionary, ri: int, display: String, value: int, extra: Dictionary) -> void:
# head 归因束封闭键集:type/source_class/origin_cell/highlight(缺省"")——束形只改函数形参,
# 行字典字段与 step_row 载荷逐字不变(行协议词表零触碰)
var type := String(head.get("type", ""))
var source_class := String(head.get("source_class", ""))
var origin_cell: Vector2i = head.get("origin_cell", Vector2i(-1, -1))
var highlight := String(head.get("highlight", ""))
# 扩展行必载效果身份(行协议 fail-fast——违约行先炸后不发,零发射;开发期违约即炸,
# 与 _assert_row_source 同族 assert 形态)
if type == "extended" and String(extra.get("effect_id", "")) == "":
assert(false, "[战斗结算] 扩展行缺 effect_id 载荷——行协议违约")
return
_assert_row_source(type, source_class, origin_cell)
var row := {
"type": type,
"source_class": source_class,
"origin_cell": [origin_cell.x, origin_cell.y],
"round_index": ri,
"display_name": display,
"value": value,
}
if highlight != "":
row["highlight"] = highlight
for k in extra.keys():
row[k] = extra[k]
step_row.emit(row)
# 行来源断言(战斗结算 明细流水:"任何一行找不到来源即断言失败——宁可报错"):
# 来源字段 = source_class(公共字段)+ origin_cell(棋盘来源行的真实格)。
# 豁免按两层成文(见 SOURCE_EXEMPT_TYPES / SOURCE_EXEMPT_CLASSES 常量注释),其余行必须带真实棋盘格。
func _assert_row_source(type: String, source_class: String, origin_cell: Vector2i) -> void:
if SOURCE_EXEMPT_TYPES.has(type):
return
assert(source_class != "", "[战斗结算] 明细行缺来源类别(type=%s)——任何一行找不到来源即断言失败" % type)
if SOURCE_EXEMPT_CLASSES.has(source_class):
return
assert(origin_cell.x >= 0 and origin_cell.y >= 0,
"[战斗结算] 明细行来源格越界(type=%s source=%s cell=%s)——棋盘来源行必须指回真实格" % [type, source_class, str(origin_cell)])
func _fatal(msg: String) -> void:
push_error("[战斗结算/fatal] " + msg)
battle_result = "fatal_error" # release 构建剥离 assert 后的唯一阻断面——受理/结算/刷新入口全查 battle_result == ""
assert(false, msg)
## 完美封印谓词(两账相减:有效误标=误标总数−豁免数)
## 契约 2:_perfect_hit_false 保持纯净,豁免只累加 _perfect_forgiven,禁回退式改写与拦截式吞事件。
func perfect_sweep_earned() -> bool:
return _perfect_hit_false - _perfect_forgiven <= 0 and _perfect_hit_true >= 1