diff --git a/design/gdd/systems/战斗循环/战斗结算.md b/design/gdd/systems/战斗循环/战斗结算.md index e8fa6872..518c9b84 100644 --- a/design/gdd/systems/战斗循环/战斗结算.md +++ b/design/gdd/systems/战斗循环/战斗结算.md @@ -137,6 +137,8 @@ percent 类(每结算、每层): E = |层数 × 行值| ÷ 100 × | 旗判定批 | 标中则兑该雷格资源的正向,每颗雷的资格只兑一次;标空则记"标旗未中"归因行,并兑该安全格资源的负向 | | 清图批 | 清图奖励对象恰好发一次;完美排雷追加第二份同身份兑付 | +授予段内的伤害兑付(爆裂、直伤、反噬)以伤害单据入队,攒到授予段尾统一结账;清图批为段内同步结账点——开单前段内排队单先按开单序兑现(见[伤害单据与伤害控制器](#伤害单据与伤害控制器所有伤害走同一道门)一节)。 + **充能已退役(2026-09-19 用户拍板)**:原"挖格/标中旗给充能计数 → 战斗段折算穿刺/魔法分量"的弹药系统整体移除——主角攻击回归单分量,克制策略归冒险前配置(主角 attack_type 表值,营地换型 = 改字段;未来冒险前参数面承接策略位)。攻防两条抽象分离纪律保留:攻击类型是伤害的属性轴,棋盘事实只是奖励触发源。 | 兑付形态 | 覆盖 | 兑付方式 | 拒绝语义 | @@ -159,9 +161,31 @@ percent 类(每结算、每层): E = |层数 × 行值| ÷ 100 × 举证:兑付数值全部读配置表,本节无新公式;完美乘数的入账换算见收益对照表一节。 +### 伤害单据与伤害控制器:所有伤害走同一道门 + +游戏里的每一份伤害——主角先手、敌方技能、穿刺弹/魔法矢、爆裂粉尘与清障炸药、陷阱反噬、地形变异——都是一张**伤害单据**:它自己声明这是一份什么伤害,然后交由**伤害控制器**按唯一管线兑现。单据九个栏目:兑付配方(唯一分派键)、来源类别 + 来源格 + 轮次(明细行归因三件)、显示名、通道(战斗段 / 棋盘兑付——声明栏)、攻击类型(普通 / 穿刺 / 魔法 / **无类型**——无类型恒不查克制矩阵,是群伤、反噬与地形变异的常态而非缺漏)、威力基数、目标规则、必中附加分量。兑付配方封闭枚举为五种: + +| 兑付配方 | 覆盖来源 | 管线步序 | +|---|---|---| +| 主角先手 | 战斗段主角攻击 | 掷点 → 折扣/暴击 → 克制点数 → 主角输出×敌方防御两池乘区 → 下限 → 交付 | +| 敌方伤害技 | 战斗段敌方技能(含普攻) | 掷点 → 折扣/暴击 → 克制恒中性 → 主角防御×敌方输出两池乘区 → 下限 → +必中附加 → 交付 → 执行上报 | +| 单目标克制直伤 | 穿刺弹 / 魔法矢正向 | 必中全额,克制点数进加法核,下限保底 | +| 群伤 | 爆裂粉尘 / 清障炸药 | 无类型恒定表值,全体存活敌逐个交付 | +| 主角直伤 | 陷阱反噬 / 治疗负向 / 地形变异 | 无类型必中,下限保底 | + +职责分界一句话:**流程决定何时开单、给谁开单;控制器决定一张单经过哪几步。** 战斗结算保留全部开单守卫(主角存活且场上有敌才开先手单、主角倒下停开敌方单),掷点、折扣、暴击、克制点数、池乘区、伤害下限、交付与执行上报的算术和次序全部收口控制器单点——未来新增伤害来源 = 填一张单,新增管线环节 = 控制器一处改动,不再逐调用点各写各的管线。 + +**目标在结账时刻解析**:单据只带目标规则(最前存活敌 / 主角 / 全体存活敌),兑现那一刻照规则看现场——前面兑付打死的敌人不会接住后面的单,开单时也不把目标写死在单上。 + +**死亡事件由单位自行宣布**:单位生命归零的那一刻自己发出死亡事件(不由流程代查、不由控制器推断)。怪死 → 队列中该敌为来源的未结单据作废(死亡即不动手——静默零行、不进冷却、不进保底计数,同既有口径);主角死 → 兑付终止、余单作废,Game Over 仍归判定段的生命读数路径。 + +**结账时机双模式(结构位,非调参项)**:单据开出后何时兑现由控制器政策承载。现行 = **段界结账**:授予段的伤害单攒到授予段尾统一兑现;清图批是段内同步结账点——开单前先按序结清段内排队单、随后即开即结(完美第二份与高光份数的流程依赖需要同步读数);战斗段的单攒到战斗段尾统一兑现。保留位 = **开单即结**:提交当场兑现,逐位等价 2026-09-24 之前的行为,作回切结构位。两模式同种子同操作均逐位复现(兑现次序 = 开单次序);同一输入在两模式下结果可不同(兑现时机的语义差异),历史基线读数以段界结账口径为准。切换 = 改控制器政策常量,走代码修订不走调参。 + +举证:本节为管线编排约定,无新计算;掷点、折扣、克制与池乘区的算术口径见战斗段一节。 + ### 战斗段与胜负判定:谁先动手、怎么扣血、怎么定胜负 -战斗段是兑现的最后一刀。主角的基础攻击恒存在——这是项目拍板:若基线为零,百分比增益乘上去永远是零,增益系统就空转了。攻击力域 [1, 999]、0 于加载期拒绝,行值读主角表。顺序固定:主角先手攻击顺位最前的存活敌人,随后按行序逐个存活敌人按本拍摇定技能还手;每个敌人依次走"按技能行兑现、结算伤害、执行上报(技能进冷却 + 保底计数)"三步且恰行动一次,主角不在其列。主角中途倒下,剩余敌人全部停手,直接进判定段。 +战斗段是兑现的最后一刀。主角的基础攻击恒存在——这是项目拍板:若基线为零,百分比增益乘上去永远是零,增益系统就空转了。攻击力域 [1, 999]、0 于加载期拒绝,行值读主角表。顺序固定:主角先手攻击顺位最前的存活敌人,随后按行序逐个存活敌人按本拍摇定技能还手;每个敌人依次走"按技能行兑现、结算伤害、执行上报(技能进冷却 + 保底计数)"三步且恰行动一次,主角不在其列。主角中途倒下,剩余敌人全部停手,直接进判定段。本段只开单——主角先手单与逐敌伤害技单攒到战斗段尾统一结账(见[伤害单据与伤害控制器](#伤害单据与伤害控制器所有伤害走同一道门)一节);施放技不涉伤害、照旧当场落行。 **开轮摇号与敌方技能兑现**(2026-09-16 敌人技能池批):敌人每拍做什么由开轮摇号决定——每轮轮次开始时,本系统在换算层排除冷却中技能,按行序逐敌从 `behavior_roll` 封闭流的独立子流摇定本拍动作写入实例池(首轮同样摇号;每轮恰摇一次;轮内刷新换图不重摇),随后冷却递减。摇定后、兑现前死亡 → 本拍技能作废、不动手(死亡即不动手)。结算战斗段只按技能行兑现:伤害技走三乘法链(见下),增益技记一行施放行并把状态效果按技能行持续回合挂入[增益状态](增益状态.md)容器——0 伤害动作不掷不折。池模板、技能行、冷却与保底语义归[敌人系统](敌人系统.md)(引用不转述)。 @@ -327,6 +351,7 @@ EV 参考数不入正文(派生读数,需要时按判定式与现行表值 - 可回放(封闭随机流口径):同种子同输入两遍步骤流逐位复现;随机消费仅限七处封闭随机流——开战掷骰(初始化随机)、技能摇号 `behavior_roll`(回合内、开轮时点、步骤流之外)、护甲等级掷点 `armor_roll`(回合内、步骤流之内)、奖励格摇号 `reward_roll`(初始化、装板期)、变异布置 `variant_place`(初始化、装板与资源预分配之后)、换板棋盘生成 `board_gen`(初始化、清图轮换板时点)、战斗结束掉落掷定 `gp_drop`(出口随机、战斗结束分支),无未登记随机源、零界面等待;单敌出招轨迹可独立复放、互不穿插。 - 无来源即炸:每行明细都能指回一个来源事实(棋盘格、单位、清图或段级结论);找不到来源即断言失败,行型与来源类别两层豁免清单见明细流水一节,豁免行仍受其它契约约束。 - 行型封闭:流水只出现本文登记的 9 类行型;新增或下线行型必须走本文修订并同步呈现层行格式化,未登记行型不得落行。 +- 伤害单据管线(2026-09-24):全部伤害(主角先手、敌方伤害技、棋盘克制直伤、群伤、反噬、地形变异)经伤害控制器单据管线兑现,兑付配方封闭五种;目标在结账时刻照规则解析;死亡事件由单位自行宣布——怪死其未结单据作废(零行、不进冷却不进保底计数)、主角死兑付终止;段界结账为现行口径、开单即结为回切结构位,两模式各自同种子同输入逐位复现。 ## 待办 @@ -359,3 +384,4 @@ EV 参考数不入正文(派生读数,需要时按判定式与现行表值 | v0.20 | 2026-09-20 | **伏击单轮上限删除批(用户拍板——与 敌人系统 / 地图战斗资源 / 难度曲线与Boss / registry / 切片代码同批)**:① 行型协议 10 类→9 类——溢出未生效行下线(唯一发射源 = 伏击上限截流,上限删除后零发射面);② 授予段伏击加层改直授无上限(反灾难护栏退役——一轮踩雷数由行动点预算天然封顶);③ 核验清单伏击守恒条改伏击池共享;④ 切片同批:enemy.tres 12 行字段删除、敌表校验/编排器截流/单位容器 F3 计划函数/日志视图 overflow 格式化删除、冒烟 blast 负向断言改直授累加(330 项 0 失败) | | v0.21 | 2026-09-20 | **格面资源八型批(用户拍板——与 敌人系统 / 地图战斗资源 / 行动经济 / registry / 切片代码同批)**:① 三乘法链与敌方技能攻击公式删伏击附加项及其"后置加法不进乘区"解释短语(rider 保留);② 授予段兑付表 blast 负向换装警觉加层(enemy_alert → alert 行)+ 新增 action 型行动点修正行(queue_ap_delta 排队、下一轮开始兑现、下限 0、明细记 effect_id=ap_delta);③ EV flat 类枚举伏击→行动点;④ 呈现快照两处伏击层数拆除(警觉并入敌方增益 effects 投影);⑤ 棋盘来源必中枚举去伏击(两处);⑥ 核验清单伏击池共享改警觉共享 | | v0.23 | 2026-09-20 | **GP+商店房批(用户拍板——与 关卡节点地图 v1.1 / 冒险结构 v0.5 / 地图战斗资源 v0.15 / 难度曲线与Boss v0.18 / registry / 切片代码同批;版本号注记:v0.22 已被充能退役批占用,表尾顺延)**:① 呈现只读面表 battle_finished 载荷 +gp_dropped(掉落掷定 + 关级加值 + 战斗内缓冲的合计——字段增删走本文修订条款兑现);② 授予段兑付形态表新增 GP 缓冲行:gp 正/负向(金币袋 / 破财——九型批)兑付 = 战斗内 GP 缓冲 ±power,战斗完成统一上抛入钱包账本(账本归冒险结构),战斗域零钱包状态;③ 随机口径六处→七处:战斗结束掉落掷定消费 `gp_drop` 封闭流(出口随机、步骤流之外);九型字典措辞与验收标准可回放条目同步 | +| v0.24 | 2026-09-24 | **伤害管线单据化批(用户三裁决——与切片代码同批,06 台账 148)**:新增「伤害单据与伤害控制器」一节——伤害对象抽象补全:五族伤害来源收口伤害控制器单点管线、兑付配方封闭五种;三裁决落文——①结账时机双模式(现行 = 段界结账:授予段尾 / 清图批同步点 / 战斗段尾;开单即结保留为回切结构位,切换走代码不走调参)②目标结账时刻照规则解析③死亡事件由单位自行宣布(怪死其未结单据作废、主角死兑付终止);授予段兑付与战斗段两节补结账时点句;验收标准 +伤害单据管线条目。切片同批:`core/damage_controller.gd` 新建(单据构造/开单/结账/死亡事件消费/管线算术收口)、battle_orchestrator 六调用点改开单、掷点/折扣/克制/池乘/攻击行文本助手迁出(`_defender_ac`/`_clamped_pool` 留薄委托)、unit_container 增 `unit_died` 信号;冒烟 557 项 0 失败双模式全绿 + A/B 对拍四配置全确定且真实分岔(行数恒等、纯次序位移);历史基线读数自本批起以段界结账口径为准(重测归曲线调参批) | diff --git a/prototypes/dungeon-sweeper-vertical-slice/README.md b/prototypes/dungeon-sweeper-vertical-slice/README.md index 4f5a10ad..0ec0e54b 100644 --- a/prototypes/dungeon-sweeper-vertical-slice/README.md +++ b/prototypes/dungeon-sweeper-vertical-slice/README.md @@ -542,3 +542,11 @@ Godot_v4.7.2-stable_win64_console.exe --headless --path . --script res://smoke/s **技术面**:`data/tables/meta.tres` 四行族(forge_products / boss_keystone / workshop_items / conversion)+ `core/meta_progression.gd` 账本服务(Core 层零 UI 零信号);SaveManager.apply_adventure_summary 增第二参 boss_drops(缺省 [] 零行为兼容,摘要元素附副本);AdventureRunner.start_adventure 增 params.hero_override(缺省全零 = 白板)→ LevelRunner base_override。**数值全 provisional 归元进度设计批配读数**(骨架先行先例 = GP+商店房批)。 **验证**:冒烟 522→549 项 0 失败三闸全过(表结构 +4 / 定价与购买 +7 / 注入与白板 +8 / 入账工坊摘要 +8);四探针全 PASS(ui_probe 营地开合/禁用态、click_probe 营地购买合成点击全路径——隔离存档换注保确定性,禁碰真实档);白板成立 = 空参数 = 零强化参数 = 纯表值三轴逐位一致。 + +## 2026-09-24 伤害管线单据化批(伤害对象抽象补全——伤害控制器统一入口) + +**架构面**:伤害此前无对象抽象——五族伤害来源(主角先手 / 敌方伤害技 / 棋盘克制直伤 / 群伤 / 反噬地形)在战斗编排器内各自内联组装管线。本批新建 `core/damage_controller.gd`(伤害单据 + 唯一兑付管线):九栏单据、五种兑付配方封闭枚举;battle_orchestrator 六调用点退化为开单点(流程决定何时开单给谁开单,单据怎么算归控制器),掷点 / 折扣 / 暴击 / 克制 / 池乘区 / 下限 / 攻击行文本算术全部迁入控制器(`_defender_ac` / `_clamped_pool` 留薄委托供快照面与冒烟白盒直呼);unit_container 新增 `unit_died` 死亡事件信号(单位自行宣布死亡——怪死其未结单据作废〔死亡即不动手〕、主角死兑付终止,Game Over 仍归判定段读数)。 + +**结账时机双模式(2026-09-24 用户三裁决之二三)**:目标 = 结账时刻照规则看现场解析(最前存活敌 / 主角 / 全体存活敌),开单不冻结对象;现行 = 段界结账(授予段尾 / 清图批同步点 / 战斗段尾统一兑现),开单即结保留为回切结构位(控制器 policy 常量一行切回=逐位还原迁移前行为)。同一输入两模式结果可不同(兑现时机语义差异),历史基线读数自本批以段界结账口径为准(重测归曲线调参批)。 + +**验证**:冒烟 557 项 0 失败双模式全绿三闸过;A/B 对拍探针四配置(L1/L2 × 双种子)两模式各自同种子逐位确定、四配置全部真实分岔(行数两两恒等、纯次序位移——分岔面 = 伤害行攒批位移与治疗满血判定时机,符合段界结账语义);三场景探针全 PASS。设计权威 = 战斗结算.md v0.24「伤害单据与伤害控制器」节。 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..eff81ef2 --- /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_SEGMENT # 结账时机开关(2026-09-24 用户拍板第二步拨 B=攒批段界统一结;回切 A 改回 POLICY_IMMEDIATE 即还原迁移前行为) +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