Files
gplan-2/game/core/unit_container.gd
T
DKChan 07f3eb55e3 feat(敌人表深度带): 遭遇行集寻址改为 (关卡, row_kind, 深度带)
敌人行集不再按 (level_index, node_depth) 精确点寻址,改为 (level_index, row_kind, depth_band):
带切法单点 EnemyTable.band_of 取首个不小于请求深度的带(偏难一侧),高于顶带返回 -1 交
resolver 取顶带并发 resolve_depth_clamp(四元组去重不变);resolver 与 ConfigDb 门面同步
加 row_kind 第三参;unit_container.spawn 与 BattleOrchestrator 透传行类(battle/boss)。

敌人表 20 行键迁移(原 node_depth 数值就地成为新带键,值列零变更),新增 row_kind 列;
schema 校验随键换代:row_kind 封闭枚举、dup_key/rowset_rule 新键串、每关 battle 带 0 锚
与每关 boss 行集 ≥1;跨表校验 ④ 敌侧改带寻址,新增同日图多 Boss 同带报错与顶带消解报错。

读数:基线冒烟 1156 项 0 失败;本轮冒烟 1161 项 0 失败;单元 35 套件全绿;探针 --click 全绿。
2026-10-22 04:17:21 +08:00

306 lines
15 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
## 战斗单位容器:hero/enemy 统一受伤死亡接口与实例池状态机,行值快照抗热重载(垂直切片验证:敌方行动可归因)。
## 零随机零事件——随机只在调用方换算层抽签消费,本类只收确定值;死亡由单位自行宣布 unit_died。
## 英雄加成只经 spawn 显式入参(-1=表值兜底),禁直改表值再由本类读表;单位读写只经投影面与命名写口,原始数组不外露。
class_name UnitContainer
extends RefCounted
# 死亡事件(单位自行宣布死亡):apply_damage 扣血归零、
# alive 翻 false 的同一时点发出;载荷 = 单位句柄(Dictionary 引用,is_hero 区分主角/敌)。
# 消费方 = 伤害控制器(怪死 → 其未结单据作废〔死亡即不动手〕;主角死 → 兑付终止封印)。
signal unit_died(u: Dictionary)
var db: ConfigDb
# 单位容器私有(E3 封装收口):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 收口:加成值经参数显式传入(atk_override/max_hp_override,-1 = 表值兜底)——
# 调用方写 db.hero 表值再由本类读表 = 共享可变单例传参(双写叠加教训根源)。
# enemy_atk_override:遭遇骰同流的 spawn 期整战持久 roll 结果
# (-1 = 表值定值路径;≥0 = 行值替换后再乘关卡 atk_mult)。敌人系统 零随机纪律保持。
# 行集受理: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 = "", row_kind: String = "battle") -> bool:
if _spawned:
push_warning("[敌人系统] Alive 相位重复 spawn(相位守卫)")
return false
# 行集数组口径(resolver 按 (L,band) 带寻址返回全部行、文件序)
var rowset: Array[Variant] = db.resolve_enemy_rowset(level_index, node_depth, row_kind)
if rowset.is_empty():
push_error("[敌人系统] 遭遇行解析失败/越域 (%d,%d,%s)" % [level_index, node_depth, row_kind])
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)——跨战斗携带 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()} # 护甲/攻击类型轴快照(读注入值,空串回退锻造工坊配置默认值 normal/light;甲效矩阵消费面)
_enemies.clear() # 难度曲线与Boss DifficultyTable 修饰:施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为(AC-10);混编行集内全部敌种同乘((L,D) 精确匹配一次取值——F9 同乘不改变异,敌人系统 v0.15)
var mod: Dictionary[Variant, Variant] = 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[Variant, Variant] = 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[Variant, Variant] = 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 半血解锁技(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, # 单敌摇号拍序(诊断标签——不参与派生)
"basic_streak": 0, # 保底计数:连续执行普攻的拍数(执行上报驱动)
"armor_type": String(row.get("armor_type", "light")), # 护甲类型快照(克制矩阵消费面——战斗结算·战斗段:light/ward/heavy)
"armor_class": int(row.get("armor_class", 0)), # 护甲等级快照(掷点判定阈值基数——战斗结算·战斗段)
"attack_type": String(row.get("attack_type", "normal")), # 攻击类型快照(克制矩阵消费面——敌方伤害单据第 9 参;缺省 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
# ---- 实例池状态机(全部确定性状态,随机在 战斗结算换算层)----
# 池条目锁定判定(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 ≠ 普攻的全部池条目。
# 锁定条目(半血未解锁)双面过滤——保底不得逼出未解锁技。
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[Variant, Variant] = 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
# 半血解锁闩检查(开轮摇号前逐敌检查;hp ≤ round(max_hp × unlock_hp_pct/100) →
# 一次过门、单向闩〔实例标记,不解锁回退〕。门=确定性谓词零随机;返回本拍新解锁的 skill_id 列表
# 供编排层出归因行〔每技恰一次——闩已置位不再返回〕)
func check_skill_unlocks(u: Dictionary) -> Array:
var out: Array[Variant] = []
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[Variant, Variant] = 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) # 死亡事件——单位自行宣布
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)