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

360 lines
17 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": 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)