From edc7eb1fd6e3f19c40900b5792511118966161e8 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Thu, 24 Sep 2026 03:47:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(combat):=20=E4=BC=A4=E5=AE=B3=E7=AE=A1?= =?UTF-8?q?=E7=BA=BF=E5=8D=95=E6=8D=AE=E5=8C=96=E7=AC=AC=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=E2=80=94=E2=80=94=E4=BC=A4=E5=AE=B3=E6=8E=A7=E5=88=B6=E5=99=A8?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=85=A5=E5=8F=A3=EF=BC=88=E4=BA=94=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=82=B9=E6=94=B6=E5=8F=A3+=E6=AD=BB=E4=BA=A1?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E4=BF=A1=E5=8F=B7+=E6=AE=B5=E7=95=8Cflush?= =?UTF-8?q?=E6=8C=82=E7=82=B9=EF=BC=8C=E5=BC=80=E5=85=B3=3DA=E9=80=90?= =?UTF-8?q?=E4=BD=8D=E7=AD=89=E4=BB=B7=E8=BF=81=E7=A7=BB=E5=89=8D=EF=BC=8C?= =?UTF-8?q?=E5=86=92=E7=83=9F557=E5=85=A8=E7=BB=BF=EF=BC=8C2026-09-24=20?= =?UTF-8?q?=E5=8F=B0=E8=B4=A6=20148=20=E6=89=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/battle_orchestrator.gd | 226 ++++------- .../core/damage_controller.gd | 367 ++++++++++++++++++ .../core/damage_controller.gd.uid | 1 + .../core/unit_container.gd | 6 + 4 files changed, 446 insertions(+), 154 deletions(-) create mode 100644 prototypes/dungeon-sweeper-vertical-slice/core/damage_controller.gd create mode 100644 prototypes/dungeon-sweeper-vertical-slice/core/damage_controller.gd.uid diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/battle_orchestrator.gd b/prototypes/dungeon-sweeper-vertical-slice/core/battle_orchestrator.gd index 53e433e3..57a68c52 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/core/battle_orchestrator.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/core/battle_orchestrator.gd @@ -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: diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/damage_controller.gd b/prototypes/dungeon-sweeper-vertical-slice/core/damage_controller.gd new file mode 100644 index 00000000..334ac6ed --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/core/damage_controller.gd @@ -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)) diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/damage_controller.gd.uid b/prototypes/dungeon-sweeper-vertical-slice/core/damage_controller.gd.uid new file mode 100644 index 00000000..f4974da2 --- /dev/null +++ b/prototypes/dungeon-sweeper-vertical-slice/core/damage_controller.gd.uid @@ -0,0 +1 @@ +uid://borexlwy615qe diff --git a/prototypes/dungeon-sweeper-vertical-slice/core/unit_container.gd b/prototypes/dungeon-sweeper-vertical-slice/core/unit_container.gd index 02963f60..9d2b1a13 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/core/unit_container.gd +++ b/prototypes/dungeon-sweeper-vertical-slice/core/unit_container.gd @@ -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