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

259 lines
13 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
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-16
# 敌人系统·战斗单位容器:hero/enemy 统一受伤死亡接口 · 实例池状态机(敌人技能池批——2026-09-16 用户拍板,
# 设计权威 docs/proposal-skill-pool.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 修订随对应批)。
# 实现 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)。敌人系统 零随机纪律保持。
func spawn(level_index: int, node_depth: int, hero_hp: int, unit_count_override: int = -1,
atk_override: int = -1, max_hp_override: int = -1, enemy_atk_override: int = -1) -> bool:
if _spawned:
push_warning("[敌人系统] Alive 相位重复 spawn(相位守卫)")
return false
var row: Dictionary = db.resolve_enemy_rowset(level_index, node_depth)
if row.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": String(db.hero.get("armor_type")) if db.hero.get("armor_type") else "light", "attack_type": String(db.hero.get("attack_type")) if db.hero.get("attack_type") else "normal"} # 护甲/攻击类型轴快照(2026-09-18 营地扩展前置批——可配置;甲效矩阵消费面)
_enemies.clear() # 难度曲线与Boss DifficultyTable 修饰(2026-09-12 结构位兑现):施加于行值、行值快照之前——MVP 空表 = 恒 1.0 无行为(AC-10)
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))
# unit_count_override ≥1 = 战斗结算 遭遇骰结果(敌人系统 零随机纪律——收到即确定值;-1 = 行值定值路径)
# 数量域常量单点 = EnemyTable.UNIT_COUNT_MIN/MAX(2026-10-02 修复批——与表校验同源)
var count: int = unit_count_override if unit_count_override >= 1 else int(row.unit_count)
if count < EnemyTable.UNIT_COUNT_MIN or count > EnemyTable.UNIT_COUNT_MAX:
push_warning("[敌人系统] 实例化数量越域: %d" % count)
return false
for i in range(count):
var eff_row_atk: int = enemy_atk_override if enemy_atk_override >= 0 else int(row.atk)
# 实例池快照(proposal §2.2:开战把模板快照一份,冷却全零——与"生成拿到当时的表值快照"同一条快照纪律)
var pool_snap: Array[Dictionary] = []
for entry in row.get("pool", []):
pool_snap.append({
"skill_id": String(entry.get("skill_id", "")),
"weight": int(entry.get("weight", 0)),
"cooldown_left": 0,
})
_enemies.append({
"handle_id": i,
"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 护甲等级掷点批)
"alive": true,
"is_hero": false,
})
_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
# ---- 实例池状态机(敌人技能池批——全部确定性状态,随机在 战斗结算换算层)----
# 可摇集合:冷却已尽的池条目投影 [{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 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:
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
# 摇定结果写入(战斗结算换算层调用——确定值;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)