# VERTICAL SLICE - NOT FOR PRODUCTION # Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2) # Date: 2026-09-16 # 敌人系统·战斗单位容器:hero/enemy 统一受伤死亡接口 · 实例池状态机(敌人技能池批——2026-09-16 用户拍板, # 设计权威 proposal-skill-pool.md〔提案已吸收退役 git a6833b6a——现行事实源=敌人系统.md 技能池节〕 v1.0)· 行值快照抗热重载 · 编排被动零事件。 # 实例池三层对象(proposal §2.2):定义层技能行(SkillTable)· 定义层池模板(EnemyTable.pool)· # 本类持实例层——spawn 把模板快照为各实例池(冷却全零),此后冷却递减/待发动作/保底计数各自独立。 # 零随机零事件纪律(proposal 约束二):随机消费只在 战斗结算换算层抽签器(SkillLottery + behavior_roll 子流); # 本类只收确定结果(set_pending_action)并管理确定性状态(冷却递减、执行上报)。旧行为环 # (pattern 环数组 + steps 指针 + heavy_multiplier)随技能池整体退役——拍结构保留(每结算每存活敌恰行动一次、 # 行序、先作用后推进),"轮到什么"改由开轮摇号决定(死亡即不动手:摇定后兑现前死亡 → 本拍作废、不 commit)。 # 切片登记接口漂移:heal() 为切片新增(战斗结算 治疗 clamp 需要写入口);spawn 签名 +3 参数(E4/区间批/ # 遭遇骰——正式侧 GDD/registry 修订随对应批);迭代 23 T2 +2 参数(hero_attack_type/hero_armor_type—— # 两根类型轴注入面:hero.tres 静态表两轴删除〔契约 2 所有权移交〕,空串=锻造工坊配置默认值回退)。 # 实现 GDD:design/gdd/systems/战斗循环/敌人系统.md(registry 追溯锚,命名规范见 CLAUDE.md) class_name UnitContainer extends RefCounted # 死亡事件(伤害管线单据化批 2026-09-24 用户裁决③——单位自行宣布死亡):apply_damage 扣血归零、 # alive 翻 false 的同一时点发出;载荷 = 单位句柄(Dictionary 引用,is_hero 区分主角/敌)。 # 消费方 = 伤害控制器(怪死 → 其未结单据作废〔死亡即不动手〕;主角死 → 兑付终止旗)。 signal unit_died(u: Dictionary) var db: ConfigDb # 单位容器私有(E3 封装收口 2026-09-13):hero/enemies 原始数组不再外露——读取经 hero_unit()/ # all_enemies() 投影面(死亡残位查询包含在内),写入只经 apply_damage/heal/set_pending_action/commit_action/ # tick_cooldowns/spawn/despawn。 # 句柄语义不变:投影返回的 Dictionary 引用即单位句柄(同 query_alive 先例)。 var _hero: Dictionary[String, Variant] = {} # {hp, max_hp, atk, is_hero: true} var _enemies: Array[Dictionary] = [] # 行主序生成序;元素 = 单位 Dictionary var _spawned := false var _despawned := false func _init(db_ref: ConfigDb) -> void: db = db_ref # E4 收口(2026-09-13):加成值经参数显式传入(atk_override/max_hp_override,-1 = 表值兜底)—— # 此前调用方写 db.hero 表值再由本类读表 = 共享可变单例传参(双写叠加 6→18→54 教训根源)。 # enemy_atk_override(2026-09-14 敌方攻击区间批):遭遇骰同流的 spawn 期整战持久 roll 结果 # (-1 = 表值定值路径;≥0 = 行值替换后再乘关卡 atk_mult)。敌人系统 零随机纪律保持。 # 行集受理换代(2026-10-07 行集多行批 P1 甲):unit_count_overrides / enemy_atk_overrides 改**逐行数组** # (下标=行集行序,长度短于行集时缺省 -1 = 该行行值路径;空数组 = 全行定值——旧调用点零适配)。 # 逐行实例化、生成序 = 文件行序(行序即契约,敌人系统 v0.15);行集实例总数(各行传入数量求和) # ≤ ENEMY_ROWSET_INSTANCE_MAX 守卫 fail-fast。 func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_overrides: Array = [], atk_override: int = -1, max_hp_override: int = -1, enemy_atk_overrides: Array = [], hero_attack_type: String = "", hero_armor_type: String = "") -> bool: if _spawned: push_warning("[敌人系统] Alive 相位重复 spawn(相位守卫)") return false # 行集数组口径(2026-10-07 行集多行批——resolver 返回该坐标全部行、文件序) var rowset: Array = db.resolve_enemy_rowset(level_index, node_depth) if rowset.is_empty(): push_error("[敌人系统] 遭遇行解析失败/越域 (%d,%d)" % [level_index, node_depth]) assert(false, "遭遇行悬空(R1 前置守卫)") return false var eff_atk: int = atk_override if atk_override >= 1 else int(db.hero.atk) var eff_max_hp: int = max_hp_override if max_hp_override >= 1 else int(db.hero.max_hp) if hero_hp > eff_max_hp or hero_hp <= 0: push_warning("[敌人系统] hero_hp 入参越界(EC16): %d" % hero_hp) return false # max_hp = 生效上限(非入参 hero_hp)——2026-09-11 关卡扩展批修复:跨战斗携带 HP 时上限不得压扁 # (恢复点/治疗包的满血守卫与治疗上限随之失效——切片登记接口语义澄清) _hero = {"hp": hero_hp, "max_hp": eff_max_hp, "atk": eff_atk, "is_hero": true, "alive": true, "display_name": "探险者", "enemy_id": "hero", "armor_type": hero_armor_type if hero_armor_type != "" else db.forge_default_armor_type(), "attack_type": hero_attack_type if hero_attack_type != "" else db.forge_default_attack_type()} # 护甲/攻击类型轴快照(迭代 23 T2——hero.tres 两轴删除后改读注入值,空串回退锻造工坊配置默认值 normal/light=行为连续;甲效矩阵消费面) _enemies.clear() # 难度曲线与Boss DifficultyTable 修饰(2026-09-12 结构位兑现):施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为(AC-10);混编行集内全部敌种同乘((L,D) 精确匹配一次取值——F9 同乘不改变异,敌人系统 v0.15) var mod: Dictionary = db.resolve_difficulty_modifiers(level_index, node_depth) var hp_mult: float = float(mod.get("hp_mult", 1.0)) var atk_mult: float = float(mod.get("atk_mult", 1.0)) # 逐行数量定值(敌人系统 零随机纪律——骰定在 战斗结算 遭遇骰层按行完成,本层收确定值; # 行 i 数量 = overrides[i](≥1 时)否则该行 unit_count 行值) var per_row_counts: Array[int] = [] var total_instances := 0 for row_i in range(rowset.size()): var row_i_dict: Dictionary = rowset[row_i] var ovr: int = int(unit_count_overrides[row_i]) if row_i < unit_count_overrides.size() else -1 var count: int = ovr if ovr >= 1 else int(row_i_dict.unit_count) if count < EnemyTable.UNIT_COUNT_MIN or count > EnemyTable.UNIT_COUNT_MAX: push_warning("[敌人系统] 行 %d 实例化数量越域(单种数域 [%d,%d]): %d" % [row_i, EnemyTable.UNIT_COUNT_MIN, EnemyTable.UNIT_COUNT_MAX, count]) return false per_row_counts.append(count) total_instances += count # 行集实例总数守卫(行集构成纪律③——各行骰后数量求和 ≤4;骰后值加载期不可判,此处 fail-fast) if total_instances > EnemyTable.ROWSET_INSTANCE_MAX: push_warning("[敌人系统] 行集实例总数越域(≤%d): %d" % [EnemyTable.ROWSET_INSTANCE_MAX, total_instances]) return false var handle := 0 for row_i in range(rowset.size()): var row: Dictionary = rowset[row_i] var eovr: int = int(enemy_atk_overrides[row_i]) if row_i < enemy_atk_overrides.size() else -1 for i in range(per_row_counts[row_i]): var eff_row_atk: int = eovr if eovr >= 0 else int(row.atk) # 实例池快照(proposal §2.2:开战把模板快照一份,冷却全零——与"生成拿到当时的表值快照"同一条快照纪律) var pool_snap: Array[Dictionary] = [] for entry in row.get("pool", []): var snap_entry := { "skill_id": String(entry.get("skill_id", "")), "weight": int(entry.get("weight", 0)), "cooldown_left": 0, } # Boss 半血解锁技(迭代14④ T3——unlock_hp_pct 随池模板快照进实例池;缺省无字段=无门槛; # unlocked=单向闩标记,初始锁定) if entry.has("unlock_hp_pct"): snap_entry["unlock_hp_pct"] = int(entry.get("unlock_hp_pct", 0)) snap_entry["unlocked"] = false pool_snap.append(snap_entry) _enemies.append({ "handle_id": handle, "enemy_id": String(row.enemy_id), "display_name": String(row.display_name), "hp": maxi(1, int(round(float(row.hp) * hp_mult))), "max_hp": maxi(1, int(round(float(row.hp) * hp_mult))), "atk": maxi(0, int(round(float(eff_row_atk) * atk_mult))), "pool": pool_snap, # 实例池(模板快照 + 冷却状态——独立摇号互不干涉) "pending_skill": "", # 本拍摇定动作(开轮摇号写入;兑现前死亡 = 作废不 commit) "roll_seq": 0, # 单敌摇号拍序(诊断标签——并流后不参与派生,ADR-0007 裁丙 2026-09-27) "basic_streak": 0, # 保底计数:连续执行普攻的拍数(执行上报驱动) "armor_type": String(row.get("armor_type", "light")), # 护甲类型快照(克制矩阵消费面——战斗结算·战斗段;2026-09-15 命名统一批:light/ward/heavy) "armor_class": int(row.get("armor_class", 0)), # 护甲等级快照(掷点判定阈值基数——战斗结算·战斗段;2026-09-16 护甲等级掷点批) "attack_type": String(row.get("attack_type", "normal")), # 攻击类型快照(克制矩阵消费面——敌方伤害单据第 9 参;迭代 24 N3 轴开列,缺省 normal 向后兼容) "alive": true, "is_hero": false, }) handle += 1 _spawned = true _despawned = false return true func query_alive(side: String) -> Array: if _despawned: _diag("Despawned 句柄查询(域闭)") return [] if side == "hero": return [] if (_hero.is_empty() or not _hero.alive) else [_hero] var out: Array[Dictionary] = [] for u in _enemies: if u.alive: out.append(u) return out # ---- 实例池状态机(敌人技能池批——全部确定性状态,随机在 战斗结算换算层)---- # 池条目锁定判定(迭代14④ Boss 半血解锁——带 unlock_hp_pct 且未过闩 = 锁定:双面过滤不进 # 可摇集合与保底特殊技集合;解锁后即可摇,冷却初值 0) func _entry_locked(e: Dictionary) -> bool: return e.has("unlock_hp_pct") and not bool(e.get("unlocked", false)) # 可摇集合:冷却已尽的池条目投影 [{skill_id, weight}]。普通攻击冷却恒 0 → 恒非空(空池不可能的结构前提, # 加载校验"每池恰一行普攻"兜底)。hero/失效句柄拒绝。锁定条目(半血未解锁)双面过滤不入。 func rollable_skills(u: Dictionary) -> Array: if u.is_empty() or _despawned or u.get("is_hero", false): _diag("rollable_skills 失效句柄") return [] var out: Array[Dictionary] = [] for e in u.pool: if _entry_locked(e): continue if int(e.cooldown_left) <= 0: out.append({"skill_id": String(e.skill_id), "weight": int(e.weight)}) return out # 特殊技集合(保底专用——proposal §2.1:必出特殊技、特殊技内按权重、不受冷却限制):type ≠ 普攻的全部池条目。 # 锁定条目(半血未解锁)双面过滤——保底不得逼出未解锁技(迭代14④ T3)。 func special_skills(u: Dictionary) -> Array: if u.is_empty() or _despawned or u.get("is_hero", false): _diag("special_skills 失效句柄") return [] var out: Array[Dictionary] = [] for e in u.pool: if _entry_locked(e): continue var srow: Dictionary = db.skill_row(String(e.skill_id)) if not srow.is_empty() and String(srow.type) != "basic": out.append({"skill_id": String(e.skill_id), "weight": int(e.weight)}) return out # 半血解锁闩检查(迭代14④ T3——开轮摇号前逐敌检查;hp ≤ round(max_hp × unlock_hp_pct/100) → # 一次过门、单向闩〔实例标记,不解锁回退〕。门=确定性谓词零随机;返回本拍新解锁的 skill_id 列表 # 供编排层出归因行〔每技恰一次——闩已置位不再返回〕) func check_skill_unlocks(u: Dictionary) -> Array: var out: Array = [] if u.is_empty() or _despawned or u.get("is_hero", false) or not bool(u.get("alive", false)): return out for e in u.pool: if not e.has("unlock_hp_pct") or bool(e.get("unlocked", false)): continue var threshold: int = int(round(float(int(u.max_hp)) * float(int(e.unlock_hp_pct)) / 100.0)) if int(u.hp) <= threshold: e["unlocked"] = true # 单向闩(不解锁回退——治疗抬血不解锁) out.append(String(e.skill_id)) return out # 摇定结果写入(战斗结算换算层调用——确定值;roll_seq 递增 = 单敌第几拍诊断标签,不参与派生) func set_pending_action(u: Dictionary, skill_id: String) -> void: if u.is_empty() or _despawned or u.get("is_hero", false): _diag("set_pending_action 失效句柄") return u.pending_skill = skill_id u.roll_seq = int(u.roll_seq) + 1 # 本拍摇定动作(未摇 = "";死亡残位保留可查——作废观测量) func pending_action(u: Dictionary) -> String: if u.is_empty() or _despawned: _diag("pending_action 失效句柄") return "" return String(u.get("pending_skill", "")) # 保底计数读数(连续执行普攻拍数——战斗结算开轮摇号时与 SkillTable.pity_threshold 比较) func basic_streak(u: Dictionary) -> int: if u.is_empty() or _despawned: _diag("basic_streak 失效句柄") return 0 return int(u.get("basic_streak", 0)) # 执行上报(战斗段兑现时调用):该技能进冷却(施放后连续 N 拍不可摇中——含增溢技,技能冷却与增益持续 # 是两个计时器互不相干)+ 保底计数更新(普攻 +1 / 特殊技归零)。摇定后兑现前死亡不经过本方法 # = 作废不进账(无冷却无计数——死亡即不动手)。 func commit_action(u: Dictionary) -> void: if u.is_empty() or _despawned or u.get("is_hero", false): _diag("commit_action 失效句柄") return var sid := String(u.get("pending_skill", "")) if sid == "": _diag("commit_action 无待发动作(兑现序违例)") return var srow: Dictionary = db.skill_row(sid) var cd: int = int(srow.get("cooldown_beats", 0)) if not srow.is_empty() else 0 for e in u.pool: if String(e.skill_id) == sid: e.cooldown_left = cd if not srow.is_empty() and String(srow.type) == "basic": u.basic_streak = int(u.basic_streak) + 1 else: u.basic_streak = 0 u.pending_skill = "" # 开轮冷却递减(战斗结算开轮摇号编排调用,摇号读值之后全体恰一次):floor 0。 # 时序口径 = 摇号按当前 cooldown_left 排除(>0 冷却中),递减在其后——等价实现"施放后连续 N 拍 # 不可摇中"(施放拍置 N → 后续 N 个开轮各排除一次);与 proposal §2.1"冷却递减 → 排除"的 # 可摇集合逐拍恒等(init N+1 的同构变换)。 func tick_cooldowns() -> void: if _despawned: return for u in _enemies: for e in u.pool: e.cooldown_left = maxi(0, int(e.cooldown_left) - 1) # 受击上报:hp 递减 floor 0 → Dead;n ≤ 0 拒绝 func apply_damage(u: Dictionary, n: int) -> bool: if n <= 0: _diag("apply_damage n ≤ 0 非法") return false if u.is_empty() or _despawned or not u.alive: _diag("apply_damage 失效/死亡句柄") return false u.hp = maxi(0, u.hp - n) if u.hp == 0: u.alive = false # Dead 不可逆(死亡判定时机归 战斗结算) unit_died.emit(u) # 死亡事件——单位自行宣布(伤害管线单据化批 2026-09-24 用户裁决③) return true # 治疗(切片登记接口漂移——战斗结算 治疗 clamp 的写入口):返回实际回复量 func heal(u: Dictionary, n: int) -> int: if n < 0 or u.is_empty() or _despawned or not u.alive: _diag("heal 非法/失效句柄") return 0 var actual: int = mini(n, int(u.max_hp) - int(u.hp)) u.hp += actual return actual # 固定顺位:行主序生成序最前存活敌;死亡后顺延;空 → null func default_target(): for u in _enemies: if u.alive: return u return null # ---- 只读投影面(E3 封装收口:外部读单位经此,禁直读容器数组)---- # 主角句柄(未 spawn/despawn 后 = {})——句柄即 Dictionary 引用(同 query_alive 先例) func hero_unit() -> Dictionary: return _hero # 全体敌人句柄(含死亡残位——呈现层残位渲染 / 白盒断言的合法入口;数组结构拷贝、字典句柄同源) func all_enemies() -> Array: return _enemies.duplicate() func despawn_all() -> bool: if _despawned or not _spawned: _diag("重复/无效 despawn_all") return false _despawned = true _enemies.clear() _hero = {} return true func _diag(msg: String) -> void: push_warning("[敌人系统] " + msg)