feat(combat): 伤害管线单据化第一步——伤害控制器统一入口(五调用点收口+死亡事件信号+段界flush挂点,开关=A逐位等价迁移前,冒烟557全绿,2026-09-24 台账 148 批)

This commit is contained in:
DKChan committed 2026-09-24 03:47:04 +08:00
1 parent 291c5aa32c
commit edc7eb1fd6
4 files changed
+446 -154

No files matched your search

@@ -11,6 +11,11 @@
# GP+商店房实施批(2026-09-21):授予段极性路由增 GP 型兑付(effect_id gp_gain/gp_loss → 战斗内缓冲
# escrow 记账,不入钱包不入增益容器);战斗结束分支掷 gp_drop 封闭流(每战斗末恰一 scope)出
# gp_dropped = 掷定 + level_gp_bonus + escrow 随 battle_finished 上抛——战斗域零 GP 状态持久。
# 伤害管线单据化批(2026-09-24 用户三裁决,06 台账 148):伤害兑付全部收口 伤害控制器(DamageController
# 单点管线)——本类六调用点(主角先手/敌方伤害技/棋盘克制直伤/群伤/反噬/地形变异)退化为开单点
# (流程决定何时开单给谁开单;单据怎么算归控制器)。结账时机开关在控制器 policy:IMMEDIATE(A=开单
# 即结,逐位等价迁移前行为——回切位)/ SEGMENT(B=攒批到段界 flush:授予段尾〔含清图批前〕+ 战斗段尾)。
# 死亡事件 = 单位容器 unit_died(单位自行宣布)→ 控制器作废该敌未结单据〔死亡即不动手〕/ 主角死终止兑付。
# 切片登记:① 事件总线载体 = 直连信号(实现期 ADR 项,provisional)② Waiting 相位刷新通道 2026-09-15 用户拍板整体
# 拆除(仅 Active 挖掘期受理)③ 效果行协议 = 授予段逐 grant 行 + 增益/减益段续存实例组合行。
# 实现 GDD:design/gdd/systems/战斗循环/战斗结算.md(v0.7 格面资源批——registry 追溯锚)
@@ -45,6 +50,7 @@ var action_economy: ActionEconomy
var _enemy_system: UnitContainer
var _status_effects: StatusContainer
var _reward_economy: RewardEconomy
var _damage: DamageController # 伤害控制器(伤害管线单据化批 2026-09-24——单据唯一兑付管线)
var encounter: Dictionary = {}
var round_index := 0
@@ -74,6 +80,8 @@ func _init(db_ref, rng_ref) -> void:
_enemy_system = UnitContainer.new(db)
_status_effects = StatusContainer.new(db)
_reward_economy = RewardEconomy.new(db, rng)
_damage = DamageController.new(db, rng, _enemy_system, _status_effects)
_damage.emit_row_cb = _emit_row # 行协议归属本类(9 类行协议 + 来源断言不外泄)
minesweeper.cell_revealed.connect(_on_cell_revealed)
minesweeper.flag_judged.connect(_on_flag_judged)
minesweeper.round_end_board.connect(_on_round_end)
@@ -88,6 +96,7 @@ func begin_battle(enc: Dictionary) -> bool:
return false
encounter = enc
escrow = 0 # 战斗域零 GP 持久(GP+商店房实施批——每场战斗缓冲从零起算)
_damage.set_hero_ac_override(int(enc.get("hero_armor_class", -1))) # 主角护甲合流入参(伤害控制器 defender_ac 消费——口径同原 _defender_ac)
db.set_reload_gate(true)
if not _status_effects.notify_battle_start():
_fatal("增益状态 开域被拒(编排违例)")
@@ -300,13 +309,15 @@ func _settle() -> void:
var vrow: Dictionary = db.variation.row_by_id(String(vid))
if String(vrow.get("effect", "")) != "flat_damage":
continue # 触发词表按 effect 路由:flat_damage=on_open 兑现;hide_number/on_open 外组合 = 数据行新词表未接线(显式忽略面)
var dmg: int = maxi(int(db.settlement.damage_floor), int(vrow.get("effect_value", 0))) # 伤害下限全来源(2026-09-20)
_enemy_system.apply_damage(_enemy_system.hero_unit(), dmg)
_emit_row("damage", "地形变异", evt.cell, ri, "%s → 主角" % String(vrow.get("display_name", vid)), dmg, {"target": "hero", "variant_id": String(vid)}, "")
# 地形变异伤害单据(伤害管线单据化批 2026-09-24)——无类型必中,下限保底在控制器管线内
var vt := _damage.open_ticket(DamageController.KIND_BOARD_FLAT_HERO, "地形变异", String(vrow.get("display_name", vid)), "board", \
int(vrow.get("effect_value", 0)), DamageController.TARGET_HERO, ri, evt.cell, "", 0, "", {"variant_id": String(vid)})
_damage.submit(vt)
for evt in _facts_flags:
_judge_flag(evt, ri)
if _facts_path == "cleared":
_grant_clear_reward(ri)
_damage.flush() # 授予段界结账(B 模式——段内开单攒批到此统一兑现;A 模式 no-op;清图批前另有同步结账点)
# ② 增益/减益段(续存实例组合行;本结算新授予的已在授予段逐 grant 出行——增益状态 义务⑦)
_emit_composition_effects("hero", ri)
_emit_composition_effects("enemy", ri)
@@ -385,14 +396,14 @@ func _apply_gp_row(row: Dictionary, source_class: String, cell: Vector2i, ri: in
return true
# flat 行兑付(负向恒即时,无库存语义):
# blast 正向 = 全体存活敌各 power 点(无敌 → 奖励未生效行);blast 负向 = 警觉 percent 行(effect_id=alert,走 _apply_percent_row 不入本函数);
# heal 正向 = 主角回复 clamp(满血 → 奖励未生效行);heal 负向 = 主角直伤 power 点(不经增益状态;归零 → 战斗段短路/判定段判负)。
# pierce/magic(伤害道具批 2026-09-18)正向 = 单目标克制直伤(必中全额);负向 = 主角直伤。
# 伤害落地全局下限(2026-09-20 用户拍板「任何伤害取值域 [1,+∞)」):本函数全部伤害兑付点经 damage_floor 保底——
# 克制修正后触底、表值误配 ≤0 都不可能打出 0/负伤害(v=0 伤害行语义早已退役,本批把棋盘侧从"表值碰巧 ≥1"升级为结构保证)。
# 返回 "redeemed" = 实际有效兑付 / "zero" = 零兑付(行已自记)——清图高光只记实际有效兑付。
func _apply_flat_row(row: Dictionary, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "") -> String:
# flat 行兑付(负向恒即时,无库存语义):伤害型分支全部改开单据(伤害管线单据化批 2026-09-24——
# 管线/下限/目标解析归 伤害控制器,本函数只保留非伤害分支与单据装配):
# action = 行动点排队(非伤害);blast 正向 = 群伤单据(无类型恒定表值打全体存活敌;负向 = 警觉 percent 行走 _apply_percent_row);
# heal 正向 = 主角回复 clamp(满血 → 奖励未生效行);heal 负向 = 主角直伤单据(不经增益状态);
# pierce/magic 正向 = 单目标克制直伤单据(attack_type 查克制矩阵,必中全额不掷点);负向 = 主角直伤单据。
# 返回 "redeemed" = 实际有效兑付 / "zero" = 零兑付(行已自记)——读数 = 控制器结账 outcome(A 模式提交即结)。
# sync = 同步开单(清图批——流程依赖完美第二份/高光份数读数,无视结账政策当场结账)。
func _apply_flat_row(row: Dictionary, source_class: String, cell: Vector2i, ri: int, highlight: String = "", label_suffix: String = "", sync := false) -> String:
var ctype := String(row.cell_type)
var power := int(row.power)
var display := String(row.display_name) + label_suffix
@@ -402,39 +413,21 @@ func _apply_flat_row(row: Dictionary, source_class: String, cell: Vector2i, ri:
_emit_row("effect", source_class, cell, ri, "%s(下轮行动点 %+d)" % [display, delta], delta, {"effect_id": "ap_delta", "pool_value": action_economy.pending_ap_delta()}, highlight)
return "redeemed"
if ctype == "blast":
var alive: Array = _enemy_system.query_alive("enemy")
if alive.is_empty():
_emit_row("reward_ineffective", source_class, cell, ri, "%s 未生效(无存活敌人)" % display, 0, {"batch_key": String(row.reward_id)}, highlight)
return "zero"
var blast_dmg: int = maxi(int(db.settlement.damage_floor), power) # 伤害下限全来源(2026-09-20)
for u in alive:
_enemy_system.apply_damage(u, blast_dmg) # 恒定表值(下限保底),逐个;受击即死——死亡即不动手
_emit_row("damage", source_class, cell, ri, "%s → 全体敌人 ×%d" % [display, alive.size()], blast_dmg, {"target": "all_enemies", "count": alive.size()}, highlight)
return "redeemed"
var t := _damage.open_ticket(DamageController.KIND_BOARD_BLAST, source_class, display, "board", power, \
DamageController.TARGET_ALL_ALIVE, ri, cell, "", 0, highlight, {"batch_key": String(row.reward_id)})
return _damage.outcome(_submit(t, sync))
if ctype == "pierce" or ctype == "magic":
# 伤害道具批(2026-09-18):穿刺/魔法两型——正向 = 单目标克制直伤(attack_type 字段查克制矩阵,
# 必中全额不掷点——棋盘来源伤害既有口径;目标 = 固定顺位最前存活敌);负向 = 主角直伤(复用反噬通道)。
# 伤害道具批(2026-09-18)语义不变:正向 = 单目标克制直伤(必中全额不掷点——棋盘来源伤害既有口径);
# 负向 = 主角直伤(复用反噬通道)。
if String(row.get("polarity", "positive")) == "negative":
var backfire: int = maxi(int(db.settlement.damage_floor), power) # 伤害下限全来源(2026-09-20)
_enemy_system.apply_damage(_enemy_system.hero_unit(), backfire)
_emit_row("damage", source_class, cell, ri, "%s → 主角" % display, backfire, {"target": "hero"}, highlight)
return "redeemed"
var target = _enemy_system.default_target()
if target == null:
_emit_row("reward_ineffective", source_class, cell, ri, "%s 未生效(无存活敌人)" % display, 0, {"batch_key": String(row.reward_id)}, highlight)
return "zero"
return _submit_flat_hero(source_class, display, power, cell, ri, highlight, sync)
var atk_type: String = String(row.get("attack_type", ctype))
var delta: int = _counter_delta(atk_type, String(target.get("armor_type", "light")))
var dealt: int = DamageMath.counter_component(power, delta, int(db.settlement.damage_floor))
_enemy_system.apply_damage(target, dealt)
_emit_row("damage", source_class, cell, ri, "%s → %s" % [display, String(target.display_name)], dealt, {"target": String(target.display_name), "attack_type": atk_type, "effectiveness": delta}, highlight)
return "redeemed"
var t2 := _damage.open_ticket(DamageController.KIND_BOARD_TYPED, source_class, display, "board", power, \
DamageController.TARGET_FRONT_ALIVE, ri, cell, atk_type, 0, highlight, {"batch_key": String(row.reward_id)})
return _damage.outcome(_submit(t2, sync))
if ctype == "heal":
if String(row.get("polarity", "positive")) == "negative":
var backlash: int = maxi(int(db.settlement.damage_floor), power) # 伤害下限全来源(2026-09-20)
_enemy_system.apply_damage(_enemy_system.hero_unit(), backlash)
_emit_row("damage", source_class, cell, ri, "%s → 主角" % display, backlash, {"target": "hero"}, highlight)
return "redeemed"
return _submit_flat_hero(source_class, display, power, cell, ri, highlight, sync)
if int(_enemy_system.hero_unit().get("hp", 0)) <= 0:
# 主角已倒下(同轮更早的负向直伤致死)——治疗不再合法作用,按"奖励未生效"留痕
_emit_row("reward_ineffective", source_class, cell, ri, "%s 未生效(主角已倒下)" % display, 0, {"batch_key": String(row.reward_id)}, highlight)
@@ -449,6 +442,19 @@ func _apply_flat_row(row: Dictionary, source_class: String, cell: Vector2i, ri:
return "zero"
# 开单分流:sync(清图批)走控制器同步开单——先按序结清段内排队单据、再当场兑现本单;
# 常规开单按结账时机政策(A 即结 / B 入队)。
func _submit(t: Dictionary, sync: bool) -> int:
return _damage.flush_submit(t) if sync else _damage.submit(t)
# 主角直伤单据(反噬/治疗包负向——无类型必中,下限保底在控制器管线内)
func _submit_flat_hero(source_class: String, display: String, power: int, cell: Vector2i, ri: int, highlight: String, sync := false) -> String:
var t := _damage.open_ticket(DamageController.KIND_BOARD_FLAT_HERO, source_class, display, "board", power, \
DamageController.TARGET_HERO, ri, cell, "", 0, highlight, {})
return _damage.outcome(_submit(t, sync))
# 旗判定批:标中 = 该雷格资源的正向兑付(每雷实例一次;高光 = 强制检查点);
# 标空 = "标旗未中"归因行 + 该安全格资源的负向兑付(同键去重——每格实例一次)。
func _judge_flag(evt: Dictionary, ri: int) -> void:
@@ -496,6 +502,8 @@ func _grant_clear_reward(ri: int) -> void:
# 清图奖励 = 板级独立对象(不属于任何一格):兑付形态按其 cell_type 的正向字典形态走,
# 行值全部取快照(身份批 roll 值)。返回实际有效兑付。
# 同步开单(sync)——完美第二份与高光份数的流程依赖需要同步读数(开单权归流程 用户裁决③);
# 同步路径内先按序结清段内排队单据(B 模式保序),随后本批无视结账政策当场兑现。
func _grant_clear_identity(snap: Dictionary, ri: int, suffix: String) -> bool:
var row := {
"reward_id": String(snap.get("reward_id", "")),
@@ -509,7 +517,7 @@ func _grant_clear_identity(snap: Dictionary, ri: int, suffix: String) -> bool:
var eff := String(row.effect_id)
if eff != "":
return _apply_percent_row(row, "clear_reward", Vector2i(-1, -1), ri, "clear_reward_h2", suffix)
return _apply_flat_row(row, "clear_reward", Vector2i(-1, -1), ri, "clear_reward_h2", suffix) == "redeemed"
return _apply_flat_row(row, "clear_reward", Vector2i(-1, -1), ri, "clear_reward_h2", suffix, true) == "redeemed"
# ---- 开轮摇号(敌人技能池批——共识点 B:技能选择前置到轮次开始,数值计算留结算段)----
@@ -533,6 +541,10 @@ func _roll_round_actions() -> void:
# ---- 战斗段 ----
# 伤害管线单据化批(2026-09-24):本段只开单——主角先手单 + 敌方伤害技单;掷点/折扣/暴击/克制/
# 池乘区/下限/交付/commit 全部收口 伤害控制器(A 模式提交即结=与迁移前逐位一致;B 模式段尾 flush)。
# 开单守卫归流程(用户裁决③——流程决定给谁开单):主角存活+目标存在才开先手单;hero hp=0 立即
# 停开(B 模式下若 flush 中主角倒下,由控制器死亡事件终止余单——两模式同一结果)。
func _combat_segment(ri: int) -> void:
# ⓪ 主角先手攻击(基础攻击恒存在——2026-09-11 用户裁决;先于敌方行动;授予段负向直伤致死 → 致电短路不先手)。
@@ -541,35 +553,13 @@ func _combat_segment(ri: int) -> void:
# 原"基础 + 穿刺/魔法充能三分量"模型随充能系统整体退役——克制策略归冒险前配置(营地换型 / 未来冒险前参数面),
# 局内不再有挖掘攒弹药的第二伤害供给。attack_up 增益经 hero_offense 池乘攻击合计(见池乘区注记)。
# 护甲等级掷点批(2026-09-16):基础分量吃掷点判定(含暴击);棋盘来源伤害不掷不折;
# 任何伤害落地 ≥ damage_floor(全局 max(1,·)——2026-09-20 用户拍板扩全来源:攻击双方/棋盘直伤/地形变异/反噬,
# 取值域 [1,+∞) 无上限)——"被减伤归零 v=0"零值伤害行分支随批退役。
# 任何伤害落地 ≥ damage_floor(全局 max(1,·)——2026-09-20 用户拍板扩全来源,下限在控制器管线单点收口)。
var hero_alive: bool = int(_enemy_system.hero_unit().get("hp", 0)) > 0
var hero_target = _enemy_system.default_target()
if hero_alive and hero_target != null:
var armor: String = String(hero_target.get("armor_type", "light"))
var base_atk_type: String = String(_enemy_system.hero_unit().get("attack_type", "normal")) # 基础分量类型 = 主角表值(营地扩展前置批 2026-09-18)
var roll_read: Dictionary = _armor_roll() # 基础分量掷点(每吃掷点分量逐次掷——掷点只吃基础分量口径不变)
var threshold: int = _defender_ac(hero_target) + int(db.settlement.attack_bonus)
var crit: bool = bool(roll_read.crit)
var discount: int = _roll_discount(int(roll_read.roll), threshold)
var base_val: float = float(_hero_atk) # 基础分量 = 表值攻击裸值(增益直乘旁路已退役——乘区统一走池)
var d_hero: int = DamageMath.rolled_component(base_val, discount, crit, int(db.settlement.crit_multiplier), _counter_delta(base_atk_type, armor))
var payload: Dictionary = {"target": hero_target.display_name}
payload[base_atk_type] = d_hero # 分量键 = 实际攻击类型(归因字段,单分量模型)
payload["roll"] = int(roll_read.roll)
payload["ac"] = threshold
payload["tier"] = discount
if crit:
payload["crit"] = true
# 增益池乘区·主角侧 + 敌方防御池(2026-09-18 结构修复批——hero_offense 乘区接线落地,
# attack_up 普通分量直乘旁路退役:增伤/乏力乘攻击合计〔2026-09-19 充能退役后 = 单分量〕;
# 与既有 enemy_defense 池乘区同段串乘)。四池对称:敌打主角 = hero_defense×enemy_output
# (_resolve_damage);主角打敌 = hero_offense×enemy_defense。
d_hero = DamageMath.pool_damage(d_hero, _clamped_pool("hero_offense"), _clamped_pool("enemy_defense"))
var d_landed: int = maxi(int(db.settlement.damage_floor), d_hero)
_enemy_system.apply_damage(hero_target, d_landed)
payload["killed"] = not hero_target.alive # 击杀标记在扣血后取(原语义不变)
_emit_row("damage", "hero_attack", Vector2i(-1, -1), ri, _attack_label("主角攻击", crit, discount), d_landed, payload)
var ht := _damage.open_ticket(DamageController.KIND_HERO_ATTACK, "hero_attack", "主角攻击", "combat", _hero_atk, \
DamageController.TARGET_FRONT_ALIVE, ri, Vector2i(-1, -1), String(_enemy_system.hero_unit().get("attack_type", "normal")))
_damage.submit(ht)
# 敌方行动(敌人技能池批——本拍动作开轮已摇定,此处按技能行兑现;行序 = 生成序)。
# 死亡即不动手:摇定后、兑现前死亡的敌不在存活序中——本拍技能作废,不 commit、不进冷却。
for u in _enemy_system.query_alive("enemy"):
@@ -589,105 +579,33 @@ func _combat_segment(ri: int) -> void:
_emit_row("cast", "enemy_action", Vector2i(-1, -1), ri, "%s 施放 %s" % [u.display_name, String(srow.display_name)], 0, {"skill_id": skill_id, "effect_id": String(srow.buff_ref), "duration": dur})
_enemy_system.commit_action(u)
continue
# 伤害技(含普攻——倍率恒 1):三乘法链 ① A = 攻击基线 × 技能倍率(判定对象 = 技能基础伤害分量)
# → ② d20 vs 主角 AC(nat20 暴击 ≠ 技能重击,两层严格区分)→ ③ +克制修正点数(敌打主角无甲轴恒中性 delta 0——加法核 2026-09-18 换装)
# → 增益池乘区 → 伤害下限 max(1,·)
# → 必中附加分量 rider(新效应词汇批 2026-09-19:后置加法、必中全额、不进乘区不出暴击——
# proposal-status-vocabulary §2;价位锚 rider_mult=0.75 = AC 折扣档最轻档"不掉档的稳定伤害")。
# 伤害技(含普攻——倍率恒 1)= 敌方伤害技单据:基线 = round(atk×倍率)、rider 预计算归开单侧
# (表值算术零随机),掷点/克制恒中性/hero_defense×enemy_output 池乘区/下限/rider 后置/commit
# 全归控制器管线(兑现序 = 开单序;来源敌已死在兑现时作废——死亡即不动手)。
var baseline: int = int(round(float(u.atk) * float(srow.get("damage_mult", 1.0))))
var rider := 0
if srow.has("rider_mult"):
rider = maxi(int(db.settlement.damage_floor), int(floor(float(u.atk) * float(srow.rider_mult))))
var roll_read_e: Dictionary = _armor_roll()
var threshold_e: int = _defender_ac(_enemy_system.hero_unit()) + int(db.settlement.attack_bonus)
var crit_e: bool = bool(roll_read_e.crit)
var discount_e: int = _roll_discount(int(roll_read_e.roll), threshold_e)
var rolled_base: int = DamageMath.rolled_component(float(baseline), discount_e, crit_e, int(db.settlement.crit_multiplier), 0) # 敌打主角无甲轴恒中性——加法核中性 delta = 0(原 percent 100 退役)
var d_tick := _resolve_damage(rolled_base)
d_tick = maxi(int(db.settlement.damage_floor), d_tick) + rider # rider 后置于伤害下限——独立必中加项,不进乘区
_enemy_system.apply_damage(_enemy_system.hero_unit(), d_tick)
var payload_e: Dictionary = {"target": "hero", "skill_id": skill_id, "baseline": baseline,
"roll": int(roll_read_e.roll), "ac": threshold_e, "tier": discount_e}
if crit_e:
payload_e["crit"] = true
if rider > 0:
payload_e["rider"] = rider
var label_e := _attack_label("%s %s" % [u.display_name, String(srow.display_name)], crit_e, discount_e)
if rider > 0:
label_e += "+附加%d(必中)" % rider
_emit_row("damage", "enemy_action", Vector2i(-1, -1), ri, label_e, d_tick, payload_e)
_enemy_system.commit_action(u) # 调用序③执行上报——技能进冷却 + 保底计数(每结算每存活敌恰一次)
var et := _damage.open_ticket(DamageController.KIND_ENEMY_SKILL, "enemy_action", "%s %s" % [u.display_name, String(srow.display_name)], "combat", \
baseline, DamageController.TARGET_HERO, ri, Vector2i(-1, -1), "", rider)
et["source_unit"] = u
et["context"] = {"skill_id": skill_id, "baseline": baseline}
_damage.submit(et)
_damage.flush() # 战斗段界结账(B 模式——段内开单攒批到此统一兑现;A 模式 no-op)
# 攻击行显示名(掷点档位随行文本可读——2026-09-16 护甲等级掷点批;零值行语义已退役,重档折扣 = "被压制")
func _attack_label(base: String, crit: bool, discount: int) -> String:
if crit:
return base + "(暴击)"
if discount < 100:
return base + "(被压制)"
return base
# ---- 算术读数(伤害管线单据化批 2026-09-24 迁移注记)----
# 掷点/折扣/克制点数/池乘区/攻击行文本已全部迁入 伤害控制器(damage_controller.gd 管线单点);
# 本类仅保留两处薄委托——_defender_ac(快照面消费)/ _clamped_pool(冒烟白盒断言直呼面 smoke_all 沿用)。
# 护甲等级掷点(armor_roll 封闭流——逐次掷逐次取号、逐位可回放):返回 {roll, crit}
func _armor_roll() -> Dictionary:
var stream: RandomNumberGenerator = rng.open_stream(String(db.settlement.armor_roll_stream))
var roll: int = stream.randi_range(1, int(db.settlement.roll_faces))
return {"roll": roll, "crit": roll == int(db.settlement.crit_roll)}
# 掷点折扣读数:nat 最大面恒全额(暴击分支前置),其余查档(缺口 = 阈值 − 掷点)
func _roll_discount(roll: int, threshold: int) -> int:
if roll == int(db.settlement.crit_roll):
return 100
return DamageMath.roll_discount_percent(roll, threshold, db.settlement.roll_tier_bounds, db.settlement.roll_tier_discounts)
# 防守方护甲等级读数(数值轴——与 armor_type 克制属性轴严格分离):敌人 = spawn 期行值快照;
# 主角 = begin_battle 显式入参(换算率标定批 2026-09-23 接线——hero_armor_class 参数此前零消费,
# 永久强化护甲档与冒险加算区护甲轴的生效值在参数处断链、战斗只读表值——护甲行强条目重测暴露);
# 入参 = LevelRunner.effective_ac() 恰是 表值+base_override+bonus 合流,缺参/-1 = 表值兜底
# (冒烟/工具直呼路径逐位不变);ac 型状态效果叠其上(ac_up 层数×幅度——不入任何池,无 clamp)
# 防守方护甲等级读数——实现归 伤害控制器 defender_ac(本委托供结算快照/呈现快照)
func _defender_ac(unit: Dictionary) -> int:
if bool(unit.get("is_hero", false)):
var total: int = int(encounter.get("hero_armor_class", -1))
if total < 0:
total = int(db.hero.armor_class)
for eff in _status_effects.query_active("hero"):
var row: Dictionary = db.status_effect_row(eff.effect_id)
if not row.is_empty() and String(row.unit) == "ac":
total += int(eff.effective_value)
return total
var total: int = int(unit.get("armor_class", 0))
for eff_e in _status_effects.query_active("enemy"):
var erow_ac: Dictionary = db.status_effect_row(eff_e.effect_id)
if not erow_ac.is_empty() and String(erow_ac.unit) == "ac":
total += int(eff_e.effective_value)
return total
# F2 乘区:池内加算 → clamp [-100,+100] → 池间乘算 → floor(单一取整规则)——算术归 DamageMath(O3)
func _resolve_damage(d_raw: int) -> int:
var p_hero := _clamped_pool("hero_defense")
var p_enemy := _clamped_pool("enemy_output")
return DamageMath.pool_damage(d_raw, p_hero, p_enemy)
# 克制修正点数读值(SettlementTable.armor_effectiveness:{攻击类型: {护甲: 点数}}——缺项回退 0 中性;加法核 2026-09-18 换装)
func _counter_delta(attack_type: String, armor: String) -> int:
var tbl: Dictionary = db.settlement.armor_effectiveness
var row: Dictionary = tbl.get(attack_type, {})
return int(row.get(armor, 0))
return _damage.defender_ac(unit)
# 池内加算 → clamp——实现归 伤害控制器 clamped_pool(本委托供冒烟白盒断言 smoke_all 直呼兼容)
func _clamped_pool(pool: String) -> int:
var total := 0
for eff in _status_effects.query_active("hero") + _status_effects.query_active("enemy"):
var row: Dictionary = db.status_effect_row(eff.effect_id)
if row.is_empty():
continue
if String(db.settlement.effect_pool_map.get(eff.effect_id, "")) == pool and String(row.unit) == "percent":
total += eff.effective_value # 池内加算(L×m 原样——越域不 clamp 由本处收口)
return clampi(total, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_hi))
return _damage.clamped_pool(pool)
func _pool_effective(effect_id: String) -> int:
@@ -0,0 +1,367 @@
# VERTICAL SLICE - NOT FOR PRODUCTION
# Date: 2026-09-24
# 伤害控制器:伤害单据唯一兑付管线——伤害管线单据化批(2026-09-24 用户三裁决,06 台账 148)。
# 背景:伤害此前无对象抽象——五族来源(主角攻击/敌方行动/棋盘克制直伤/棋盘群伤/反噬地形)在
# 战斗编排器内各自内联组装管线;类型轴(attack_type)与算术层(DamageMath)早已收口,缺的是
# 「一次伤害」这个对象与单点管线。本类补上:五调用点退化为开单点(流程决定何时开单、给谁开单),
# 本类决定一张单经过哪几步(掷点 → 折扣 → 暴击 → 克制点数 → 池乘区 → 落地下限 → 交付)。
# 用户三裁决落位:
# ① 兑现时机双模式(结账时机开关):POLICY_IMMEDIATE(A——开单即结,逐位等价 2026-09-24 前现状
# 行为,回切位)/ POLICY_SEGMENT(B——开单攒批、段界统一结账;流程在授予段尾与战斗段尾调 flush)。
# 切换=改 policy 一个值,管线/单据/数据表零改动。
# ② 目标 = 结账时刻照 target_rule 看现场解析(front_alive_enemy = 行主序最前存活敌 / hero /
# all_alive_enemies)——开单时只写规则不冻结对象。
# ③ 死亡事件由单位自行宣布(UnitContainer.apply_damage 归零 → unit_died 信号):怪死 = 队列中
# 该敌为来源的未结单据作废(死亡即不动手——静默零行零 commit,同现行"不在存活序"口径);
# 主角死 = _halted 终止(flush 余单作废;Game Over 仍归战斗编排器判定段既有 hp 读数路径)。
# 单据栏目(九栏):kind(兑付配方,唯一分派键)/ source_class+origin_cell+round_index(行协议归因三件)/
# display(行文本基名)/ channel(战斗段|棋盘兑付——声明栏,管线步序由 kind 承载)/
# attack_type(normal|pierce|magic|""=无类型——""恒不查克制矩阵)/ base_value(威力或攻击基线)/
# target_rule / rider(必中附加,后置于下限不进乘区)/ context(kind 专用上下文)。
# 可选:requires(前置单据 id——前置单 redeemed 才兑付,否则静默作废;清图完美第二份用)。
# 行协议归属战斗编排器(emit_row 以 Callable 注入——9 类行协议与来源断言不外泄本类)。
# RNG 纪律:掷点只在结账时刻消费 armor_roll 封闭流——A 模式提交即结=消费时点与迁移前逐位一致,
# B 模式消费次序=开单次序(同种子同操作仍逐位复现;结果数值与 A 模式可不同——兑现时机语义差异)。
# 实现 GDD:design/gdd/systems/战斗循环/战斗结算.md(registry 追溯锚)
class_name DamageController
extends RefCounted
const POLICY_IMMEDIATE := "immediate" # A:开单即结(现状行为——回切位)
const POLICY_SEGMENT := "segment" # B:攒批到段界 flush 统一结(2026-09-24 用户拍板目标形态)
# 兑付配方(kind → 步序):
# hero_attack 战斗段·主角先手:掷点折扣暴击 → 克制点数(attack_type vs 敌护甲类型)→
# hero_offense×enemy_defense 池乘区 → 下限 → 交付;行载荷带 roll/ac/tier/killed。
# enemy_skill 战斗段·敌方伤害技(含普攻):基线 = round(atk×倍率)(开单侧算好入 base_value)→
# 掷点折扣暴击 → 克制恒中性(敌打主角无甲轴 delta 0)→ hero_defense×enemy_output
# 池乘区 → 下限 → +rider(必中附加)→ 交付 → commit_action;来源敌已死 = 作废。
# board_typed 棋盘·单目标克制直伤(穿刺弹/魔法矢正向):必中全额,克制点数进加法核,下限保底。
# board_blast 棋盘·群伤(爆裂粉尘/清障炸药):无类型恒定表值,全体存活敌逐个交付。
# board_flat_hero 棋盘·主角直伤(反噬/治疗包负向/地形变异):无类型必中,下限保底。
const KIND_HERO_ATTACK := "hero_attack"
const KIND_ENEMY_SKILL := "enemy_skill"
const KIND_BOARD_TYPED := "board_typed"
const KIND_BOARD_BLAST := "board_blast"
const KIND_BOARD_FLAT_HERO := "board_flat_hero"
const TARGET_FRONT_ALIVE := "front_alive_enemy"
const TARGET_HERO := "hero"
const TARGET_ALL_ALIVE := "all_alive_enemies"
var db
var rng
var units # UnitContainer——目标解析/伤害交付/存活查询(句柄即 Dictionary 引用)
var effects # StatusContainer——池值/AC 型效果读取(增益状态 只读消费面)
var emit_row_cb: Callable # 行协议注入口:func(type, source_class, origin_cell, ri, display, value, extra, highlight)
var policy := POLICY_IMMEDIATE # 结账时机开关(两步走:第一步 A=现状行为,第二步拨 B)
var _queue: Array = [] # 未结单据(B 模式攒批;A 模式恒空)
var _outcomes := {} # ticket_id → "redeemed"/"zero"/"voided"(结账后可查——清图高光份数消费)
var _seq := 0 # 流水号
var _halted := false # 主角倒下终止旗(死亡事件置位——flush 余单作废)
var _hero_ac_override := -1 # 主角护甲等级合流入参(begin_battle 显式设定——-1 = 表值兜底,口径同战斗编排器)
func _init(db_ref, rng_ref, units_ref, effects_ref) -> void:
db = db_ref
rng = rng_ref
units = units_ref
effects = effects_ref
units.unit_died.connect(_on_unit_died)
# 主角护甲等级合流入参(战斗编排器 begin_battle 调用——encounter.hero_armor_class;缺参/-1 = 表值兜底)
func set_hero_ac_override(v: int) -> void:
_hero_ac_override = v
# ---- 开单面(流程调用——编排器六调用点的唯一形态)----
# 单据构造(缺省填齐——调用点只写差异栏;context = kind 专用:enemy_skill 带 skill_id/baseline,
# board_flat_hero 的键并入行载荷,board_blast/board_typed 带 batch_key 供未生效行归因)
func open_ticket(kind: String, source_class: String, display: String, channel: String, base_value: int,
target_rule: String, ri: int, origin_cell: Vector2i = Vector2i(-1, -1), attack_type: String = "",
rider: int = 0, highlight: String = "", context: Dictionary = {}) -> Dictionary:
return {
"kind": kind,
"source_class": source_class,
"origin_cell": origin_cell,
"round_index": ri,
"display": display,
"channel": channel,
"attack_type": attack_type,
"base_value": base_value,
"target_rule": target_rule,
"rider": rider,
"highlight": highlight,
"context": context,
"requires": -1, # 前置单据 id(-1 = 无依赖)
"source_unit": {}, # enemy_skill 专用——来源敌句柄(作废判定 + commit)
}
# 开单:A 模式当场结账;B 模式入队待 flush。返回流水号(outcome() 查询键)。
func submit(t: Dictionary) -> int:
_seq += 1
t["ticket_id"] = _seq
_outcomes[_seq] = "pending"
if policy == POLICY_IMMEDIATE:
_resolve(t)
else:
_queue.append(t)
return _seq
# 同步开单(清图批专用——完美第二份与高光份数的流程依赖需要同步读数,开单权归流程 用户裁决③):
# 先按开单序结清段内排队的单据(保序),再无视结账时机政策当场兑现本单。
func flush_submit(t: Dictionary) -> int:
flush()
_seq += 1
t["ticket_id"] = _seq
_outcomes[_seq] = "pending"
_resolve(t)
return _seq
# 结账:B 模式按开单序逐张兑现队列(兑现中经 requires 追加的回声单按入队序自然续结);
# A 模式恒空队 no-op(段界调用点两模式共用)。主角倒下(_halted)后余单全部静默作废。
func flush() -> void:
if policy != POLICY_SEGMENT:
return
var i := 0
while i < _queue.size():
if _halted:
break
_resolve(_queue[i])
i += 1
for j in range(i, _queue.size()):
_outcomes[int(_queue[j].ticket_id)] = "voided"
_queue.clear()
# 结账读数:"redeemed"=实际有效兑付 / "zero"=合法零兑付(未生效行已自记)/ "voided"=作废(静默)/
# "pending"=未结(B 模式排队中)
func outcome(ticket_id: int) -> String:
return String(_outcomes.get(ticket_id, "pending"))
# ---- 死亡事件(用户裁决③——单位自行宣布,本类消费)----
func _on_unit_died(u: Dictionary) -> void:
if bool(u.get("is_hero", false)):
_halted = true # 主角倒下——flush 余单作废;Game Over 归判定段既有 hp 读数路径
return
for t in _queue:
if t.source_unit == u:
t["cancelled"] = true # 死亡即不动手——该敌未结单据作废(静默零行零 commit)
# ---- 兑付管线(单点——所有伤害经过同一道门)----
func _resolve(t: Dictionary) -> void:
var id := int(t.ticket_id)
if _halted or bool(t.get("cancelled", false)) \
or (int(t.get("requires", -1)) >= 0 and String(_outcomes.get(int(t.requires), "")) != "redeemed"):
_outcomes[id] = "voided" # 终止后余单 / 来源敌已死 / 前置单未实际有效——静默作废(死亡即不动手口径)
return
match String(t.kind):
KIND_HERO_ATTACK:
_resolve_hero_attack(t)
KIND_ENEMY_SKILL:
_resolve_enemy_skill(t)
KIND_BOARD_TYPED:
_resolve_board_typed(t)
KIND_BOARD_BLAST:
_resolve_board_blast(t)
KIND_BOARD_FLAT_HERO:
_resolve_board_flat_hero(t)
_:
push_error("[伤害控制器/fatal] 未知兑付配方: %s" % String(t.kind))
_outcomes[id] = "zero"
return
# 主角倒下检查(每张单兑现后——敌方连拍致死即中止,同现行"hp=0 立即短路"口径)
if int(units.hero_unit().get("hp", 0)) <= 0:
_halted = true
# 主角先手攻击:掷点 → 折扣/暴击 → 克制点数 → hero_offense×enemy_defense 池乘区 → 下限 → 交付
func _resolve_hero_attack(t: Dictionary) -> void:
var target = _target_for(String(t.target_rule))
if target == null:
_outcomes[int(t.ticket_id)] = "zero" # 不可达(流程开单前已有存活守卫)——防御位不发行
return
var armor: String = String(target.get("armor_type", "light"))
var atk_type: String = String(t.attack_type)
var roll_read: Dictionary = _armor_roll()
var threshold: int = defender_ac(target) + int(db.settlement.attack_bonus)
var crit: bool = bool(roll_read.crit)
var discount: int = _roll_discount(int(roll_read.roll), threshold)
var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.settlement.crit_multiplier), _counter_delta(atk_type, armor))
var payload: Dictionary = {"target": String(target.display_name)}
payload[atk_type] = rolled # 分量键 = 实际攻击类型(归因字段,单分量模型)
payload["roll"] = int(roll_read.roll)
payload["ac"] = threshold
payload["tier"] = discount
if crit:
payload["crit"] = true
var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_offense"), clamped_pool("enemy_defense"))
var landed: int = maxi(int(db.settlement.damage_floor), pooled)
units.apply_damage(target, landed)
payload["killed"] = not target.alive # 击杀标记在扣血后取(原语义不变)
emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), _attack_label(String(t.display), crit, discount), landed, payload, String(t.highlight))
_outcomes[int(t.ticket_id)] = "redeemed"
# 敌方伤害技:掷点 → 折扣/暴击 → 克制恒中性 delta 0(敌打主角无甲轴)→ hero_defense×enemy_output
# 池乘区 → 下限 → +rider(后置必中附加)→ 交付 → commit_action(作废单不 commit——死亡即不动手)
func _resolve_enemy_skill(t: Dictionary) -> void:
var hero: Dictionary = units.hero_unit()
var roll_read: Dictionary = _armor_roll()
var threshold: int = defender_ac(hero) + int(db.settlement.attack_bonus)
var crit: bool = bool(roll_read.crit)
var discount: int = _roll_discount(int(roll_read.roll), threshold)
var rolled: int = DamageMath.rolled_component(float(t.base_value), discount, crit, int(db.settlement.crit_multiplier), 0)
var pooled: int = DamageMath.pool_damage(rolled, clamped_pool("hero_defense"), clamped_pool("enemy_output"))
var total: int = maxi(int(db.settlement.damage_floor), pooled) + int(t.rider) # rider 后置于伤害下限——独立必中加项,不进乘区
units.apply_damage(hero, total)
var payload: Dictionary = {"target": "hero", "skill_id": String(t.context.get("skill_id", "")), "baseline": int(t.context.get("baseline", 0)),
"roll": int(roll_read.roll), "ac": threshold, "tier": discount}
if crit:
payload["crit"] = true
if int(t.rider) > 0:
payload["rider"] = int(t.rider)
var label := _attack_label(String(t.display), crit, discount)
if int(t.rider) > 0:
label += "+附加%d(必中)" % int(t.rider)
emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), label, total, payload, String(t.highlight))
units.commit_action(t.source_unit)
_outcomes[int(t.ticket_id)] = "redeemed"
# 棋盘克制直伤:必中全额不掷点;目标 = 结账时刻最前存活敌;克制点数进加法核;下限保底
func _resolve_board_typed(t: Dictionary) -> void:
var target = units.default_target()
if target == null:
_ineffective_row(t, "无存活敌人")
_outcomes[int(t.ticket_id)] = "zero"
return
var delta: int = _counter_delta(String(t.attack_type), String(target.get("armor_type", "light")))
var dealt: int = DamageMath.counter_component(int(t.base_value), delta, int(db.settlement.damage_floor))
units.apply_damage(target, dealt)
emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), "%s → %s" % [String(t.display), String(target.display_name)], dealt,
{"target": String(target.display_name), "attack_type": String(t.attack_type), "effectiveness": delta}, String(t.highlight))
_outcomes[int(t.ticket_id)] = "redeemed"
# 棋盘群伤:无类型恒定表值,结账时刻全体存活敌逐个交付(单行汇总——行内 count = 兑现时存活数)
func _resolve_board_blast(t: Dictionary) -> void:
var alive: Array = units.query_alive("enemy")
if alive.is_empty():
_ineffective_row(t, "无存活敌人")
_outcomes[int(t.ticket_id)] = "zero"
return
var blast_dmg: int = maxi(int(db.settlement.damage_floor), int(t.base_value)) # 伤害下限全来源(2026-09-20)
for u in alive:
units.apply_damage(u, blast_dmg) # 恒定表值(下限保底),逐个;受击即死——死亡即不动手
emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), "%s → 全体敌人 ×%d" % [String(t.display), alive.size()], blast_dmg,
{"target": "all_enemies", "count": alive.size()}, String(t.highlight))
_outcomes[int(t.ticket_id)] = "redeemed"
# 棋盘主角直伤(反噬/治疗包负向/地形变异):无类型必中,下限保底;context 键并入行载荷
func _resolve_board_flat_hero(t: Dictionary) -> void:
var dealt: int = maxi(int(db.settlement.damage_floor), int(t.base_value)) # 伤害下限全来源(2026-09-20)
units.apply_damage(units.hero_unit(), dealt)
var extra: Dictionary = {"target": "hero"}
for k in t.context.keys():
extra[k] = t.context[k]
emit_row_cb.call("damage", String(t.source_class), t.origin_cell, int(t.round_index), "%s → 主角" % String(t.display), dealt, extra, String(t.highlight))
_outcomes[int(t.ticket_id)] = "redeemed"
# ---- 管线算术读数(自战斗编排器迁入——管线单点;编排器留 _clamped_pool/_defender_ac 薄委托)----
# 护甲等级掷点(armor_roll 封闭流——逐次掷逐次取号、逐位可回放):返回 {roll, crit}
func _armor_roll() -> Dictionary:
var stream: RandomNumberGenerator = rng.open_stream(String(db.settlement.armor_roll_stream))
var roll: int = stream.randi_range(1, int(db.settlement.roll_faces))
return {"roll": roll, "crit": roll == int(db.settlement.crit_roll)}
# 掷点折扣读数:nat 最大面恒全额(暴击分支前置),其余查档(缺口 = 阈值 − 掷点)
func _roll_discount(roll: int, threshold: int) -> int:
if roll == int(db.settlement.crit_roll):
return 100
return DamageMath.roll_discount_percent(roll, threshold, db.settlement.roll_tier_bounds, db.settlement.roll_tier_discounts)
# 防守方护甲等级读数(数值轴——与 armor_type 克制属性轴严格分离):敌人 = spawn 期行值快照;
# 主角 = begin_battle 显式入参(encounter.hero_armor_class 合流值,缺参/-1 = 表值兜底);
# ac 型状态效果叠其上(ac_up 层数×幅度——不入任何池,无 clamp)
func defender_ac(unit: Dictionary) -> int:
if bool(unit.get("is_hero", false)):
var total: int = _hero_ac_override
if total < 0:
total = int(db.hero.armor_class)
for eff in effects.query_active("hero"):
var row: Dictionary = db.status_effect_row(eff.effect_id)
if not row.is_empty() and String(row.unit) == "ac":
total += int(eff.effective_value)
return total
var total_e: int = int(unit.get("armor_class", 0))
for eff_e in effects.query_active("enemy"):
var erow_ac: Dictionary = db.status_effect_row(eff_e.effect_id)
if not erow_ac.is_empty() and String(erow_ac.unit) == "ac":
total_e += int(eff_e.effective_value)
return total_e
# 克制修正点数读值(SettlementTable.armor_effectiveness:{攻击类型: {护甲: 点数}}——缺项回退 0 中性;
# attack_type "" = 无类型恒不查(群伤/反噬/地形/敌方技))
func _counter_delta(attack_type: String, armor: String) -> int:
if attack_type == "":
return 0
var tbl: Dictionary = db.settlement.armor_effectiveness
var row: Dictionary = tbl.get(attack_type, {})
return int(row.get(armor, 0))
# 池内加算 → clamp [-100,+100](池间乘算归 DamageMath.pool_damage 调用侧)
func clamped_pool(pool: String) -> int:
var total := 0
for eff in effects.query_active("hero") + effects.query_active("enemy"):
var row: Dictionary = db.status_effect_row(eff.effect_id)
if row.is_empty():
continue
if String(db.settlement.effect_pool_map.get(eff.effect_id, "")) == pool and String(row.unit) == "percent":
total += eff.effective_value # 池内加算(L×m 原样——越域不 clamp 由本处收口)
return clampi(total, int(db.settlement.pool_clamp_lo), int(db.settlement.pool_clamp_hi))
# 攻击行显示名(掷点档位随行文本可读;重档折扣 = "被压制")
func _attack_label(base: String, crit: bool, discount: int) -> String:
if crit:
return base + "(暴击)"
if discount < 100:
return base + "(被压制)"
return base
# ---- 内部 ----
# 目标解析(裁决②——结账时刻照规则看现场;句柄即 Dictionary 引用)
func _target_for(rule: String):
match rule:
TARGET_FRONT_ALIVE:
return units.default_target()
TARGET_HERO:
return units.hero_unit()
TARGET_ALL_ALIVE:
return units.query_alive("enemy")
push_error("[伤害控制器/fatal] 未知目标规则: %s" % rule)
return null
func _ineffective_row(t: Dictionary, reason: String) -> void:
emit_row_cb.call("reward_ineffective", String(t.source_class), t.origin_cell, int(t.round_index),
"%s 未生效(%s)" % [String(t.display), reason], 0, {"batch_key": String(t.context.get("batch_key", ""))}, String(t.highlight))
@@ -0,0 +1 @@
uid://borexlwy615qe
@@ -15,6 +15,11 @@
class_name UnitContainer
extends RefCounted
# 死亡事件(伤害管线单据化批 2026-09-24 用户裁决③——单位自行宣布死亡):apply_damage 扣血归零、
# alive 翻 false 的同一时点发出;载荷 = 单位句柄(Dictionary 引用,is_hero 区分主角/敌)。
# 消费方 = 伤害控制器(怪死 → 其未结单据作废〔死亡即不动手〕;主角死 → 兑付终止旗)。
signal unit_died(u: Dictionary)
var db
# 单位容器私有(E3 封装收口 2026-09-13):hero/enemies 原始数组不再外露——读取经 hero_unit()/
# all_enemies() 投影面(死亡残位查询包含在内),写入只经 apply_damage/heal/set_pending_action/commit_action/
@@ -204,6 +209,7 @@ func apply_damage(u: Dictionary, n: int) -> bool:
u.hp = maxi(0, u.hp - n)
if u.hp == 0:
u.alive = false # Dead 不可逆(死亡判定时机归 战斗结算)
unit_died.emit(u) # 死亡事件——单位自行宣布(伤害管线单据化批 2026-09-24 用户裁决③)
return true