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

368 lines
19 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
# 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: ConfigDb
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_SEGMENT # 结账时机开关(2026-09-24 用户拍板第二步拨 B=攒批段界统一结;回切 A 改回 POLICY_IMMEDIATE 即还原迁移前行为)
var _queue: Array[Dictionary] = [] # 未结单据(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))