360 lines
17 KiB
GDScript
360 lines
17 KiB
GDScript
## 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": String(db.hero.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)
|
||
var pool_snap: Array[Dictionary] = _pool_snapshot(row) # 实例池快照(开战模板快照——纪律见助手头注)
|
||
_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
|
||
|
||
|
||
# 实例池快照(proposal §2.2:开战把模板快照一份,冷却全零——与"生成拿到当时的表值快照"同一条
|
||
# 快照纪律。触发条件:技能行 trigger 是字段权威——快照随读;带 latch 行写单向闩标记
|
||
# trigger_latched 初始锁定;缺省无 trigger=无条件技)
|
||
func _pool_snapshot(row: Dictionary) -> Array[Dictionary]:
|
||
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,
|
||
}
|
||
var srow_snap: Dictionary[Variant, Variant] = db.skill_row(String(entry.get("skill_id", "")))
|
||
if srow_snap.has("trigger"):
|
||
snap_entry["trigger"] = (srow_snap.get("trigger", {}) as Dictionary).duplicate(true)
|
||
if bool((snap_entry["trigger"] as Dictionary).get("latch", false)):
|
||
snap_entry["trigger_latched"] = false
|
||
pool_snap.append(snap_entry)
|
||
return pool_snap
|
||
|
||
|
||
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
|
||
|
||
|
||
# ---- 实例池状态机(全部确定性状态,随机在 战斗结算换算层)----
|
||
|
||
# 池条目门槛判定(触发条件收编——无 trigger 恒 false;闩已过恒 false〔过闩即不再拦〕;
|
||
# 否则按 fact 分派求值。百分比事实=阈值式(v1.2):先折算绝对阈值 T=round(对应 max_hp × value/100)、
|
||
# 再以 hp op T 比较——六算子统一在该阈值上定义。facts 缺键=条件假(拦)——fail-safe)
|
||
func _entry_blocked(e: Dictionary, u: Dictionary, facts: Dictionary) -> bool:
|
||
if not e.has("trigger"):
|
||
return false
|
||
if e.has("trigger_latched") and bool(e.get("trigger_latched", false)):
|
||
return false # 闩已过——不再拦(治疗抬血不回锁)
|
||
var t: Dictionary[Variant, Variant] = e.get("trigger", {})
|
||
var fact := String(t.get("fact", ""))
|
||
var op := String(t.get("op", ""))
|
||
var value: Variant = t.get("value", 0)
|
||
if fact == "self_hp_pct" or fact == "hero_hp_pct":
|
||
return _pct_fact_blocked(fact, value, op, u) # 自类/主角百分比事实(阈值式折算——v1.2)
|
||
# 全局事实(编排器组包——缺键=拦)
|
||
if not facts.has(fact):
|
||
return true
|
||
var fv: Variant = facts.get(fact)
|
||
if fact == "last_round_end":
|
||
return not _compare_str(String(fv), op, String(value))
|
||
return not _compare(int(fv), op, int(value))
|
||
|
||
|
||
# 六算子统一比较(数值)
|
||
func _compare(lhs: int, op: String, rhs: int) -> bool:
|
||
match op:
|
||
"eq": return lhs == rhs
|
||
"neq": return lhs != rhs
|
||
"gt": return lhs > rhs
|
||
"gte": return lhs >= rhs
|
||
"lt": return lhs < rhs
|
||
"lte": return lhs <= rhs
|
||
return false
|
||
|
||
|
||
# 枚举算子(eq/neq——字符串)
|
||
func _compare_str(lhs: String, op: String, rhs: String) -> bool:
|
||
return lhs == rhs if op == "eq" else lhs != rhs
|
||
|
||
|
||
# 可摇集合:冷却已尽的池条目投影 [{skill_id, weight}]。普通攻击冷却恒 0 → 恒非空(空池不可能的结构前提,
|
||
# 加载校验"每池恰一行普攻"兜底)。hero/失效句柄拒绝。锁定条目(半血未解锁)双面过滤不入。
|
||
# 百分比事实阈值判定(阈值式 v1.2:先折算绝对阈值 T=round(对应 max_hp × value/100)、
|
||
# 再以 hp op T 比较——六算子统一在该阈值上定义。self=容器内直读单位行;hero=主角行,
|
||
# 无主角行=条件假(拦——fail-safe))
|
||
func _pct_fact_blocked(fact: String, value: Variant, op: String, u: Dictionary) -> bool:
|
||
if fact == "self_hp_pct":
|
||
var threshold: int = int(round(float(int(u.max_hp)) * float(int(value)) / 100.0))
|
||
return not _compare(int(u.hp), op, threshold)
|
||
var hrow: Dictionary[Variant, Variant] = _hero
|
||
if hrow.is_empty():
|
||
return true # 无主角行——条件假(fail-safe)
|
||
var hthreshold: int = int(round(float(int(hrow.max_hp)) * float(int(value)) / 100.0))
|
||
return not _compare(int(hrow.hp), op, hthreshold)
|
||
|
||
|
||
func rollable_skills(u: Dictionary, facts: 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_blocked(e, u, facts):
|
||
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, facts: 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_blocked(e, u, facts):
|
||
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_trigger_unlocks(u: Dictionary, facts: 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("trigger") or not e.has("trigger_latched") or bool(e.get("trigger_latched", false)):
|
||
continue # 只处理闩型且未过闩条目
|
||
if not _entry_blocked(e, u, facts):
|
||
e["trigger_latched"] = 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, int(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 = int(u.hp) + actual
|
||
return actual
|
||
|
||
|
||
# 固定顺位:行主序生成序最前存活敌;死亡后顺延;空 → null
|
||
func default_target() -> Variant:
|
||
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)
|