Files
spellforge/scripts/autoloads/enemy_manager.gd
T
2026-07-20 10:56:52 +08:00

330 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.
## EnemyManager — 敌人管理器 AutoloadGDScript 接口层)
## 热路径由 C# EnemyManagerCs 子节点驱动。
## 权威来源:implementation_plan.md §2.3.C
##
## SoA 布局(ENEMY_STRIDE=8):
## [ x, y, vx, vy, hp, hp_max, enemy_type, state ]
## 0 1 2 3 4 5 6 7
extends Node
const ENEMY_STRIDE: int = 8 # 禁止裸整数 8P6-N54
const MAX_ENEMIES: int = 1024
const OFFSCREEN_SEPARATION_INTERVAL: int = 10 # 屏外低频 Boid 分离力更新频率(P6-N1)
# SoA 热数组
var _data: PackedFloat32Array = PackedFloat32Array()
var _active_count: int = 0
# entity_id ↔ SoA 槽位 双向映射
var _entity_index_map: Dictionary = {} # { entity_id → slot_idx }
var _slot_entity_map: Dictionary = {} # { slot_idx → entity_id }
var _next_entity_id: int = 1
# LOD:屏内可见标志(PackedByteArray0=屏外,1=屏内
var _visible_flags: PackedByteArray = PackedByteArray()
var _offscreen_sep_counter: int = 0
var _cs_node: Node = null
## ADR-A4:精英寻路。W114 杂鱼永远 BoidW15+ Elite/Boss 按需挂 NavigationAgent2D
## 并发寻路上限 MAX_PATHFINDING_ENEMIES=20,超出降级 BoidP-S5-AI-01)。
const MAX_PATHFINDING_ENEMIES: int = 20
const ELITE_SPEED: float = 90.0
## 敌人原型(enemy_type 存于 SoA[+6])。S6 内容:基础/快速/护甲/精英/Boss
enum Type { BASIC = 0, FAST = 1, ARMORED = 2, ELITE = 3, MINIBOSS = 4, BOSS = 5 }
## 各原型数值:纯数据驱动,唯一来源 data/enemies.json(游戏设计器「敌人」面板维护)
## get() 的单值默认仅为防崩溃,非内容副本
var _SPEED: Dictionary = {} # type → 移动速度
var _ARMOR: Dictionary = {} # type → 护甲
var _RENDER_SIZE: Dictionary = {} # type → 渲染直径
var _RENDER_COLOR: Dictionary = {} # type → 渲染颜色
const ARCHETYPE_JSON: String = "res://data/enemies.json"
## 数据驱动:从 data/enemies.json 加载原型数值(速度/护甲/尺寸/颜色)
func _load_json_archetypes() -> void:
if not FileAccess.file_exists(ARCHETYPE_JSON):
push_error("EnemyManager: 缺失 res://data/enemies.json(敌人原型数值)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(ARCHETYPE_JSON))
if not (data is Dictionary):
push_error("EnemyManager: enemies.json 格式错误")
return
for k in data:
var t: int = int(k)
var d: Dictionary = data[k]
_SPEED[t] = float(d.get("speed", 80.0))
_ARMOR[t] = float(d.get("armor", 0.0))
_RENDER_SIZE[t] = float(d.get("size", 14.0))
var c: Array = d.get("color", [0.9, 0.3, 0.3])
_RENDER_COLOR[t] = Color(float(c[0]), float(c[1]), float(c[2]))
var _nav_region: NavigationRegion2D = null # 程序化矩形导航区
var _pathfinders: Dictionary = {} # entity_id → {"agent":NavigationAgent2D, "host":Node2D}
var _pathfinder_order: Array = [] # FIFO,用于超上限淘汰
func _ready() -> void:
_load_json_archetypes() # 数据驱动:覆盖原型数值(游戏设计器面板维护)
_data.resize(MAX_ENEMIES * ENEMY_STRIDE)
_data.fill(0.0)
_visible_flags.resize(MAX_ENEMIES)
_visible_flags.fill(0)
func _physics_process(delta: float) -> void:
_offscreen_sep_counter += 1
var do_sep: bool = (_offscreen_sep_counter >= OFFSCREEN_SEPARATION_INTERVAL)
if do_sep:
_offscreen_sep_counter = 0
if not _cs_node:
_gd_update_movement(delta)
# ADR-A4:精英寻路移动(NavigationAgent2D 跟随),仅当存在 pathfinder 时执行
if not _pathfinders.is_empty():
_update_pathfinding_movement(delta)
# S1: 移动后更新 SpatialGrid(碰撞检测依赖)
_update_spatial_grid()
func _update_spatial_grid() -> void:
for i in _active_count:
var base: int = i * ENEMY_STRIDE
var eid: int = _slot_entity_map.get(i, -1)
if eid >= 0:
SpatialGrid.insert(eid, Vector2(_data[base + 0], _data[base + 1]))
# GDScript 回退:直线追玩家(C# 没就绪时使用)
func _gd_update_movement(delta: float) -> void:
var player_pos: Vector2 = PlayerManager.get_position()
var has_pf: bool = not _pathfinders.is_empty() # 无精英时零额外开销
for i in _active_count:
if has_pf and _pathfinders.has(_slot_entity_map.get(i, -1)):
continue # 寻路精英由 _update_pathfinding_movement 处理
var base: int = i * ENEMY_STRIDE
var ex: float = _data[base + 0]
var ey: float = _data[base + 1]
var spd: float = _SPEED.get(int(_data[base + 6]), 80.0) # 按原型查速度
var dx: float = player_pos.x - ex
var dy: float = player_pos.y - ey
var dist: float = sqrt(dx * dx + dy * dy)
if dist > 1.0:
_data[base + 2] = (dx / dist) * spd
_data[base + 3] = (dy / dist) * spd
_data[base + 0] += _data[base + 2] * delta
_data[base + 1] += _data[base + 3] * delta
# ── 对外接口 ───────────────────────────────────────────────────
func spawn_enemy(pos: Vector2, hp: float, enemy_type: int = 0, has_pathfinding: bool = false) -> int:
if _active_count >= MAX_ENEMIES:
return -1
var entity_id: int = _next_entity_id
_next_entity_id += 1
var slot: int = _active_count
var base: int = slot * ENEMY_STRIDE
_data[base + 0] = pos.x
_data[base + 1] = pos.y
_data[base + 2] = 0.0
_data[base + 3] = 0.0
_data[base + 4] = hp # hp
_data[base + 5] = hp # hp_max
_data[base + 6] = float(enemy_type)
_data[base + 7] = 0.0 # state: 0=alive
_entity_index_map[entity_id] = slot
_slot_entity_map[slot] = entity_id
_active_count += 1
# ADR-A4W15+ Elite 在并发上限内挂寻路(超限降级 Boid)
if has_pathfinding and _pathfinders.size() < MAX_PATHFINDING_ENEMIES:
_register_pathfinder(entity_id, pos)
return entity_id
# ── ADR-A4 精英寻路子系统 ──────────────────────────────────────
func _ensure_nav_region() -> void:
if _nav_region != null:
return
_nav_region = NavigationRegion2D.new()
var poly := NavigationPolygon.new()
# 覆盖竞技场的单个凸矩形导航多边形(无障碍占位;P-S5-AI-01 仅测寻路开销)
var ext: float = 4000.0
poly.vertices = PackedVector2Array([
Vector2(-ext, -ext), Vector2(-ext, ext), Vector2(ext, ext), Vector2(ext, -ext),
])
poly.add_polygon(PackedInt32Array([0, 1, 2, 3]))
_nav_region.navigation_polygon = poly
add_child(_nav_region)
func _register_pathfinder(entity_id: int, pos: Vector2) -> void:
_ensure_nav_region()
var host := Node2D.new()
host.position = pos
add_child(host)
var agent := NavigationAgent2D.new()
agent.path_desired_distance = 8.0
agent.target_desired_distance = 8.0
agent.avoidance_enabled = false # 与 SoA Boid 分离力共存,避免双重避障
host.add_child(agent)
_pathfinders[entity_id] = {"agent": agent, "host": host}
_pathfinder_order.append(entity_id)
func _unregister_pathfinder(entity_id: int) -> void:
var pf: Dictionary = _pathfinders.get(entity_id, {})
if pf.is_empty():
return
(pf["host"] as Node).queue_free() # agent 是 host 子节点,随之释放
_pathfinders.erase(entity_id)
_pathfinder_order.erase(entity_id)
## 寻路精英移动:host 同步到 SoA 坐标,agent 朝玩家求下一路径点并转向
func _update_pathfinding_movement(delta: float) -> void:
var player_pos: Vector2 = PlayerManager.get_position()
for eid in _pathfinders:
var slot: int = _entity_index_map.get(eid, -1)
if slot < 0:
continue
var base: int = slot * ENEMY_STRIDE
var pos: Vector2 = Vector2(_data[base + 0], _data[base + 1])
var pf: Dictionary = _pathfinders[eid]
var agent: NavigationAgent2D = pf["agent"]
(pf["host"] as Node2D).global_position = pos
agent.target_position = player_pos
var nxt: Vector2 = agent.get_next_path_position()
var dir: Vector2 = nxt - pos
if dir.length() > 1.0:
dir = dir.normalized()
_data[base + 2] = dir.x * ELITE_SPEED
_data[base + 3] = dir.y * ELITE_SPEED
_data[base + 0] += _data[base + 2] * delta
_data[base + 1] += _data[base + 3] * delta
func get_pathfinder_count() -> int:
return _pathfinders.size()
func despawn_enemy(entity_id: int) -> void:
var slot: int = _entity_index_map.get(entity_id, -1)
if slot < 0:
return
StatusManager.remove_all_for_entity(entity_id)
SpatialGrid.remove_entity(entity_id)
if _pathfinders.has(entity_id):
_unregister_pathfinder(entity_id)
var last: int = _active_count - 1
if slot != last:
var base_slot: int = slot * ENEMY_STRIDE
var base_last: int = last * ENEMY_STRIDE
for s in ENEMY_STRIDE:
_data[base_slot + s] = _data[base_last + s]
var moved_id: int = _slot_entity_map[last]
_entity_index_map[moved_id] = slot
_slot_entity_map[slot] = moved_id
_entity_index_map.erase(entity_id)
_slot_entity_map.erase(last)
_active_count -= 1
## 获取指定 entity_id 的世界坐标(找不到返回 Vector2(-9999,-9999)
func get_pos_by_id(entity_id: int) -> Vector2:
var slot: int = _entity_index_map.get(entity_id, -1)
if slot < 0:
return Vector2(-9999.0, -9999.0)
var base: int = slot * ENEMY_STRIDE
return Vector2(_data[base + 0], _data[base + 1])
## S1 完整伤害接口:从 DamageContextPool 取 context,计算公式后扣 HP
## 公式(S1 简化):final_dmg = base_damage * mult
## S4 起补充 resistance / armor(参见 combat_mechanics_depth.md §4
func apply_damage_from_context(entity_id: int, ctx_id: int) -> bool:
var ctx: DamageContext = DamageContextPool.get_context(ctx_id)
if ctx == null:
return false
var final_dmg: float = ctx.calc_damage()
return apply_damage(entity_id, final_dmg, ctx.owner_id, true)
## 直接扣血接口;source_id 用于击杀归属,record_dps 控制是否计入 DPS
func apply_damage(entity_id: int, damage: float, source_id: int = -1, record_dps: bool = true) -> bool:
var slot: int = _entity_index_map.get(entity_id, -1)
if slot < 0:
return false
var combo_stacks: int = StatusManager.get_stacks(entity_id, StatusID.COMBO_MARK)
var combo_bonus: float = 1.0 + float(combo_stacks) * 0.02 # 每层 +2% 伤害
var base: int = slot * ENEMY_STRIDE
var armor: float = _ARMOR.get(int(_data[base + 6]), 0.0) # 原型护甲,扣减后保底 1
var final_dmg: float = maxf(1.0, damage * combo_bonus - armor)
_data[base + 4] -= final_dmg
if record_dps:
DpsTracker.record_damage(final_dmg)
if _data[base + 4] <= 0.0:
StatusManager.remove_all_for_entity(entity_id)
EventBus.emit(EventID.ENEMY_KILLED, {"entity_id": entity_id, "killer_id": source_id})
despawn_enemy(entity_id)
return true
return false
func has_entity(entity_id: int) -> bool:
return _entity_index_map.has(entity_id)
## 返回敌人血量百分比(权威来源:arch §5.2 get_hp_percent
func get_hp_percent(entity_id: int) -> float:
var slot: int = _entity_index_map.get(entity_id, -1)
if slot < 0:
return 0.0
var base: int = slot * ENEMY_STRIDE
var hp: float = _data[base + 4] # slot +4: hp
var hp_max: float = _data[base + 5] # slot +5: hp_max
return hp / max(hp_max, 0.001)
func get_active_count() -> int:
return _active_count
## 渲染:活跃敌人 SoA 位置 → MultiMesh(按原型设尺寸/颜色,Boss 大且醒目)
func sync_multimesh(mm: MultiMesh) -> void:
if mm == null:
return
var n: int = min(_active_count, mm.instance_count)
mm.visible_instance_count = n
for i in n:
var base: int = i * ENEMY_STRIDE
var etype: int = int(_data[base + 6])
var sz: float = _RENDER_SIZE.get(etype, 14.0)
mm.set_instance_transform_2d(i, Transform2D(
0.0, Vector2(sz, sz), 0.0,
Vector2(_data[base + 0], _data[base + 1])))
mm.set_instance_color(i, _RENDER_COLOR.get(etype, Color(0.9, 0.3, 0.3)))
## 返回最近敌人位置
func get_nearest_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
var best_sq: float = max_dist * max_dist
var best: Vector2 = origin
for i in _active_count:
var base: int = i * ENEMY_STRIDE
var dx: float = _data[base + 0] - origin.x
var dy: float = _data[base + 1] - origin.y
var dsq: float = dx * dx + dy * dy
if dsq < best_sq:
best_sq = dsq
best = Vector2(_data[base + 0], _data[base + 1])
return best
## 建立敌人位置快照(BulletManager homing 共用)
func fill_pos_snapshot(out_arr: PackedVector2Array) -> void:
out_arr.resize(_active_count)
for i in _active_count:
var base: int = i * ENEMY_STRIDE
out_arr[i] = Vector2(_data[base + 0], _data[base + 1])
## 同步 Node2D 显示位置(屏内 dirty sync
func sync_node_positions(enemy_nodes: Array) -> void:
for i in min(_active_count, enemy_nodes.size()):
if _visible_flags[i] == int(1):
var base: int = i * ENEMY_STRIDE
enemy_nodes[i].position = Vector2(_data[base + 0], _data[base + 1])
func reset() -> void:
_active_count = 0
_data.fill(0.0)
_visible_flags.fill(0)
_entity_index_map.clear()
_slot_entity_map.clear()
_next_entity_id = 1
# ADR-A4:释放所有寻路精英节点
for eid in _pathfinders.keys():
(_pathfinders[eid]["host"] as Node).queue_free()
_pathfinders.clear()
_pathfinder_order.clear()