初次提交

This commit is contained in:
2026-07-20 10:56:52 +08:00
commit 7bcc0026e0
462 changed files with 50191 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
## AudioBusID — AudioBus 索引常量(Autoload: AudioBusID
## 与 AudioServer 路由层(architecture_design.md ADR-A3)对应。
## 禁止在 SettingsManager 等处使用字符串查 Bus(跨切片约束)。
extends Node
const MASTER: int = 0
const BGM: int = 1
const BGM_BASE: int = 2
const BGM_LAYER: int = 3
const SFX: int = 4
const SFX_COMBAT: int = 5
const SFX_SPATIAL: int = 6
const UI: int = 7
const AMBIENCE: int = 8
const VOICE: int = 9
+1
View File
@@ -0,0 +1 @@
uid://douqjxwt18yns
+110
View File
@@ -0,0 +1,110 @@
## AudioManager — 音效池 + 节流(Autoload: AudioManager
## 权威来源:development_plan.md S6 P132 AudioStreamPlayer2D 池、0.1s 节流)
## 无音频素材时用程序化蜂鸣占位(同 API,放素材即替换)。SFX 总线 send→Master,受 SettingsManager 音量影响。
extends Node
const POOL_SIZE: int = 32
const THROTTLE_SEC: float = 0.1 # 同一 sound_id 两次播放最小间隔
## sound_id → 占位蜂鸣参数 [freq, ms](有真实 res://audio/sfx/<id>.* 时优先加载)
const _SFX_DEFS: Dictionary = {
"hit": [900.0, 60.0],
"kill": [420.0, 90.0],
"hurt": [180.0, 140.0],
"wave_complete": [660.0, 220.0],
"boss": [120.0, 400.0],
"shop": [780.0, 120.0],
"buy": [1040.0, 80.0],
"level_up": [880.0, 180.0],
}
var _players: Array = [] # Array[AudioStreamPlayer2D]
var _next: int = 0 # round-robin 游标
var _streams: Dictionary = {} # sound_id → AudioStream(懒构建缓存)
var _last_play: Dictionary = {} # sound_id → 上次播放 ticks_msec
func _ready() -> void:
_ensure_sfx_bus()
for i in POOL_SIZE:
var p := AudioStreamPlayer2D.new()
p.bus = "SFX"
add_child(p)
_players.append(p)
# 事件挂钩(无素材时静默蜂鸣)
EventBus.subscribe(EventID.ENEMY_KILLED, func(_p): play("kill"))
EventBus.subscribe(EventID.BULLET_HIT, func(p): play("hit", p.get("hit_pos", Vector2.ZERO)))
EventBus.subscribe(EventID.PLAYER_DAMAGED, func(_p): play("hurt"))
EventBus.subscribe(EventID.WAVE_COMPLETE, func(_p): play("wave_complete"))
EventBus.subscribe(EventID.BOSS_SPAWNED, func(_p): play("boss"))
EventBus.subscribe(EventID.SHOP_OPENED, func(_p): play("shop"))
EventBus.subscribe(EventID.LEVEL_UP, func(_p): play("level_up"))
func _ensure_sfx_bus() -> void:
if AudioServer.get_bus_index("SFX") < 0:
var idx: int = AudioServer.bus_count
AudioServer.add_bus(idx)
AudioServer.set_bus_name(idx, "SFX")
AudioServer.set_bus_send(idx, "Master") # 受 Master 音量(SettingsManager)影响
## 播放音效;返回 true=已触发,false=节流跳过 / 无可用流
func play(sound_id: String, world_pos: Vector2 = Vector2.ZERO) -> bool:
var now: int = Time.get_ticks_msec()
var last: int = int(_last_play.get(sound_id, -100000))
if now - last < int(THROTTLE_SEC * 1000.0):
return false # 0.1s 节流:抑制同一音效高频刷屏
var stream: AudioStream = _get_stream(sound_id)
if stream == null:
return false
_last_play[sound_id] = now
var p: AudioStreamPlayer2D = _players[_next]
_next = (_next + 1) % POOL_SIZE
p.stream = stream
p.global_position = world_pos
p.play()
return true
func _get_stream(sound_id: String) -> AudioStream:
if _streams.has(sound_id):
return _streams[sound_id]
# 优先真实素材
for ext in [".ogg", ".wav", ".mp3"]:
var path: String = "res://audio/sfx/%s%s" % [sound_id, ext]
if ResourceLoader.exists(path):
var s = load(path)
if s is AudioStream:
_streams[sound_id] = s
return s
# 回退:程序化蜂鸣
if _SFX_DEFS.has(sound_id):
var def: Array = _SFX_DEFS[sound_id]
var beep := _make_beep(float(def[0]), float(def[1]))
_streams[sound_id] = beep
return beep
_streams[sound_id] = null
return null
## 生成短蜂鸣(16-bit PCM 正弦 + 线性衰减包络)
func _make_beep(freq: float, ms: float) -> AudioStreamWAV:
var sr: int = 22050
var n: int = max(1, int(sr * ms / 1000.0))
var data := PackedByteArray()
data.resize(n * 2)
for i in n:
var t: float = float(i) / float(sr)
var env: float = 1.0 - float(i) / float(n)
var sample: int = int(sin(t * freq * TAU) * 11000.0 * env)
data.encode_s16(i * 2, sample)
var w := AudioStreamWAV.new()
w.format = AudioStreamWAV.FORMAT_16_BITS
w.mix_rate = sr
w.stereo = false
w.data = data
return w
## 当前正在播放的池实例数(测试 / 调试用)
func active_voices() -> int:
var c: int = 0
for p in _players:
if p.playing:
c += 1
return c
+1
View File
@@ -0,0 +1 @@
uid://cufd7gicqywyq
+178
View File
@@ -0,0 +1,178 @@
## BulletManager — 子弹管理器 AutoloadGDScript 接口层)
## 热路径由 C# BulletManagerCs 子节点驱动。
## 权威来源:architecture_design.md §4.2
##
## SoA 完整布局(BULLET_STRIDE=12):
## [ x, y, vx, vy, lifetime, radius, base_damage, damage_mult, damage_type, owner_id, source_tags, acceleration ]
## 0 1 2 3 4 5 6 7 8 9 10 11
## 冷数据(pierce/bounce/homing/payload_id)→ _bullet_contexts: Dictionary
extends Node
const BULLET_STRIDE: int = 12 # 禁止裸整数 12(跨切片约束)
const MAX_BULLETS: int = 2048 # 预分配 SoA 大小
# SoA 热数组(由 BulletManagerCs 直接读写)
var _data: PackedFloat32Array = PackedFloat32Array()
var _active_count: int = 0
# 冷数据(非热路径)
var _bullet_contexts: Dictionary = {}
# 敌人位置快照(homing 共用,由 _physics_process 帧头建立)
var _enemy_pos_snapshot: PackedVector2Array = PackedVector2Array()
var _cs_node: Node = null
func _ready() -> void:
_data.resize(MAX_BULLETS * BULLET_STRIDE)
_data.fill(0.0)
func _physics_process(delta: float) -> void:
EnemyManager.fill_pos_snapshot(_enemy_pos_snapshot)
if not _cs_node:
_gd_integrate(delta)
# GDScript 回退路径(C# 未就绪时 / S0/S1 基线验证)
func _gd_integrate(delta: float) -> void:
var i: int = 0
while i < _active_count:
var base: int = i * BULLET_STRIDE
_data[base + 0] += _data[base + 2] * delta # x += vx * dt
_data[base + 1] += _data[base + 3] * delta # y += vy * dt
_data[base + 2] += _data[base + 11] * delta # vx += accel * dt
_data[base + 3] += _data[base + 11] * delta # vy += accel * dt
_data[base + 4] -= delta # lifetime -= dt
if _data[base + 4] <= 0.0:
_swap_and_pop(i)
elif _check_collision(i, base):
# 命中处理已在 _check_collision 内完成,子弹被回收
pass # _active_count 已减少,i 不递增
else:
i += 1
# S1 碰撞检测:数据驱动,使用 SpatialGrid 查询
# 命中后扣血;pierce_remaining=0 时回收子弹;返回 true 表示子弹已被回收
func _check_collision(bullet_idx: int, base: int) -> bool:
var bx: float = _data[base + 0]
var by: float = _data[base + 1]
var br: float = _data[base + 5] # bullet radius
var b_dmg: float = _data[base + 6] # base_damage
var b_mult: float = _data[base + 7] # damage_mult
var b_type: int = int(_data[base + 8])
var b_own: int = int(_data[base + 9])
var query_r: float = br + 16.0 # 16px = 敌人碰撞体估算半径
var hits: PackedInt32Array = SpatialGrid.query_circle(Vector2(bx, by), query_r)
if hits.is_empty():
return false
for entity_id in hits:
var ene_pos: Vector2 = EnemyManager.get_pos_by_id(entity_id)
if ene_pos == Vector2(-9999.0, -9999.0):
continue
var dx: float = bx - ene_pos.x
var dy: float = by - ene_pos.y
var dist_sq: float = dx * dx + dy * dy
if dist_sq > query_r * query_r:
continue
# 命中:通过 DamageContextPool 发出伤害
var ctx_id: int = DamageContextPool.acquire(b_dmg, b_mult, b_type, b_own, false, 0.0)
EnemyManager.apply_damage_from_context(entity_id, ctx_id)
DamageContextPool.release(ctx_id)
var hit_pos: Vector2 = EnemyManager.get_pos_by_id(entity_id)
if hit_pos == Vector2(-9999.0, -9999.0):
hit_pos = Vector2(bx, by)
VFXManager.play("hit_spark", hit_pos)
EventBus.emit(EventID.BULLET_HIT, {"bullet_id": bullet_idx, "target_id": entity_id, "hit_pos": hit_pos})
# S4: 命中施加状态 / 连击标记
var cold: Dictionary = _bullet_contexts.get(bullet_idx, {})
var status_id: int = int(cold.get("apply_status_id", -1))
if status_id > 0:
StatusManager.apply(entity_id, status_id, 1, -1.0, b_own)
if cold.get("apply_combo_mark", false):
StatusManager.apply(entity_id, StatusID.COMBO_MARK, 1, 2.0, b_own)
# S3: 命中触发子荷载
var payload_id: int = int(cold.get("on_hit_payload_id", -1))
if payload_id >= 0:
var parent_depth: int = int(cold.get("trigger_parent_depth", 0))
SpellEvaluator.execute_sub(payload_id, hit_pos, b_own, parent_depth + 1)
# pierce 处理
var pierce: int = int(cold.get("pierce_remaining", 0))
if pierce > 0:
cold["pierce_remaining"] = pierce - 1
_bullet_contexts[bullet_idx] = cold
return false # 穿透:子弹继续飞行
_swap_and_pop(bullet_idx)
return true # 子弹已回收
return false
func _swap_and_pop(idx: int) -> void:
var last: int = _active_count - 1
if idx != last:
var base_idx: int = idx * BULLET_STRIDE
var base_last: int = last * BULLET_STRIDE
for s in BULLET_STRIDE:
_data[base_idx + s] = _data[base_last + s]
if _bullet_contexts.has(last):
_bullet_contexts[idx] = _bullet_contexts[last]
_bullet_contexts.erase(last)
elif _bullet_contexts.has(idx):
_bullet_contexts.erase(idx)
_active_count -= 1
# ── 对外接口 ─────────────────────────────────────────────────────
## 生成子弹,返回 bullet_id(比 -1 表示满容)
## 参数附带冷数据:cold_data = {"on_hit_payload_id": int, "pierce_remaining": int, ...}
func spawn_bullet(pos: Vector2, vel: Vector2, lifetime: float, radius: float,
base_damage: float, damage_mult: float, damage_type: int,
owner_id: int, source_tags: int = 0, acceleration: float = 0.0,
cold_data: Dictionary = {}) -> int:
if _active_count >= MAX_BULLETS:
return -1
var bullet_id: int = _active_count
var base: int = bullet_id * BULLET_STRIDE
_data[base + 0] = pos.x
_data[base + 1] = pos.y
_data[base + 2] = vel.x
_data[base + 3] = vel.y
_data[base + 4] = lifetime
_data[base + 5] = radius
_data[base + 6] = base_damage
_data[base + 7] = damage_mult
_data[base + 8] = float(damage_type)
_data[base + 9] = float(owner_id)
_data[base + 10] = float(source_tags)
_data[base + 11] = acceleration
if not cold_data.is_empty():
_bullet_contexts[bullet_id] = cold_data
_active_count += 1
return bullet_id
## 提前回收:lifetime 置 0C# 下帧 SoA 清理
func despawn_bullet(bullet_id: int) -> void:
if bullet_id < 0 or bullet_id >= _active_count:
return
_data[bullet_id * BULLET_STRIDE + 4] = 0.0 # slot +4: lifetime
func get_active_count() -> int:
return _active_count
## 渲染:将活跃子弹的 SoA 位置批量写入 MultiMesh(§4.4SoA 索引 ↔ instance 索引共享)
## visible_instance_count 控制渲染数量,避免每帧重分配 instance_count
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 * BULLET_STRIDE
var r: float = _data[base + 5]
mm.set_instance_transform_2d(i, Transform2D(
0.0, Vector2(r * 2.0, r * 2.0), 0.0,
Vector2(_data[base + 0], _data[base + 1])))
func get_nearest_enemy_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
return EnemyManager.get_nearest_pos(origin, max_dist)
func reset() -> void:
_active_count = 0
_data.fill(0.0)
_bullet_contexts.clear()
+1
View File
@@ -0,0 +1 @@
uid://d07qoobgfgyr3
+39
View File
@@ -0,0 +1,39 @@
## ConfigMgr — JSON 配置加载器(Autoload: ConfigMgr
## 权威来源:architecture_design.md §2 数据层
extends Node
var _cache: Dictionary = {} # { file_path: String → Dictionary }
## 加载 JSON 配置文件(带缓存)
func load_json(path: String) -> Dictionary:
if _cache.has(path):
return _cache[path]
if not FileAccess.file_exists(path):
push_warning("ConfigMgr: 文件不存在 %s" % path)
return {}
var text: String = FileAccess.get_file_as_string(path)
var parsed = JSON.parse_string(text)
if parsed == null:
push_error("ConfigMgr: JSON 解析失败 %s" % path)
return {}
if parsed is Dictionary:
_cache[path] = parsed
return parsed
push_error("ConfigMgr: JSON 根节点不是 Dictionary %s" % path)
return {}
## 查询键值(支持点号路径,如 "enemies.basic.hp"
func get_value(path: String, key: String, default_val = null):
var data: Dictionary = load_json(path)
var keys: PackedStringArray = key.split(".")
var node = data
for k in keys:
if node is Dictionary and node.has(k):
node = node[k]
else:
return default_val
return node
## 清除缓存(热重载时调用)
func clear_cache() -> void:
_cache.clear()
+1
View File
@@ -0,0 +1 @@
uid://ca4ohbfymmam7
+9
View File
@@ -0,0 +1,9 @@
## CoreFeatureTag — Core 特性标签整数常量(Autoload: CoreFeatureTag
## 禁止在判断逻辑中使用裸字符串(跨切片约束 ADR-R5-N2)。
extends Node
const PERSISTENT_MEMORY: int = 1 # 寄存器跨帧保留(wand_memory Core
const DUAL_STREAM: int = 2 # 双流执行(P6-N12S6 P1 实现)
const ALWAYS_CAST_LAST: int = 3 # 尾槽 MODIFIER/TRIGGER/LOGIC 静默跳过(P6-N7
const SHUFFLE_DECK: int = 4 # ACTION 原子单元乱序(P5-N2
const INFINITE_SPELLS: int = 5 # 卖血流,配合 heavy_costP6-N18
@@ -0,0 +1 @@
uid://c7sxbv1ede8h5
+108
View File
@@ -0,0 +1,108 @@
## CrashReporter — 全局崩溃/异常日志 Autoload
##
## 职责:
## 1. 捕获 NOTIFICATION_CRASH(引擎级崩溃信号)并写入 user://crash_log.txt
## 2. 捕获 NOTIFICATION_WM_CLOSE_REQUEST(正常退出),记录最后一帧状态
## 3. 游戏启动时检查上次是否有未完成的崩溃日志并上报 EventBus(供 HUD 展示提示)
##
## 注意:此 Autoload 必须排在所有其他 Autoload 之前注册(project.godot [autoload] 顺序),
## 确保其他系统崩溃时本 Reporter 已初始化。
##
## 使用方式:
## CrashReporter.log_error("模块名", "错误描述") → 写入 crash_log.txt(不崩溃)
## CrashReporter.log_fatal("模块名", "致命错误") → 写入后 crash(开发模式下)
extends Node
const LOG_PATH: String = "user://crash_log.txt"
const MAX_LOG_BYTES: int = 512 * 1024 # 512KB 上限,防止无限增长
var _session_start_time: float = 0.0
var _last_wave: int = 0 # 由 WaveManager 每波更新,崩溃时记录当前波次
# ── 生命周期 ────────────────────────────────────────────────────────────────
func _ready() -> void:
_session_start_time = Time.get_unix_time_from_system()
_rotate_log_if_oversized()
_check_previous_crash()
func _notification(what: int) -> void:
match what:
NOTIFICATION_CRASH:
_write_crash_entry("CRASH", "引擎级崩溃(NOTIFICATION_CRASH")
NOTIFICATION_WM_CLOSE_REQUEST:
_write_crash_entry("SHUTDOWN", "正常退出")
get_tree().quit()
# ── 公共接口 ────────────────────────────────────────────────────────────────
## 记录非致命错误(push_error 级别,不终止运行)
func log_error(module: String, message: String) -> void:
push_error("[%s] %s" % [module, message])
_append_log("ERROR", module, message)
## 记录致命错误(开发模式下触发断言失败,发布模式下仅记录)
func log_fatal(module: String, message: String) -> void:
_write_crash_entry("FATAL", "[%s] %s" % [module, message])
assert(false, "[CrashReporter] Fatal: %s%s" % [module, message])
## 由 WaveManager 调用,更新崩溃时记录的波次信息
func set_current_wave(wave: int) -> void:
_last_wave = wave
# ── 内部实现 ─────────────────────────────────────────────────────────────────
func _write_crash_entry(level: String, reason: String) -> void:
var elapsed: float = Time.get_unix_time_from_system() - _session_start_time
var entry: String = (
"\n=== %s @ %s (session +%.1fs, wave=%d) ===\n%s\n" % [
level,
Time.get_datetime_string_from_system(),
elapsed,
_last_wave,
reason
]
)
_append_log_raw(entry)
func _append_log(level: String, module: String, message: String) -> void:
var line: String = "[%s][%s] %s%s\n" % [
Time.get_time_string_from_system(),
level, module, message
]
_append_log_raw(line)
func _append_log_raw(text: String) -> void:
var f := FileAccess.open(LOG_PATH, FileAccess.READ_WRITE)
if f == null:
f = FileAccess.open(LOG_PATH, FileAccess.WRITE)
if f == null:
push_warning("CrashReporter: 无法打开 %s" % LOG_PATH)
return
f.seek_end(0)
f.store_string(text)
func _rotate_log_if_oversized() -> void:
if not FileAccess.file_exists(LOG_PATH):
return
if FileAccess.get_file_as_bytes(LOG_PATH).size() > MAX_LOG_BYTES:
var archived: String = LOG_PATH.replace(".txt", "_prev.txt")
DirAccess.rename_absolute(
ProjectSettings.globalize_path(LOG_PATH),
ProjectSettings.globalize_path(archived)
)
func _check_previous_crash() -> void:
## 检查上次是否有 CRASH 或 FATAL 记录,若有则在下次启动时通过 EventBus 通知
if not FileAccess.file_exists(LOG_PATH):
return
var content: String = FileAccess.get_file_as_string(LOG_PATH)
if "=== CRASH" in content or "=== FATAL" in content:
# 延迟到第一帧,确保 EventBus 已初始化
call_deferred("_emit_crash_detected")
func _emit_crash_detected() -> void:
if Engine.has_singleton("EventBus"):
# EventID 暂用 0(特殊保留 ID),HUD 订阅后展示"上次游戏异常退出"提示
EventBus.emit(0, {"source": "crash_reporter", "log_path": LOG_PATH})
+1
View File
@@ -0,0 +1 @@
uid://dk2lnqdqniw0h
+28
View File
@@ -0,0 +1,28 @@
## DamageContext — 伤害上下文数据类(必须单独放在此文件)
## 权威来源:implementation_plan.md §2.1 E-N2 P6-N49
## 禁止将 class_name DamageContext 写入 damage_context_pool.gd
extends RefCounted
class_name DamageContext
var base_damage: float = 0.0
var mult: float = 1.0
var damage_type: int = 0 # DamageType.PHYSICAL
var owner_id: int = -1
var source_tags: int = 0
var is_crit: bool = false
var pierce_rate: float = 0.0 # 护甲穿透率 0.0~1.0
func reset() -> void:
base_damage = 0.0
mult = 1.0
damage_type = 0
owner_id = -1
source_tags = 0
is_crit = false
pierce_rate = 0.0
## 伤害公式:((base_damage + add) × mult) × (1 resistance) armor
## S1 简化版:返回 base_damage * mult
func calc_damage(armor: float = 0.0, resistance: float = 0.0) -> float:
var effective_armor: float = armor * (1.0 - pierce_rate)
return max(0.0, base_damage * mult * (1.0 - resistance) - effective_armor)
+1
View File
@@ -0,0 +1 @@
uid://d3xq216d8r3kh
+69
View File
@@ -0,0 +1,69 @@
## DamageContextPool — 伤害上下文对象池(Autoload: DamageContextPool
## 权威来源:implementation_plan.md §2.1 E-N2 P6-N67
## 本文件不声明 class_name!禁止将 DamageContext 写入这里
extends Node
const POOL_SIZE: int = 64
var _slots: Array = []
var _free_ids: PackedInt32Array = PackedInt32Array()
var _in_use: PackedByteArray = PackedByteArray()
func _ready() -> void:
_slots.resize(POOL_SIZE)
_free_ids.resize(POOL_SIZE)
_in_use.resize(POOL_SIZE)
for i in POOL_SIZE:
_slots[i] = DamageContext.new()
_free_ids[i] = POOL_SIZE - 1 - i
_in_use[i] = 0
## acquire 内部已调用 ctx.reset()P6-N67
func acquire(base_dmg: float, m: float, dtype: int, owner: int, crit: bool, pierce: float) -> int:
if _free_ids.is_empty():
push_warning("DamageContextPool: 池已耗尽 (POOL_SIZE=%d)" % POOL_SIZE)
var new_id: int = _slots.size()
_slots.append(DamageContext.new())
_in_use.append(0)
_fill_slot(new_id, base_dmg, m, dtype, owner, crit, pierce)
return new_id
var id: int = _free_ids[_free_ids.size() - 1]
_free_ids.resize(_free_ids.size() - 1)
_fill_slot(id, base_dmg, m, dtype, owner, crit, pierce)
return id
func _fill_slot(id: int, base_dmg: float, m: float, dtype: int, owner: int, crit: bool, pierce: float) -> void:
var ctx: DamageContext = _slots[id]
ctx.reset()
ctx.base_damage = base_dmg
ctx.mult = m
ctx.damage_type = dtype
ctx.owner_id = owner
ctx.is_crit = crit
ctx.pierce_rate = pierce
_in_use[id] = int(1)
func get_context(id: int) -> DamageContext:
if id < 0 or id >= _slots.size() or _in_use[id] == 0:
return null
return _slots[id]
func release(id: int) -> void:
if id < 0 or id >= _slots.size() or _in_use[id] == 0:
push_warning("DamageContextPool.release: 无效或重复归还 ID=%d" % id)
return
_in_use[id] = 0
_free_ids.append(id)
func get_pool_usage() -> String:
var used: int = 0
for i in _in_use.size():
if _in_use[i] != 0:
used += 1
return "%d / %d" % [used, _slots.size()]
func reset() -> void:
_free_ids.clear()
for i in _slots.size():
_in_use[i] = 0
_free_ids.append(i)
@@ -0,0 +1 @@
uid://cw107yf7db43l
+11
View File
@@ -0,0 +1,11 @@
## DamageType — 伤害类型整数常量(Autoload: DamageType
## 与 BulletManager SoA slot+8 (damage_type) 字段对应。
## 扩展新类型时从 6 起递增,并同步更新 implementation_plan.md §2.1 DamageContext 表。
extends Node
const PHYSICAL: int = 0
const FIRE: int = 1
const ICE: int = 2
const LIGHTNING: int = 3
const POISON: int = 4
const ARCANE: int = 5
+1
View File
@@ -0,0 +1 @@
uid://gydgmnbchqig
+50
View File
@@ -0,0 +1,50 @@
## DpsTracker — DPS 环形缓冲区(Autoload: DpsTracker
## 权威来源:implementation_plan.md §2.5.EP6-N38、P6-N47
extends Node
const DPS_WINDOW_SEC: float = 3.0
const _RB_SIZE: int = 256
var _rb_time: PackedFloat64Array = PackedFloat64Array()
var _rb_dmg: PackedFloat32Array = PackedFloat32Array()
var _rb_head: int = 0
var _rb_count: int = 0
var _accumulated_dmg: float = 0.0
func _ready() -> void:
_rb_time.resize(_RB_SIZE)
_rb_dmg.resize(_RB_SIZE)
func record_damage(amount: float) -> void:
if amount <= 0.0:
return
var now: float = Time.get_ticks_msec() / 1000.0
_rb_time[_rb_head] = now
_rb_dmg[_rb_head] = amount
_rb_head = (_rb_head + 1) % _RB_SIZE
if _rb_count < _RB_SIZE:
_rb_count += 1
_accumulated_dmg += amount
func get_dps() -> float:
_recalc_window(Time.get_ticks_msec() / 1000.0)
return _accumulated_dmg / DPS_WINDOW_SEC
func _recalc_window(now: float) -> void:
var sum: float = 0.0
var valid: int = 0
var tail: int = (_rb_head - _rb_count + _RB_SIZE) % _RB_SIZE
for k in _rb_count:
var idx: int = (tail + k) % _RB_SIZE
if (now - _rb_time[idx]) <= DPS_WINDOW_SEC:
sum += _rb_dmg[idx]
valid += 1
_accumulated_dmg = sum
_rb_count = valid
func reset() -> void:
_rb_head = 0
_rb_count = 0
_accumulated_dmg = 0.0
_rb_time.fill(0.0)
_rb_dmg.fill(0.0)
+1
View File
@@ -0,0 +1 @@
uid://dpjv1tbiar8cm
+18
View File
@@ -0,0 +1,18 @@
## DropManager — 掉落物管理(Autoload: DropManager
## S2 范围:敌人死亡时直接发放 XP/金币(无拾取物步骤)
## S3+ 再加入地面拾取物
extends Node
## S2 内置掉落参数
const XP_PER_KILL: int = 5
const GOLD_PER_KILL: int = 2
func _ready() -> void:
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
func _on_enemy_killed(payload: Dictionary) -> void:
PlayerStats.gain_xp(XP_PER_KILL)
PlayerStats.gain_gold(GOLD_PER_KILL)
func reset() -> void:
pass # 无状态需要重置
+1
View File
@@ -0,0 +1 @@
uid://dbwhq00c61nxt
+76
View File
@@ -0,0 +1,76 @@
## EndlessRecords — 本地排行榜(Autoload: EndlessRecords
## 权威来源:certification_checklist.md ST-40/41HMAC-SHA256 防篡改)、P6-N19 评分公式
## 排名:到达波次为主、同波次按用时升序;在线分 = wave*100000 + (86400 - elapsed_sec)
extends Node
const RECORDS_PATH: String = "user://endless_records.json"
const MAX_RECORDS: int = 50
## HMAC 密钥(不入存档;仅防普通玩家手改,无法对抗逆向,见 ST-40)
## PackedByteArray(...) 非常量表达式,故用 var 在加载时初始化
var _HMAC_KEY: PackedByteArray = PackedByteArray([
0xA3, 0x7F, 0x2C, 0x91, 0x4E, 0xD8, 0x12, 0x6B,
0xF0, 0x39, 0x5A, 0xCC, 0x77, 0x1D, 0x8E, 0x44,
0x22, 0xB9, 0x60, 0x3F, 0xE1, 0x05, 0x9C, 0x7A,
0x53, 0xD4, 0x08, 0x6F, 0xAB, 0x31, 0xC7, 0x90,
])
## P6-N19:单整数双维度评分(波次高优先;同波次剩余秒数高优先)
static func compute_score(wave: int, elapsed_sec: int) -> int:
return wave * 100000 + (86400 - clampi(elapsed_sec, 0, 86400))
func _sign(data: String) -> String:
var crypto := Crypto.new()
var hmac := crypto.hmac_digest(HashingContext.HASH_SHA256, _HMAC_KEY, data.to_utf8_buffer())
return hmac.hex_encode()
## 追加一条记录并持久化;返回排序后的全部记录
func add_record(wave: int, elapsed_sec: int, kills: int) -> Array:
var records: Array = load_records()
records.append({
"wave": wave,
"elapsed": elapsed_sec,
"kills": kills,
"score": compute_score(wave, elapsed_sec),
})
records = _sort_records(records)
if records.size() > MAX_RECORDS:
records.resize(MAX_RECORDS)
save_records(records)
return records
## data 以 JSON 字符串存储并对该精确字符串签名(避免 JSON int↔float 往返破坏签名)
func save_records(records: Array) -> void:
var payload: String = JSON.stringify(records)
var signed: Dictionary = {"data": payload, "sig": _sign(payload)}
var f := FileAccess.open(RECORDS_PATH, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(signed))
f.close()
func load_records() -> Array:
if not FileAccess.file_exists(RECORDS_PATH):
return []
var raw = JSON.parse_string(FileAccess.get_file_as_string(RECORDS_PATH))
if not (raw is Dictionary):
return []
var payload: String = String(raw.get("data", ""))
if _sign(payload) != String(raw.get("sig", "")):
push_warning("EndlessRecords: 签名验证失败,分数不可信,拒绝载入") # ST-41
return []
var data = JSON.parse_string(payload)
return data if data is Array else []
## 波次降序、同波次用时升序(P6-N19)
func _sort_records(records: Array) -> Array:
records.sort_custom(func(a, b):
var wa: int = int(a.get("wave", 0))
var wb: int = int(b.get("wave", 0))
if wa != wb:
return wa > wb
return int(a.get("elapsed", 99999)) < int(b.get("elapsed", 99999)))
return records
func get_top(n: int = 10) -> Array:
var r: Array = load_records()
return r.slice(0, min(n, r.size()))
+1
View File
@@ -0,0 +1 @@
uid://docfp01fd1red
+329
View File
@@ -0,0 +1,329 @@
## 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()
+1
View File
@@ -0,0 +1 @@
uid://ctmujoxxqga04
+42
View File
@@ -0,0 +1,42 @@
## EventBus — 全局事件总线(Autoload: EventBus
## 权威接口定义:architecture_design.md §6.2
##
## 使用约定:
## - 订阅:EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
## - 发送:EventBus.emit(EventID.ENEMY_KILLED, {"entity_id": id})
## - 取消:EventBus.unsubscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
extends Node
var _listeners: Dictionary = {} # { event_id: int → Array[Callable] }
func subscribe(event_id: int, callback: Callable) -> void:
if not _listeners.has(event_id):
_listeners[event_id] = []
var arr: Array = _listeners[event_id]
if not arr.has(callback):
arr.append(callback)
func unsubscribe(event_id: int, callback: Callable) -> void:
if not _listeners.has(event_id):
return
_listeners[event_id].erase(callback)
func emit(event_id: int, payload: Dictionary = {}) -> void:
if not _listeners.has(event_id):
return
var arr: Array = _listeners[event_id]
# 逆序迭代防止回调内部 unsubscribe 导致跳过
for i in range(arr.size() - 1, -1, -1):
arr[i].call(payload)
## C# 批量通知接口:一次调用发送多个事件(ADR-L1 规则3)
## events: Array of [event_id: int, payload: Dictionary]
func emit_batch(events: Array) -> void:
for ev in events:
var eid: int = ev[0]
var pay: Dictionary = ev[1] if ev.size() > 1 else {}
emit(eid, pay)
## 清空所有订阅(关卡结束 / 场景切换时调用)
func reset() -> void:
_listeners.clear()
+1
View File
@@ -0,0 +1 @@
uid://c1aikk2kvjgiq
+49
View File
@@ -0,0 +1,49 @@
## EventID — 全局事件 ID 常量目录(Autoload: EventID
## 权威来源:implementation_plan.md §2.1
## 规则:新增事件从 22 起递增,并同步更新本文件。禁止硬编码整数替代常量。
extends Node
# ── 基础系统事件 (1-4) ────────────────────────────────────────────
const GAME_STARTED: int = 1
const GAME_OVER: int = 2
const GAME_PAUSED: int = 3
const GAME_RESUMED: int = 4
# ── 战斗事件 (5-10) ───────────────────────────────────────────────
const PLAYER_DAMAGED: int = 5
const PLAYER_DIED: int = 6
const ENEMY_KILLED: int = 7
const WAVE_COMPLETE: int = 8
const CHARGE_STATE_CHANGED: int = 9 # 供 UI 使用,非边沿信号
const ON_PLAYER_HURT: int = 10 # 受击反馈(VFX/音效触发)
# ── 法术事件 (11-15) ──────────────────────────────────────────────
const SPELL_CAST_BEGIN: int = 11
const SPELL_CAST_END: int = 12
const DASH_TRIGGERED: int = 13 # 冲刺边沿检测(P6-N36
const CHARGE_FIRED: int = 14 # 蓄力完成一次性边沿(P6-N69);SpellEvaluator 订阅此而非 CHARGE_STATE_CHANGED
const SPELL_EQUIPPED: int = 15
# ── 进度 / UI 事件 (16-21) ────────────────────────────────────────
const LEVEL_UP: int = 16
const SHOP_OPENED: int = 17
const ACHIEVEMENT_UNLOCKED: int = 18 # 负载:{ "achievement_id": String }ST-10 Steam 成就钩子
const BOSS_SPAWNED: int = 19
const GAME_STATE_CHANGED: int = 20 # 负载:{ "from": String, "to": String }
const SPELL_DROP_PICKUP: int = 21 # 负载:{ "spell_id": String, "pos": Vector2 }
# ── S1 新增事件 (22+) ─────────────────────────────────────────────
const APPLY_DAMAGE: int = 22 # 负载:damage_context_id: intEnemyManager 订阅处理伤害
# ── S3 新增事件 (23+) ─────────────────────────────────────────────
const SPELL_DEPTH_EXCEEDED: int = 23 # 负载:{caster_id, depth}HUD 订阅显示警告
const BULLET_HIT_TRIGGER: int = 24 # 负载:{bullet_id, payload_id, hit_pos};调试用
# ── S4 新增事件 (25+) ─────────────────────────────────────────────
const STATUS_APPLIED: int = 25 # 负载:{target_id, status_type, stacks}
const BULLET_HIT: int = 26 # 负载:{bullet_id, target_id, hit_pos}
# ── S5 新增事件 (27+) ─────────────────────────────────────────────
const MINION_SPAWNED: int = 27 # 负载:{minion_id, owner_id, current_count}
const MINION_EXPIRED: int = 28 # 负载:{minion_id, owner_id, current_count}
# 每新增一条须同步更新 implementation_plan.md §2.1 事件目录表
+1
View File
@@ -0,0 +1 @@
uid://chtsfdmd0ykor
+33
View File
@@ -0,0 +1,33 @@
## LocaleManager — 本地化加载(Autoload: Locale
## 权威来源:architecture_design.md ADR-A1tr("KEY") + .po + TranslationServer
## 加载 translations/*.po 并注册到 TranslationServer,设默认语言。
## 商业发行至少支持 简中 / 繁中 / 英 / 日 四语(P-S6-06)。
extends Node
const LOCALES: Array = ["zh_CN", "zh_TW", "en", "ja"]
const DEFAULT_LOCALE: String = "zh_CN"
func _ready() -> void:
for loc in LOCALES:
var path: String = "res://translations/%s.po" % loc
if ResourceLoader.exists(path):
var t = load(path)
if t is Translation:
TranslationServer.add_translation(t)
TranslationServer.set_locale(DEFAULT_LOCALE)
func set_locale(loc: String) -> void:
TranslationServer.set_locale(loc)
func get_locale() -> String:
return TranslationServer.get_locale()
## 调试 / 设置菜单用:循环切换语言
func cycle_locale() -> String:
var cur: String = TranslationServer.get_locale()
var idx: int = LOCALES.find(cur)
if idx < 0:
idx = 0
var nxt: String = LOCALES[(idx + 1) % LOCALES.size()]
TranslationServer.set_locale(nxt)
return nxt
+1
View File
@@ -0,0 +1 @@
uid://dpk1b28i6qybx
+103
View File
@@ -0,0 +1,103 @@
## MinionManager — 玩家召唤物管理(Autoload: MinionManager
## 权威来源:architecture_design.md §5.12 + ADR-R4-N4(独立管理器,方案 B)
## 数量上限小(MAX_MINIONS=20ADR-R4-N4/plan S5),不需 C# 热路径,用 Array[Dictionary]
## 炮台型(Stationary)AI:定点对范围内最近敌人开火
##
## 注:plan S5 提到 "SoA stride=8",但 §5.12 权威实现明确用 Array[Dictionary]
## 且 "不需要 C# 热路径",故按 §5.12 实现;SoA 化为后续可选优化。
extends Node
const MAX_MINIONS: int = 20 # ADR-R4-N4:同场最大召唤物,超出 FIFO 顶替最旧
const DEFAULT_LIFETIME: float = 30.0
## 活跃召唤物:{ id, owner_id, pos, lifetime_rem, range, fire_interval, fire_cd,
## damage, bullet_speed, bullet_radius, bullet_lifetime, damage_type }
var _minions: Array = []
var _next_id: int = 0
## def: Dictionary(项目无 .tres,用字典描述召唤物面板)
## 可选键:lifetime, range, fire_interval, damage, bullet_speed, bullet_radius,
## bullet_lifetime, damage_type
func spawn_minion(def: Dictionary, owner_id: int, pos: Vector2) -> int:
if _minions.size() >= MAX_MINIONS:
_expire(_minions[0]["id"]) # FIFO:顶掉最旧
var lifetime: float = float(def.get("lifetime", DEFAULT_LIFETIME))
if lifetime <= 0.0:
lifetime = DEFAULT_LIFETIME
var id: int = _next_id
_next_id = (_next_id + 1) % 100000
var entry: Dictionary = {
"id": id,
"owner_id": owner_id,
"pos": pos,
"lifetime_rem": lifetime,
"range": float(def.get("range", 350.0)),
"fire_interval": maxf(0.05, float(def.get("fire_interval", 0.8))),
"fire_cd": 0.0,
"damage": float(def.get("damage", 4.0)),
"bullet_speed": float(def.get("bullet_speed", 360.0)),
"bullet_radius": float(def.get("bullet_radius", 5.0)),
"bullet_lifetime": float(def.get("bullet_lifetime", 2.0)),
"damage_type": int(def.get("damage_type", 0)),
}
_minions.append(entry)
EventBus.emit(EventID.MINION_SPAWNED, {
"minion_id": id, "owner_id": owner_id, "current_count": _minions.size(),
})
return id
func _physics_process(delta: float) -> void:
var i: int = 0
while i < _minions.size():
var m: Dictionary = _minions[i]
m["lifetime_rem"] -= delta
if m["lifetime_rem"] <= 0.0:
_expire(m["id"])
continue # 不自增 i:当前槽已被 remove_at 后移的元素填补
# 炮台 AI:冷却到点且范围内有敌人则朝最近敌人开火
m["fire_cd"] -= delta
if m["fire_cd"] <= 0.0:
var pos: Vector2 = m["pos"]
var rng: float = m["range"]
if SpatialGrid.query_circle(pos, rng).size() > 0:
var tgt: Vector2 = EnemyManager.get_nearest_pos(pos, rng)
var dir: Vector2 = (tgt - pos)
dir = dir.normalized() if dir.length() > 0.001 else Vector2.RIGHT
BulletManager.spawn_bullet(
pos, dir * m["bullet_speed"], m["bullet_lifetime"],
m["bullet_radius"], m["damage"], 1.0, m["damage_type"],
m["owner_id"], 0, 0.0, {})
m["fire_cd"] = m["fire_interval"]
i += 1
func _expire(minion_id: int) -> void:
for i in _minions.size():
if _minions[i]["id"] == minion_id:
var owner: int = _minions[i]["owner_id"]
_minions.remove_at(i)
EventBus.emit(EventID.MINION_EXPIRED, {
"minion_id": minion_id, "owner_id": owner, "current_count": _minions.size(),
})
return
func recall_all(owner_id: int = -1) -> void:
# owner_id<0 → 全部召回(Run 结束);否则仅该 owner
var ids: Array = []
for m in _minions:
if owner_id < 0 or m["owner_id"] == owner_id:
ids.append(m["id"])
for mid in ids:
_expire(mid)
func get_count(owner_id: int = -1) -> int:
if owner_id < 0:
return _minions.size()
var c: int = 0
for m in _minions:
if m["owner_id"] == owner_id:
c += 1
return c
func reset() -> void:
_minions.clear()
_next_id = 0
+1
View File
@@ -0,0 +1 @@
uid://dkjoahbv8qtwl
+40
View File
@@ -0,0 +1,40 @@
## ObjectPool — 通用对象池(Autoload: ObjectPool
## 权威来源:implementation_plan.md §2.1 Layer-0
##
## 使用约定:
## 1. 被池化对象必须实现 reset() 方法 (签名:func reset() -> void
## 2. 取出:pool.acquire() → 返回对象(内部已调 reset())
## 3. 归还:pool.release(obj)
extends Node
## 创建一个指定工厂和容量的对象池
func create_pool(capacity: int, factory: Callable) -> PoolInstance:
var p := PoolInstance.new()
p._factory = factory
for _i in capacity:
p._stack.append(factory.call())
return p
# ── PoolInstance 内部类(业务系统持有实例) ──────────────────────────────
class PoolInstance:
var _factory: Callable
var _stack: Array = []
func acquire() -> Object:
var obj: Object
if _stack.is_empty():
obj = _factory.call()
else:
obj = _stack.pop_back()
if obj.has_method("reset"):
obj.reset()
return obj
func release(obj: Object) -> void:
_stack.append(obj)
func get_available_count() -> int:
return _stack.size()
func drain() -> void:
_stack.clear()
+1
View File
@@ -0,0 +1 @@
uid://b2ekg7pdggafm
+89
View File
@@ -0,0 +1,89 @@
## PlayerStats — 玩家属性统计(Autoload: PlayerStats
## 权威来源:development_plan.md S2、numerical_design.md §1.2
extends Node
signal stats_changed
signal leveled_up(new_level: int)
# ── HP ───────────────────────────────────────────────
var hp: float = 100.0
var hp_max: float = 100.0
# ── 资源 ──────────────────────────────────────────────
var gold: int = 0
var xp: int = 0
# ── 等级 ──────────────────────────────────────────────
var level: int = 1
var xp_to_next: int = 10 # 升级所需 XP
# ── 战斗属性 ───────────────────────────────────────────
var cpu_limit: int = 5 # 控制 MAX_OPS = cpu_limit * 40
var armor: float = 0.0
var resistance: float = 0.0 # 0.0~1.0
func _ready() -> void:
EventBus.subscribe(EventID.PLAYER_DAMAGED, _on_player_damaged)
## 升级曲线:roundf(10 × 1.4^(level-1))P6-N16
## Level 1→2: 10 XP; Level 5→6: ≈ 54 XP
static func xp_for_level(lv: int) -> int:
return roundi(10.0 * pow(1.4, float(lv - 1)))
func gain_xp(amount: int) -> void:
xp += amount
while xp >= xp_to_next:
xp -= xp_to_next
level += 1
xp_to_next = xp_for_level(level)
leveled_up.emit(level)
EventBus.emit(EventID.LEVEL_UP, {"level": level})
stats_changed.emit()
func gain_gold(amount: int) -> void:
gold += amount
stats_changed.emit()
func spend_gold(amount: int) -> bool:
if gold < amount:
return false
gold -= amount
stats_changed.emit()
return true
func take_damage(amount: float) -> void:
hp = max(0.0, hp - amount * SettingsManager.player_dmg_taken_mult()) # 难度减伤(初学者×0.7
stats_changed.emit()
if hp <= 0.0:
EventBus.emit(EventID.PLAYER_DIED, {})
func heal(amount: float) -> void:
hp = min(hp_max, hp + amount)
stats_changed.emit()
func get_hp_percent() -> float:
return hp / max(hp_max, 0.001)
func _on_player_damaged(payload: Dictionary) -> void:
take_damage(float(payload.get("damage", 0.0)))
func reset_for_run() -> void:
hp = hp_max
gold = 0
xp = 0
level = 1
xp_to_next = xp_for_level(1)
stats_changed.emit()
func get_save_data() -> Dictionary:
return {"hp": hp, "hp_max": hp_max, "gold": gold, "xp": xp, "level": level, "cpu_limit": cpu_limit}
func load_save_data(data: Dictionary) -> void:
hp = float(data.get("hp", 100.0))
hp_max = float(data.get("hp_max", 100.0))
gold = int(data.get("gold", 0))
xp = int(data.get("xp", 0))
level = int(data.get("level", 1))
cpu_limit = int(data.get("cpu_limit", 5))
xp_to_next = xp_for_level(level)
stats_changed.emit()
+1
View File
@@ -0,0 +1 @@
uid://dl4lp1m675v82
+118
View File
@@ -0,0 +1,118 @@
## ProfileManager — 局内 Run 存档 A/B 双槽(Autoload: ProfileManager
## 权威来源:architecture_design.md ADR-A2
## 下面实现了:
## - A/B 双槽交替写入防崩溃
## - schema_version 字段必须
## - 所有读取必须经过 _migrate_run()
## - NOTIFICATION_WM_CLOSE_REQUEST 尽力写最后一笔
extends Node
const SCHEMA_VERSION: int = 2 # v2:新增 wandCore/deck/bench)持久化
const PATH_A: String = "user://run_a.json"
const PATH_B: String = "user://run_b.json"
var _current_slot: String = PATH_A # 上次写入的槽
var _dirty: bool = false
## 法杖状态提供者(CombatManager 在 _ready 注册);须有 get_wand_save_data/apply_wand_save_data
var _wand_provider: Object = null
func set_wand_provider(p: Object) -> void:
_wand_provider = p
func _ready() -> void:
get_tree().root.connect("close_requested", _on_close_request)
func _on_close_request() -> void:
if _dirty:
save_run()
_dirty = false
## 写入当前 Run 状态(交替槽位)
func save_run() -> void:
var data: Dictionary = _collect_run_data()
data["schema_version"] = SCHEMA_VERSION
data["saved_at"] = Time.get_unix_time_from_system()
var json_str: String = JSON.stringify(data)
var target: String = _next_slot()
var file := FileAccess.open(target, FileAccess.WRITE)
if file:
file.store_string(json_str)
file.close()
_current_slot = target
_dirty = false
## 加载 Run,自动选择未损坏的最新槽
func load_run() -> Dictionary:
var data_a: Dictionary = _read_slot(PATH_A)
var data_b: Dictionary = _read_slot(PATH_B)
var best: Dictionary
if data_a.is_empty() and data_b.is_empty():
return {}
elif data_a.is_empty():
best = data_b
elif data_b.is_empty():
best = data_a
else:
var ta: int = int(data_a.get("saved_at", 0))
var tb: int = int(data_b.get("saved_at", 0))
best = data_a if ta > tb else data_b
return _migrate_run(best)
## 应用局内状态
func apply_run(data: Dictionary) -> void:
if data.is_empty():
return
PlayerStats.load_save_data(data.get("player_stats", {}))
WaveManager.current_wave = int(data.get("wave_num", 0))
var shop_seed: int = int(data.get("shop_seed", 0))
if shop_seed > 0:
ShopManager._shop_seed = shop_seed
# 恢复法杖(Core/deck/bench
if data.has("wand") and is_instance_valid(_wand_provider) and _wand_provider.has_method("apply_wand_save_data"):
_wand_provider.apply_wand_save_data(data.get("wand", {}))
## 清除 Run(死亡 / 新开局)
func clear_run() -> void:
for path in [PATH_A, PATH_B]:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
_dirty = false
func mark_dirty() -> void:
_dirty = true
func _next_slot() -> String:
return PATH_B if _current_slot == PATH_A else PATH_A
func _collect_run_data() -> Dictionary:
var d: Dictionary = {
"wave_num": WaveManager.current_wave,
"shop_seed": ShopManager.get_shop_seed(),
"player_stats": PlayerStats.get_save_data(),
}
if is_instance_valid(_wand_provider) and _wand_provider.has_method("get_wand_save_data"):
d["wand"] = _wand_provider.get_wand_save_data()
return d
func _read_slot(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var text: String = FileAccess.get_file_as_string(path)
if text.is_empty():
return {}
var parsed = JSON.parse_string(text)
if parsed is Dictionary:
return parsed
return {}
## 迁移旧存档
func _migrate_run(data: Dictionary) -> Dictionary:
var ver: int = int(data.get("schema_version", 0))
if ver < 1:
# v0→v1: 无破坏性字段变更
data["schema_version"] = 1
if ver < 2:
# v1→v2: 旧档无 wand 字段;apply_run 检测缺失即保留默认法杖,无需补字段
data["schema_version"] = 2
return data
+1
View File
@@ -0,0 +1 @@
uid://bix3rk2gyfmvu
+93
View File
@@ -0,0 +1,93 @@
## SceneManager — 场景路由 + 黑幕淡入淡出(Autoload: SceneManager
## 所有场景跳转必须经过此单例,保证转场动画一致。
extends Node
const FADE_DURATION: float = 0.35
## 下一次加载游戏场景时的启动模式
## "new"=新游戏 "continue"=读档继续 "endless"=无尽模式
var start_mode: String = "new"
var is_transitioning: bool = false
var _overlay: ColorRect = null
var _tween: Tween = null
# 场景路径注册表
const SCENES: Dictionary = {
"splash": "res://scenes/ui/splash.tscn",
"main_menu": "res://scenes/ui/main_menu.tscn",
"game": "res://scenes/main/combat_s2.tscn",
}
func _ready() -> void:
# 转场需在暂停态下也能运行(从暂停菜单返回主菜单等)
process_mode = Node.PROCESS_MODE_ALWAYS
# 全屏黑幕覆盖层(始终在最顶层)
var cl := CanvasLayer.new()
cl.layer = 127
add_child(cl)
_overlay = ColorRect.new()
_overlay.color = Color.BLACK
_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
cl.add_child(_overlay)
_overlay.modulate.a = 1.0 # 从黑屏开始,首屏淡入
func _fade_in(on_done: Callable = Callable()) -> void:
if _tween:
_tween.kill()
_tween = create_tween()
_tween.tween_property(_overlay, "modulate:a", 0.0, FADE_DURATION)
if not on_done.is_null():
_tween.tween_callback(on_done)
func _fade_out(on_done: Callable = Callable()) -> void:
if _tween:
_tween.kill()
_tween = create_tween()
_tween.tween_property(_overlay, "modulate:a", 1.0, FADE_DURATION)
if not on_done.is_null():
_tween.tween_callback(on_done)
## 通用跳转(不建议直接调用,优先用下方语义方法)
func goto(scene_id: String) -> void:
if is_transitioning:
return
is_transitioning = true
_fade_out(func():
get_tree().change_scene_to_file(SCENES[scene_id])
call_deferred("_after_scene_load")
)
func _after_scene_load() -> void:
_fade_in(func(): is_transitioning = false)
## ── 语义跳转方法 ────────────────────────────────────────────
func goto_splash() -> void:
goto("splash")
func goto_main_menu() -> void:
goto("main_menu")
## 启动新游戏(从头开始)
func start_new_game() -> void:
start_mode = "new"
goto("game")
## 继续上局存档
func continue_game() -> void:
start_mode = "continue"
goto("game")
## 初始淡入(场景 _ready 调用)。经 goto 进入时由 _after_scene_load 独占淡入,
## 此处跳过以免 kill 掉带"复位 is_transitioning"回调的转场 Tween(仅单独启动场景时淡入)。
func fade_in_first() -> void:
if is_transitioning:
return
_fade_in()
## 当前是否有可继续的存档
func has_save() -> bool:
return FileAccess.file_exists(ProfileManager.PATH_A) or \
FileAccess.file_exists(ProfileManager.PATH_B)
+1
View File
@@ -0,0 +1 @@
uid://broymfifhvw2n
+149
View File
@@ -0,0 +1,149 @@
## SettingsManager — 全局设置 + 难度(Autoload: SettingsManager
## 权威来源:development_plan.md S6 P1(难度三档持久化)、certification_checklist.md ST-03(清除本地数据)
## 持久化 user://save_data.json{ difficulty:int, locale:String, master_volume:float }
extends Node
const SAVE_PATH: String = "user://save_data.json"
enum Difficulty { BEGINNER = 0, STANDARD = 1, CHALLENGE = 2 }
var difficulty: int = Difficulty.STANDARD
var locale: String = "zh_CN"
var master_volume: float = 1.0
var ftue_done: bool = false # 首次启动难度引导是否已完成(FTUE)
func _ready() -> void:
_load()
_load_balance()
_apply_audio()
if Locale:
Locale.set_locale(locale)
## 是否需要首启难度引导(FTUE
func needs_ftue() -> bool:
return not ftue_done
## 完成 FTUE:设定难度并持久化标志
func complete_ftue(chosen_difficulty: int) -> void:
difficulty = clampi(chosen_difficulty, 0, 2)
ftue_done = true
_save()
## 难度乘子 + Boss 血量:纯数据驱动,唯一来源 data/balance.json(游戏设计器「平衡」面板维护)
## get() 的默认仅为防崩溃,非内容副本
var _MULTS: Dictionary = {} # key → [初学者, 标准, 挑战]
var _BOSS_HP: Dictionary = {} # 原型→基础血量
const BALANCE_JSON: String = "res://data/balance.json"
func _mult(key: String) -> float:
var arr: Array = _MULTS.get(key, [1.0, 1.0, 1.0])
return float(arr[clampi(difficulty, 0, arr.size() - 1)])
func enemy_hp_mult() -> float: return _mult("enemy_hp")
func player_dmg_taken_mult() -> float: return _mult("player_dmg")
func wave_count_mult() -> float: return _mult("wave_count")
func boss_hp_mult() -> float: return _mult("boss_hp")
## Boss 基础血量(WaveManager._spawn_boss 查询;balance.json 可调)
func get_boss_base_hp(boss_type: int) -> float:
return float(_BOSS_HP.get(str(boss_type), 500.0))
## 数据驱动:加载 balance.json(难度乘子 + Boss 血量)
func _load_balance() -> void:
if not FileAccess.file_exists(BALANCE_JSON):
push_error("SettingsManager: 缺失 res://data/balance.json(难度乘子/Boss血量)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(BALANCE_JSON))
if not (data is Dictionary):
push_error("SettingsManager: balance.json 格式错误")
return
if data.has("difficulty_mults") and data["difficulty_mults"] is Dictionary:
for k in data["difficulty_mults"]:
_MULTS[k] = data["difficulty_mults"][k]
if data.has("boss_hp") and data["boss_hp"] is Dictionary:
_BOSS_HP = data["boss_hp"]
func difficulty_name() -> String:
return ["DIFF_BEGINNER", "DIFF_STANDARD", "DIFF_CHALLENGE"][difficulty]
# ── 设置变更(即时保存)──────────────────────────────────────
func set_difficulty(d: int) -> void:
difficulty = clampi(d, 0, 2)
_save()
func cycle_difficulty() -> int:
set_difficulty((difficulty + 1) % 3)
return difficulty
func set_locale(loc: String) -> void:
locale = loc
if Locale:
Locale.set_locale(loc)
_save()
func set_master_volume(v: float) -> void:
master_volume = clampf(v, 0.0, 1.0)
_apply_audio()
_save()
func _apply_audio() -> void:
var bus: int = AudioServer.get_bus_index("Master")
if bus >= 0:
AudioServer.set_bus_volume_db(bus, linear_to_db(maxf(0.0001, master_volume)))
# ── ST-03:清除所有本地数据 ─────────────────────────────────
func clear_all_local_data() -> void:
var files: Array = [
"user://crash_log.txt", "user://crash_log_prev.txt",
"user://run_a.json", "user://run_b.json",
"user://endless_records.json", "user://save_data.json",
]
for path in files:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
_remove_dir_recursive("user://runs")
# 重置内存态
difficulty = Difficulty.STANDARD
master_volume = 1.0
ftue_done = false # 清数据后重新触发首启难度引导
# 用路径引用避免编译时 autoload 依赖(打破 SettingsManager→ProfileManager→WaveManager→SettingsManager 循环)
var pm: Node = get_node_or_null("/root/ProfileManager")
if pm and pm.has_method("clear_run"):
pm.clear_run()
_apply_audio()
func _remove_dir_recursive(path: String) -> void:
var d := DirAccess.open(path)
if d == null:
return
d.list_dir_begin()
var fn := d.get_next()
while fn != "":
if not d.current_is_dir():
DirAccess.remove_absolute(ProjectSettings.globalize_path(path + "/" + fn))
fn = d.get_next()
d.list_dir_end()
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
# ── 持久化 ──────────────────────────────────────────────────
func _save() -> void:
var data: Dictionary = {
"difficulty": difficulty, "locale": locale,
"master_volume": master_volume, "ftue_done": ftue_done,
}
var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(data))
f.close()
func _load() -> void:
if not FileAccess.file_exists(SAVE_PATH):
return
var parsed = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH))
if not (parsed is Dictionary):
return
difficulty = clampi(int(parsed.get("difficulty", 1)), 0, 2)
locale = String(parsed.get("locale", "zh_CN"))
master_volume = clampf(float(parsed.get("master_volume", 1.0)), 0.0, 1.0)
ftue_done = bool(parsed.get("ftue_done", false))
@@ -0,0 +1 @@
uid://dvs14n88hmy06
+86
View File
@@ -0,0 +1,86 @@
## ShopManager — 商店管理(Autoload: ShopManager
## S2 范围:3 选 1 法术、金币扣减、刷新费用公式(P6-N23)
## 权威来源:development_plan.md S2、numerical_design.md §2.3
extends Node
signal shop_refreshed
signal shop_closed
const SLOT_COUNT: int = 3
const REROLL_BASE_COST: int = 20 # 第 1 次刷新费用
const REROLL_STEP: int = 10 # 每次递增价格
var current_slots: Array = [] # Array[SpellNode] null 表示已售出)
var reroll_count: int = 0 # 本波已刷新次数(WAVE_COMPLETE 后重置)
var _shop_seed: int = 0 # 随机种(ADR-A2 P-S2-07
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
func _ready() -> void:
EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete)
func _on_wave_complete(_payload: Dictionary) -> void:
reroll_count = 0 # P6-N23:波次结算后重置刷新次数
func open_shop(seed_override: int = -1) -> void:
if seed_override >= 0:
_shop_seed = seed_override
else:
_shop_seed = randi()
_rng.seed = _shop_seed
_refresh_slots()
EventBus.emit(EventID.SHOP_OPENED, {"seed": _shop_seed})
func _refresh_slots() -> void:
# 纯数据驱动池:SpellRegistrydata/spells.json)中可购买(shop_cost>0)的法术
var pool: Array = SpellRegistry.get_all_ids().filter(func(id):
var s: SpellNode = SpellRegistry.get_spell(id)
return s != null and int(s.meta.get("shop_cost", 0)) > 0)
current_slots.clear()
for _i in SLOT_COUNT:
if pool.is_empty():
current_slots.append(null)
continue
var idx: int = _rng.randi_range(0, pool.size() - 1)
current_slots.append(SpellRegistry.get_spell(pool[idx]))
pool.remove_at(idx)
shop_refreshed.emit()
## 购买指定槽位的法术,返回购买的 SpellNode、失败返回 null
func buy_spell(slot_idx: int) -> SpellNode:
if slot_idx < 0 or slot_idx >= current_slots.size():
return null
var spell: SpellNode = current_slots[slot_idx]
if spell == null:
return null
var cost: int = int(spell.meta.get("shop_cost", 20))
if not PlayerStats.spend_gold(cost):
return null
current_slots[slot_idx] = null
shop_refreshed.emit()
return spell
## 刷新商店,返回 true 表示成功
func reroll() -> bool:
var cost: int = get_reroll_cost()
if not PlayerStats.spend_gold(cost):
return false
reroll_count += 1
_rng.seed = _shop_seed + reroll_count * 31337
_refresh_slots()
return true
## 刷新费用公式:base + step * reroll_countP6-N23
func get_reroll_cost() -> int:
return REROLL_BASE_COST + reroll_count * REROLL_STEP
func close_shop() -> void:
current_slots.clear()
shop_closed.emit()
func get_shop_seed() -> int:
return _shop_seed
func reset() -> void:
current_slots.clear()
reroll_count = 0
_shop_seed = 0
+1
View File
@@ -0,0 +1 @@
uid://cu5ace51jtmf4
+75
View File
@@ -0,0 +1,75 @@
## SpatialGrid — 2D 空间哈希网格 AutoloadGDScript 外壳)
## 热路径由 C# SpatialGridCs 子节点执行(就绪后接管)
## 权威来源:architecture_design.md §4.3
extends Node
const CELL_SIZE: int = 64 # 格子大小(像素)
const GRID_COLS: int = 128 # 128×128 格 × 64px = 8192×8192 覆盖范围
const GRID_ROWS: int = 128
const GRID_OFFSET: int = 4096 # 将世界中心映射到网格中心(支持负坐标)
var _cs_node: Node = null # SpatialGridCs C# 子节点(就绪后挂载)
## GDScript 回退网格
var _grid: Array = [] # Array[Array[int]]
var _entity_cell: Dictionary = {} # { entity_id → cell_idx }
func _ready() -> void:
_grid.resize(GRID_COLS * GRID_ROWS)
for i in _grid.size():
_grid[i] = []
## 插入或更新实体位置(由 EnemyManager 每物理帧调用)
func insert(entity_id: int, pos: Vector2) -> void:
if _cs_node and _cs_node.has_method("Insert"):
_cs_node.call("Insert", entity_id, pos)
return
var cx: int = clamp(int((pos.x + GRID_OFFSET) / CELL_SIZE), 0, GRID_COLS - 1)
var cy: int = clamp(int((pos.y + GRID_OFFSET) / CELL_SIZE), 0, GRID_ROWS - 1)
var cell: int = cx + cy * GRID_COLS
var old_cell: int = _entity_cell.get(entity_id, -1)
if old_cell == cell:
return
if old_cell >= 0:
_grid[old_cell].erase(entity_id)
_grid[cell].append(entity_id)
_entity_cell[entity_id] = cell
## 查询圆形范围内的所有实体 ID
func query_circle(center: Vector2, radius: float) -> PackedInt32Array:
if _cs_node and _cs_node.has_method("QueryCircle"):
return _cs_node.call("QueryCircle", center, radius)
var result := PackedInt32Array()
var min_cx: int = clamp(int((center.x - radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_COLS - 1)
var max_cx: int = clamp(int((center.x + radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_COLS - 1)
var min_cy: int = clamp(int((center.y - radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_ROWS - 1)
var max_cy: int = clamp(int((center.y + radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_ROWS - 1)
for cy in range(min_cy, max_cy + 1):
for cx in range(min_cx, max_cx + 1):
for eid in _grid[cx + cy * GRID_COLS]:
result.append(eid)
return result
## 每帧由 EnemyManager._physics_process 驱动(dirty-list 方案)
func rebuild() -> void:
if _cs_node and _cs_node.has_method("Rebuild"):
_cs_node.call("Rebuild")
## 移除实体
func remove_entity(entity_id: int) -> void:
if _cs_node and _cs_node.has_method("Remove"):
_cs_node.call("Remove", entity_id)
return
var old_cell: int = _entity_cell.get(entity_id, -1)
if old_cell >= 0:
_grid[old_cell].erase(entity_id)
_entity_cell.erase(entity_id)
## 清空(关卡重置)
func clear() -> void:
if _cs_node and _cs_node.has_method("Clear"):
_cs_node.call("Clear")
return
for cell_list in _grid:
(cell_list as Array).clear()
_entity_cell.clear()
+1
View File
@@ -0,0 +1 @@
uid://x822ya5c7tlk
+54
View File
@@ -0,0 +1,54 @@
## SpellContextPool — SpellContext 对象池(Autoload: SpellContextPool
## 权威来源:implementation_plan.md §2.1、S1 验收 P-S1-04
extends Node
const POOL_SIZE: int = 32 # P-S1-04: pool size 不超过 32
var _pool: Array = [] # Array[SpellContext]
var _in_use: PackedByteArray = PackedByteArray()
var _slots: Array = [] # Array[SpellContext] 固定槽位
func _ready() -> void:
_slots.resize(POOL_SIZE)
_in_use.resize(POOL_SIZE)
for i in POOL_SIZE:
_slots[i] = SpellContext.new()
_in_use[i] = 0
_pool.append(i) # 空闲 ID 表
## 取出一个 SpellContext。若 has_persistent_memory=true 则保留 registers
func acquire(caster_id: int, has_persistent_memory: bool = false) -> SpellContext:
if _pool.is_empty():
push_warning("SpellContextPool: 池已耗尽,动态创建 SpellContext(建议上调 POOL_SIZE=%d" % POOL_SIZE)
var ctx_dyn := SpellContext.new()
ctx_dyn.caster_id = caster_id
if not has_persistent_memory:
ctx_dyn.clear_registers()
return ctx_dyn
var slot_id: int = _pool[_pool.size() - 1]
_pool.resize(_pool.size() - 1)
var ctx: SpellContext = _slots[slot_id]
_in_use[slot_id] = int(1)
ctx.reset()
ctx.caster_id = caster_id
if not has_persistent_memory:
ctx.clear_registers()
return ctx
func release(ctx: SpellContext) -> void:
for i in _slots.size():
if _slots[i] == ctx:
if _in_use[i] != 0:
_in_use[i] = 0
_pool.append(i)
return
# 动态分配的(池已耗尽时)直接丢弃
func get_available_count() -> int:
return _pool.size()
func reset() -> void:
_pool.clear()
for i in _slots.size():
_in_use[i] = 0
_pool.append(i)
@@ -0,0 +1 @@
uid://dgqs614fymhqe
+46
View File
@@ -0,0 +1,46 @@
## SpellRegistry — 法术注册表(Autoload: SpellRegistry
## 纯数据驱动:唯一来源 res://data/spells.json(游戏设计器「法术」面板维护)
## 无硬编码回退——法术定义只存在于 JSON。
extends Node
const JSON_DATA_PATH: String = "res://data/spells.json"
var _registry: Dictionary = {} # { spell_id: String → SpellNode }
func _ready() -> void:
if not FileAccess.file_exists(JSON_DATA_PATH):
push_error("SpellRegistry: 缺失 res://data/spells.json(无任何法术定义)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(JSON_DATA_PATH))
if not (data is Dictionary):
push_error("SpellRegistry: spells.json 格式错误(应为对象)")
return
for sid in data:
_registry[String(sid)] = _spell_from_dict(String(sid), data[sid])
func _spell_from_dict(sid: String, d: Dictionary) -> SpellNode:
var n := SpellNode.new()
n.id = sid
n.type = int(d.get("type", 0))
n.display_name = String(d.get("display_name", sid))
n.description = String(d.get("description", ""))
n.element_tags = d.get("element_tags", [])
n.meta = d.get("meta", {})
return n
## 运行时动态注册(如共鸣产出、未来扩展),非数据来源
func register_spell(spell: SpellNode) -> void:
if spell and spell.id != "":
_registry[spell.id] = spell
func get_spell(id: String) -> SpellNode:
return _registry.get(id, null)
func get_all_ids() -> Array:
return _registry.keys()
func has_spell(id: String) -> bool:
return _registry.has(id)
func get_registry_size() -> int:
return _registry.size()
+1
View File
@@ -0,0 +1 @@
uid://dbdjccwuktibn
+14
View File
@@ -0,0 +1,14 @@
## StatusID — 状态效果整数 IDAutoload: StatusID
## 规则:禁止使用 StatusType.XXX 枚举(跨切片约束 P6-N30),必须用本常量。
## 新增状态从 9 起递增,不得复用或跳号。
extends Node
const BURN: int = 1
const FREEZE: int = 2
const POISON: int = 3
const WET: int = 4
const OILY: int = 5
const STUN: int = 6
const COMBO_MARK: int = 7 # 连击叠加(consecutive_hits_stacking.md
const VULNERABILITY: int = 8 # 易伤标记
# 新增从 9 起
+1
View File
@@ -0,0 +1 @@
uid://dke3xnu50nshg
+122
View File
@@ -0,0 +1,122 @@
## StatusManager — 状态效果管理(Autoload: StatusManager
## 权威来源:combat_mechanics_depth.md §3.2.5、implementation_plan.md §2.4
extends Node
const STATUS_BATCH_LIMIT: int = 500
var _active_statuses: Array = [] # Array[StatusInstance]
var _physics_frame: int = 0
func _physics_process(delta: float) -> void:
_physics_frame += 1
var use_batch: bool = _active_statuses.size() > STATUS_BATCH_LIMIT
var batch_parity: int = _physics_frame % 2
var i: int = _active_statuses.size() - 1
while i >= 0:
var inst: StatusInstance = _active_statuses[i]
if use_batch and (inst.entity_id % 2) != batch_parity:
i -= 1
continue
inst.remaining_duration -= delta
inst.tick_accumulator += delta
if inst.type_def and not inst.type_def.is_combo_tracker and inst.type_def.dot_damage_per_tick > 0.0:
var interval: float = max(0.001, inst.type_def.tick_interval)
while inst.tick_accumulator >= interval:
inst.tick_accumulator -= interval
_apply_dot_tick(inst)
if inst.remaining_duration <= 0.0:
_active_statuses[i] = _active_statuses[_active_statuses.size() - 1]
_active_statuses.pop_back()
i -= 1
## 施加状态;duration < 0 时使用 StatusTypeDef 默认 duration
func apply(entity_id: int, status_type_id: int, stacks: int = 1, duration: float = -1.0, owner_id: int = -1) -> void:
var typedef: StatusTypeDef = StatusRegistry.get_type(status_type_id)
if typedef == null:
push_warning("StatusManager.apply: 未知 status_id=%d" % status_type_id)
return
var dur: float = typedef.duration if duration < 0.0 else duration
var existing_idx: int = _find(entity_id, status_type_id)
if existing_idx >= 0:
var inst: StatusInstance = _active_statuses[existing_idx]
match typedef.stack_mode:
StatusTypeDef.StackMode.REFRESH:
inst.remaining_duration = dur
inst.stacks = max(inst.stacks, stacks)
StatusTypeDef.StackMode.INTENSITY:
var cap: int = typedef.max_stacks if typedef.max_stacks > 0 else 9999
inst.stacks = min(inst.stacks + stacks, cap)
inst.remaining_duration = dur
StatusTypeDef.StackMode.INDEPENDENT:
pass # 独立实例:追加新条目
if typedef.stack_mode != StatusTypeDef.StackMode.INDEPENDENT:
EventBus.emit(EventID.STATUS_APPLIED, {
"target_id": entity_id, "status_type": status_type_id, "stacks": inst.stacks
})
return
var inst_new := StatusInstance.new()
inst_new.setup(entity_id, typedef, stacks, dur, owner_id)
_active_statuses.append(inst_new)
EventBus.emit(EventID.STATUS_APPLIED, {
"target_id": entity_id, "status_type": status_type_id, "stacks": inst_new.stacks
})
func get_stacks(entity_id: int, status_type_id: int) -> int:
var idx: int = _find(entity_id, status_type_id)
if idx < 0:
return 0
return _active_statuses[idx].stacks
func has_status(entity_id: int, status_type_id: int) -> bool:
return _find(entity_id, status_type_id) >= 0
func remove_all_for_entity(entity_id: int) -> void:
var i: int = _active_statuses.size() - 1
while i >= 0:
if _active_statuses[i].entity_id == entity_id:
_active_statuses[i] = _active_statuses[_active_statuses.size() - 1]
_active_statuses.pop_back()
i -= 1
func remove_source(owner_id: int) -> void:
var i: int = _active_statuses.size() - 1
while i >= 0:
if _active_statuses[i].owner_id == owner_id:
_active_statuses[i] = _active_statuses[_active_statuses.size() - 1]
_active_statuses.pop_back()
i -= 1
func get_active_count() -> int:
return _active_statuses.size()
func reset() -> void:
_active_statuses.clear()
_physics_frame = 0
func _find(entity_id: int, status_type_id: int) -> int:
for i in _active_statuses.size():
var inst: StatusInstance = _active_statuses[i]
if inst.entity_id == entity_id and inst.status_type_id == status_type_id:
return i
return -1
func _apply_dot_tick(inst: StatusInstance) -> void:
if not EnemyManager.has_entity(inst.entity_id):
return
var dmg: float = inst.type_def.dot_damage_per_tick * float(inst.stacks)
if dmg <= 0.0:
return
EnemyManager.apply_damage(inst.entity_id, dmg, inst.owner_id, false)
func _swap_and_pop(idx: int) -> void:
var last: int = _active_statuses.size() - 1
if idx != last:
_active_statuses[idx] = _active_statuses[last]
_active_statuses.pop_back()
+1
View File
@@ -0,0 +1 @@
uid://vm1hbvxu51s8
+40
View File
@@ -0,0 +1,40 @@
## StatusRegistry — 状态效果注册表(Autoload: StatusRegistry
## 纯数据驱动:唯一来源 res://data/status_effects.json(游戏设计器「状态」面板维护)
extends Node
const STATUS_JSON: String = "res://data/status_effects.json"
var _registry: Dictionary = {} # { status_id: int → StatusTypeDef }
func _ready() -> void:
if not FileAccess.file_exists(STATUS_JSON):
push_error("StatusRegistry: 缺失 res://data/status_effects.json(无状态效果定义)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(STATUS_JSON))
if not (data is Dictionary):
push_error("StatusRegistry: status_effects.json 格式错误")
return
for k in data:
var sid: int = int(k)
var d: Dictionary = data[k]
var t := StatusTypeDef.new()
t.id = sid
t.display_name = String(d.get("name", "Status %d" % sid))
t.duration = float(d.get("duration", 3.0))
t.tick_interval = float(d.get("tick_interval", 1.0))
t.stack_mode = int(d.get("stack_mode", 0))
t.max_stacks = int(d.get("max_stacks", 1))
t.dot_damage_per_tick = float(d.get("dot_damage", 0.0))
t.dot_damage_type = int(d.get("dot_damage_type", 0))
t.vfx_id = String(d.get("vfx_id", ""))
t.is_combo_tracker = bool(d.get("is_combo_tracker", false))
_registry[sid] = t
func get_type(status_id: int) -> StatusTypeDef:
return _registry.get(status_id, null)
func has_type(status_id: int) -> bool:
return _registry.has(status_id)
func get_registry_size() -> int:
return _registry.size()
+1
View File
@@ -0,0 +1 @@
uid://ckxpvasdeb8gi
+34
View File
@@ -0,0 +1,34 @@
## TimeManager — 时间缩放与 GameTick 计数器(Autoload: TimeManager
## 权威来源:implementation_plan.md §2.1 Layer-0
##
## - time_scale: 全局时间缩放(慢动作 / 冻结时间 Core 效果)
## - game_tick: 物理帧计数器(每 _physics_process 递增 1)— 不受 time_scale 影响
extends Node
var time_scale: float = 1.0
var game_tick: int = 0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS # 暂停时仍持续计数
func _physics_process(_delta: float) -> void:
game_tick += 1
Engine.time_scale = time_scale
## 返回当前帧缩放后 delta(供战斗系统使用)
func scaled_delta(raw_delta: float) -> float:
return raw_delta * time_scale
## 设置时间缩放(clamp 防止异常値)
func set_time_scale(scale: float) -> void:
time_scale = clamp(scale, 0.0, 10.0)
## 恢复正常速度
func reset_time_scale() -> void:
time_scale = 1.0
Engine.time_scale = 1.0
## 忽略帧计数器(关卡重置)
func reset() -> void:
game_tick = 0
reset_time_scale()
+1
View File
@@ -0,0 +1 @@
uid://bx3w1yacjl3qh
+125
View File
@@ -0,0 +1,125 @@
## VFXManager — 视觉特效管理(Autoload: VFXManager
## S4:程序化色块占位特效 + 对象池(P6-N72 one_shot=true
extends Node2D
const MAX_ACTIVE_VFX: int = 200
var _vfx_pool: Dictionary = {} # { effect_id: Array[Node2D] }
var _active_count: int = 0
func _ready() -> void:
EventBus.subscribe(EventID.STATUS_APPLIED, _on_status_applied)
func play(effect_id: String, world_pos: Vector2, override_scale: float = 1.0) -> void:
if _active_count >= MAX_ACTIVE_VFX:
return
var node: Node2D = _pool_pop(effect_id)
node.global_position = world_pos
node.scale = Vector2.ONE * override_scale
node.visible = true
_active_count += 1
# 触发一次性粒子爆发
var p: GPUParticles2D = node.get_node_or_null("Particles")
if p:
p.restart()
p.emitting = true
var timer: Timer = node.get_node_or_null("AutoReturn")
if timer:
timer.start()
func _pool_pop(effect_id: String) -> Node2D:
if _vfx_pool.has(effect_id) and not _vfx_pool[effect_id].is_empty():
return _vfx_pool[effect_id].pop_back()
return _instantiate_vfx(effect_id)
func _pool_return(effect_id: String, node: Node2D) -> void:
node.visible = false
_active_count = max(0, _active_count - 1)
if not _vfx_pool.has(effect_id):
_vfx_pool[effect_id] = []
_vfx_pool[effect_id].append(node)
func _instantiate_vfx(effect_id: String) -> Node2D:
# 程序化美术占位:GPUParticles2D 爆裂粒子(放 scenes/vfx/<id>.tscn 可整体替换)
var node := Node2D.new()
node.name = "VFX_" + effect_id
var col: Color = _color_for(effect_id)
var p := GPUParticles2D.new()
p.name = "Particles"
p.amount = _amount_for(effect_id)
p.lifetime = 0.45
p.one_shot = true # P6-N72
p.explosiveness = 0.9 # 一次性爆发
p.local_coords = false
var mat := ParticleProcessMaterial.new()
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
mat.emission_sphere_radius = 4.0
mat.direction = Vector3(0, 0, 0)
mat.spread = 180.0
mat.gravity = Vector3(0, 0, 0)
mat.initial_velocity_min = _speed_for(effect_id) * 0.5
mat.initial_velocity_max = _speed_for(effect_id)
mat.scale_min = 1.5
mat.scale_max = 4.0
# 颜色随生命淡出
var grad := Gradient.new()
grad.set_color(0, Color(col.r, col.g, col.b, col.a))
grad.set_color(1, Color(col.r, col.g, col.b, 0.0))
var gtex := GradientTexture1D.new()
gtex.gradient = grad
mat.color_ramp = gtex
p.process_material = mat
node.add_child(p)
# 自动回收(粒子播完)
var timer := Timer.new()
timer.name = "AutoReturn"
timer.one_shot = true
timer.wait_time = 0.55
timer.timeout.connect(_pool_return.bind(effect_id, node))
node.add_child(timer)
add_child(node)
node.visible = false
return node
## 不同特效的粒子数 / 速度
func _amount_for(effect_id: String) -> int:
match effect_id:
"death_burst": return 24
"hit_spark": return 10
"cast_flash": return 8
_: return 12
func _speed_for(effect_id: String) -> float:
match effect_id:
"death_burst": return 220.0
"hit_spark": return 160.0
_: return 110.0
func _color_for(effect_id: String) -> Color:
match effect_id:
"hit_spark": return Color(1.0, 0.9, 0.3, 0.9)
"status_burn": return Color(1.0, 0.4, 0.1, 0.8)
"status_poison": return Color(0.3, 0.9, 0.2, 0.8)
"cast_flash": return Color(0.6, 0.8, 1.0, 0.7)
"death_burst": return Color(1.0, 0.2, 0.2, 0.8)
_: return Color(1, 1, 1, 0.6)
func _on_status_applied(payload: Dictionary) -> void:
var status_type: int = int(payload.get("status_type", 0))
var typedef: StatusTypeDef = StatusRegistry.get_type(status_type)
if typedef and typedef.vfx_id != "":
var eid: int = int(payload.get("target_id", -1))
var pos: Vector2 = EnemyManager.get_pos_by_id(eid)
if pos != Vector2(-9999.0, -9999.0):
play(typedef.vfx_id, pos)
func get_active_count() -> int:
return _active_count
func reset() -> void:
for key in _vfx_pool:
for n in _vfx_pool[key]:
if is_instance_valid(n):
n.queue_free()
_vfx_pool.clear()
_active_count = 0
+1
View File
@@ -0,0 +1 @@
uid://cfobw7w7hcchm
+46
View File
@@ -0,0 +1,46 @@
## WandPreset — Core(法杖)工厂(Autoload: WandPreset
## 纯数据驱动:Core 定义唯一来源 res://data/cores.json(游戏设计器「Core」面板维护)
## 法术已迁至 SpellRegistrydata/spells.json),此处不再定义法术。
extends Node
const CORES_JSON: String = "res://data/cores.json"
var _json_cores: Dictionary = {}
func _ready() -> void:
if not FileAccess.file_exists(CORES_JSON):
push_error("WandPreset: 缺失 res://data/cores.json(无 Core 定义)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(CORES_JSON))
if data is Dictionary:
_json_cores = data
else:
push_error("WandPreset: cores.json 格式错误")
## 按 ID 创建 Core(纯 cores.json);未知 ID 报错并返回错误安全默认(5 槽 LINEAR)
func make_core_by_id(cid: String) -> CoreDefinition:
if _json_cores.has(cid):
return _core_from_dict(cid, _json_cores[cid])
push_error("WandPreset: Core '%s' 不在 cores.json" % cid)
return _core_from_dict(cid, {})
func _core_from_dict(cid: String, d: Dictionary) -> CoreDefinition:
var c := CoreDefinition.new()
c.id = cid
c.display_name = String(d.get("display_name", cid))
c.slot_count = int(d.get("slot_count", 5))
c.topology = int(d.get("topology", 0))
c.cpu_limit = int(d.get("cpu_limit", 5))
c.cast_interval = float(d.get("cast_interval", 0.5))
c.feature_tags = int(d.get("feature_tags", 0))
c.grid_rows = int(d.get("grid_rows", 1))
c.grid_cols = int(d.get("grid_cols", 5))
c.edges = d.get("edges", [])
return c
## 默认装备(combat_test 等用):wand_basic + 第一个 ACTION 法术
func make_default_loadout() -> Dictionary:
var core: CoreDefinition = make_core_by_id("wand_basic")
var spell: SpellNode = SpellRegistry.get_spell("action_spark_bolt")
var spells: Array = [spell] if spell else []
var compiled = SpellEvaluator.compile_wand(core, spells)
return {"core": core, "spells": spells, "compiled": compiled}
+1
View File
@@ -0,0 +1 @@
uid://bfgjbhdg6b17l
+174
View File
@@ -0,0 +1,174 @@
## WaveManager — 波次管理(Autoload: WaveManager
## S6 内容:Wave 1~20 完整配置(敌人原型/精英寻路/Mini Boss W8/Final Boss W20
## 设计参考:game_design.md §3.3W1-5 数值 / W6-10 机制 / W11-20 弹幕)、§4.5 Boss
extends Node
enum WaveState { IDLE = 0, BATTLE = 1, COMPLETE = 2 }
var current_wave: int = 0
var wave_state: int = WaveState.IDLE
var enemies_alive: int = 0
var _wave_start_time: float = 0.0
const ARENA_RADIUS: float = 700.0
const MAX_WAVE: int = 20
## Boss 追踪
var _boss_id: int = -1
var _boss_max_hp: float = 0.0
var _boss_phase: int = 0
var _boss_wave: bool = false
## 波次配置表:纯数据驱动,唯一来源 data/waves.json(游戏设计器「波次」面板维护)
## 字段:count, hp, type, elite, gold, xp, boss(-1=无 / 4=MiniBoss / 5=Boss)
var WAVE_CONFIG: Array = []
const WAVES_JSON: String = "res://data/waves.json"
func _ready() -> void:
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
_load_json_waves()
## 数据驱动:从 data/waves.json 加载波次表
func _load_json_waves() -> void:
if not FileAccess.file_exists(WAVES_JSON):
push_error("WaveManager: 缺失 res://data/waves.json(无波次配置)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(WAVES_JSON))
if data is Array and not data.is_empty():
WAVE_CONFIG = data
else:
push_error("WaveManager: waves.json 格式错误(应为非空数组)")
func _physics_process(_delta: float) -> void:
if _boss_wave and _boss_id >= 0:
_update_boss_phase()
## 开始指定波次(由 CombatManager 调用)
func start_wave(wave_num: int) -> void:
current_wave = wave_num
wave_state = WaveState.BATTLE
_wave_start_time = Time.get_ticks_msec() / 1000.0
_boss_id = -1
_boss_phase = 0
_boss_wave = false
var cfg: Dictionary = _get_wave_config(wave_num)
enemies_alive = int(cfg["count"])
_spawn_wave_enemies(cfg)
if int(cfg.get("boss", -1)) >= 0:
_spawn_boss(int(cfg["boss"]), wave_num)
EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "SHOP", "to": "BATTLE", "wave": wave_num})
print("[WaveManager] Wave %d 开始:%d 杂兵 + %d 精英%s" % [
wave_num, int(cfg["count"]), int(cfg.get("elite", 0)),
("Boss!)" if int(cfg.get("boss", -1)) >= 0 else "")])
func _spawn_wave_enemies(cfg: Dictionary) -> void:
# 难度乘子:怪物数量(挑战×1.2)、怪物血量(初学者×0.7)
var count: int = int(round(int(cfg["count"]) * SettingsManager.wave_count_mult()))
var hp: float = float(cfg["hp"]) * SettingsManager.enemy_hp_mult()
var etype: int = int(cfg.get("type", 0))
# enemies_alive 已按 cfg.count 初始化,按难度修正差额
enemies_alive += count - int(cfg["count"])
for _i in count:
EnemyManager.spawn_enemy(_ring_pos(), hp, etype)
# W15+ 精英:带 NavigationAgent2D 寻路(ADR-A4),更高血量
var elites: int = int(cfg.get("elite", 0))
for _e in elites:
EnemyManager.spawn_enemy(_ring_pos(), hp * 2.5, EnemyManager.Type.ELITE, true)
enemies_alive += elites # 精英计入存活
func _spawn_boss(boss_type: int, wave_num: int) -> void:
# Boss 基础血量由 SettingsManagerbalance.json 可调)提供 × 难度乘子
var hp: float = SettingsManager.get_boss_base_hp(boss_type) * SettingsManager.boss_hp_mult()
_boss_max_hp = hp
# Boss 在玩家上方稍远处入场
_boss_id = EnemyManager.spawn_enemy(Vector2(0.0, -ARENA_RADIUS * 0.8), hp, boss_type)
_boss_wave = true
_boss_phase = 1
enemies_alive += 1
EventBus.emit(EventID.BOSS_SPAWNED, {
"boss_id": _boss_id, "boss_type": boss_type, "wave": wave_num, "max_hp": hp})
print("[WaveManager] Boss 入场!type=%d hp=%.0f (wave %d)" % [boss_type, hp, wave_num])
## Boss 阶段:HP 跨越阈值时进阶并召唤援军(简化 AI,game_design §4.5
func _update_boss_phase() -> void:
if not EnemyManager.has_entity(_boss_id):
return
var frac: float = EnemyManager.get_hp_percent(_boss_id)
var target_phase: int = 1
if frac <= 0.33:
target_phase = 3
elif frac <= 0.66:
target_phase = 2
if target_phase > _boss_phase:
_boss_phase = target_phase
_on_boss_phase_enter(target_phase)
func _on_boss_phase_enter(phase: int) -> void:
# 进阶召唤一批援军施压(Phase 2/3)
var add_count: int = 4 + phase * 2
var boss_pos: Vector2 = EnemyManager.get_pos_by_id(_boss_id)
for _i in add_count:
var off := Vector2(randf_range(-120, 120), randf_range(-120, 120))
var aid: int = EnemyManager.spawn_enemy(boss_pos + off, 40.0, EnemyManager.Type.FAST)
if aid >= 0:
enemies_alive += 1
EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "BATTLE", "to": "BOSS_PHASE_%d" % phase})
print("[WaveManager] Boss 进入 Phase %d,召唤 %d 援军" % [phase, add_count])
func _ring_pos() -> Vector2:
var angle: float = randf() * TAU
var radius: float = randf_range(250.0, ARENA_RADIUS)
return Vector2(cos(angle) * radius, sin(angle) * radius)
func _on_enemy_killed(payload: Dictionary) -> void:
if wave_state != WaveState.BATTLE:
return
var killed_id: int = int(payload.get("entity_id", -1))
# Boss 波:Boss 死亡即通关本波(援军为附赠)
if _boss_wave and killed_id == _boss_id:
_complete_wave()
return
enemies_alive -= 1
if enemies_alive <= 0 and not _boss_wave:
_complete_wave()
func _complete_wave() -> void:
if wave_state != WaveState.BATTLE:
return
wave_state = WaveState.COMPLETE
_boss_wave = false
_boss_id = -1
var cfg: Dictionary = _get_wave_config(current_wave)
PlayerStats.gain_gold(int(cfg["gold"]))
PlayerStats.gain_xp(int(cfg["xp"]))
var elapsed: float = Time.get_ticks_msec() / 1000.0 - _wave_start_time
EventBus.emit(EventID.WAVE_COMPLETE, {"wave": current_wave, "elapsed_sec": elapsed})
print("[WaveManager] Wave %d 完成!奖励: %dG, %d XP" % [current_wave, int(cfg["gold"]), int(cfg["xp"])])
func _get_wave_config(wave_num: int) -> Dictionary:
var idx: int = clamp(wave_num - 1, 0, WAVE_CONFIG.size() - 1)
var base: Dictionary = WAVE_CONFIG[idx].duplicate()
# 超过 W20Endless):数值膨胀
if wave_num > MAX_WAVE:
var extra: int = wave_num - MAX_WAVE
base["count"] = int(base["count"]) + extra * 6
base["hp"] = float(base["hp"]) * pow(1.12, extra)
base["gold"] = int(base["gold"]) + extra * 8
base["xp"] = int(base["xp"]) + extra * 12
base["boss"] = -1 # Endless 不重复终 Boss
return base
func get_wave_count(wave_num: int) -> int:
return int(_get_wave_config(wave_num)["count"])
func is_boss_wave(wave_num: int) -> bool:
return int(_get_wave_config(wave_num).get("boss", -1)) >= 0
func reset() -> void:
current_wave = 0
wave_state = WaveState.IDLE
enemies_alive = 0
_boss_id = -1
_boss_phase = 0
_boss_wave = false
+1
View File
@@ -0,0 +1 @@
uid://dj1o00ehrwfwj
+76
View File
@@ -0,0 +1,76 @@
## ZoneManager — 地面效果区域管理(Autoload: ZoneManager
## 权威来源:architecture_design.md §8 ADR-R5-N1P6-N71 tick 用 while + -=
## SoA PackedFloat32Array stride=8;基于 SpatialGrid 空间索引(禁用 Area2D
## [0]=cx [1]=cy [2]=radius [3]=status_id [4]=duration [5]=tick_interval [6]=tick_accum [7]=owner_id
##
## 注:本 GDScript 外壳承载 tick 热循环为 S5 功能验收路径(P-S5-04);
## C# 子节点 ZoneManagerCs 驱动的压力路径(P-S5-ZM-0164 区×1000 敌 <1ms)留作后续优化,
## 与项目既有 C# 回退策略(R-08)一致。
extends Node
const ZONE_STRIDE: int = 8
const MAX_ZONES: int = 64
var _zone_data: PackedFloat32Array = PackedFloat32Array()
var _active_zones: int = 0
func _ready() -> void:
_zone_data.resize(MAX_ZONES * ZONE_STRIDE)
## 生成一个地面效果区域;满员时顶掉最旧(index 0 前移)
func spawn_zone(cx: float, cy: float, radius: float, status_id: int,
duration: float, tick_interval: float, owner_id: int) -> void:
if _active_zones >= MAX_ZONES:
# 顶掉最旧 Zoneindex 0),整体前移(O(N)N≤64)
for j in range(ZONE_STRIDE, _active_zones * ZONE_STRIDE):
_zone_data[j - ZONE_STRIDE] = _zone_data[j]
_active_zones -= 1
var base: int = _active_zones * ZONE_STRIDE
_zone_data[base + 0] = cx
_zone_data[base + 1] = cy
_zone_data[base + 2] = radius
_zone_data[base + 3] = float(status_id)
_zone_data[base + 4] = duration
_zone_data[base + 5] = maxf(0.01, tick_interval)
_zone_data[base + 6] = 0.0 # tick_accum
_zone_data[base + 7] = float(owner_id)
_active_zones += 1
func _physics_process(delta: float) -> void:
if _active_zones == 0:
return
var i: int = 0
while i < _active_zones:
var base: int = i * ZONE_STRIDE
_zone_data[base + 4] -= delta # duration 倒计时
_zone_data[base + 6] += delta # tick_accum 累积
# 速率限制:外层 if 守卫 + 内层 while + -= tick_intervalP6-N71,保留余量精度)
# SpatialGrid.query_circle 仅在 if 守卫内查询一次,不在 while 内重复查询
if _zone_data[base + 6] >= _zone_data[base + 5]:
var cx: float = _zone_data[base + 0]
var cy: float = _zone_data[base + 1]
var rad: float = _zone_data[base + 2]
var sid: int = int(_zone_data[base + 3])
var oid: int = int(_zone_data[base + 7])
var targets: PackedInt32Array = SpatialGrid.query_circle(Vector2(cx, cy), rad)
while _zone_data[base + 6] >= _zone_data[base + 5]:
_zone_data[base + 6] -= _zone_data[base + 5] # -= tick_interval 保留余量
for t_id in targets:
StatusManager.apply(t_id, sid, 1, -1.0, oid) # stacks=1, duration=默认, owner
if _zone_data[base + 4] <= 0.0: # duration 耗尽 → swap-and-pop 移除 + VFX 淡出
VFXManager.play("zone_expire", Vector2(_zone_data[base + 0], _zone_data[base + 1]))
if i < _active_zones - 1:
var last: int = (_active_zones - 1) * ZONE_STRIDE
for k in ZONE_STRIDE:
_zone_data[base + k] = _zone_data[last + k]
_active_zones -= 1
else:
i += 1
func get_active_count() -> int:
return _active_zones
func reset() -> void:
_active_zones = 0 # 惰性清理:SoA 数据不清零,_active_zones 控制有效范围
+1
View File
@@ -0,0 +1 @@
uid://dpk50hi3o0lg4