初次提交
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://douqjxwt18yns
|
||||
@@ -0,0 +1,110 @@
|
||||
## AudioManager — 音效池 + 节流(Autoload: AudioManager)
|
||||
## 权威来源:development_plan.md S6 P1(32 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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cufd7gicqywyq
|
||||
@@ -0,0 +1,178 @@
|
||||
## BulletManager — 子弹管理器 Autoload(GDScript 接口层)
|
||||
## 热路径由 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 置 0;C# 下帧 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.4,SoA 索引 ↔ 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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://d07qoobgfgyr3
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ca4ohbfymmam7
|
||||
@@ -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-N12,S6 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_cost(P6-N18)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7sxbv1ede8h5
|
||||
@@ -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})
|
||||
@@ -0,0 +1 @@
|
||||
uid://dk2lnqdqniw0h
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3xq216d8r3kh
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://gydgmnbchqig
|
||||
@@ -0,0 +1,50 @@
|
||||
## DpsTracker — DPS 环形缓冲区(Autoload: DpsTracker)
|
||||
## 权威来源:implementation_plan.md §2.5.E(P6-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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpjv1tbiar8cm
|
||||
@@ -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 # 无状态需要重置
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbwhq00c61nxt
|
||||
@@ -0,0 +1,76 @@
|
||||
## EndlessRecords — 本地排行榜(Autoload: EndlessRecords)
|
||||
## 权威来源:certification_checklist.md ST-40/41(HMAC-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()))
|
||||
@@ -0,0 +1 @@
|
||||
uid://docfp01fd1red
|
||||
@@ -0,0 +1,329 @@
|
||||
## EnemyManager — 敌人管理器 Autoload(GDScript 接口层)
|
||||
## 热路径由 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 # 禁止裸整数 8(P6-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:屏内可见标志(PackedByteArray)0=屏外,1=屏内
|
||||
var _visible_flags: PackedByteArray = PackedByteArray()
|
||||
|
||||
var _offscreen_sep_counter: int = 0
|
||||
var _cs_node: Node = null
|
||||
|
||||
## ADR-A4:精英寻路。W1–14 杂鱼永远 Boid;W15+ Elite/Boss 按需挂 NavigationAgent2D,
|
||||
## 并发寻路上限 MAX_PATHFINDING_ENEMIES=20,超出降级 Boid(P-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-A4:W15+ 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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctmujoxxqga04
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1aikk2kvjgiq
|
||||
@@ -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: int;EnemyManager 订阅处理伤害
|
||||
|
||||
# ── 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 事件目录表
|
||||
@@ -0,0 +1 @@
|
||||
uid://chtsfdmd0ykor
|
||||
@@ -0,0 +1,33 @@
|
||||
## LocaleManager — 本地化加载(Autoload: Locale)
|
||||
## 权威来源:architecture_design.md ADR-A1(tr("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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpk1b28i6qybx
|
||||
@@ -0,0 +1,103 @@
|
||||
## MinionManager — 玩家召唤物管理(Autoload: MinionManager)
|
||||
## 权威来源:architecture_design.md §5.12 + ADR-R4-N4(独立管理器,方案 B)
|
||||
## 数量上限小(MAX_MINIONS=20,ADR-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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkjoahbv8qtwl
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b2ekg7pdggafm
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dl4lp1m675v82
|
||||
@@ -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:新增 wand(Core/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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bix3rk2gyfmvu
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://broymfifhvw2n
|
||||
@@ -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
|
||||
@@ -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:
|
||||
# 纯数据驱动池:SpellRegistry(data/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_count(P6-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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cu5ace51jtmf4
|
||||
@@ -0,0 +1,75 @@
|
||||
## SpatialGrid — 2D 空间哈希网格 Autoload(GDScript 外壳)
|
||||
## 热路径由 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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://x822ya5c7tlk
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbdjccwuktibn
|
||||
@@ -0,0 +1,14 @@
|
||||
## StatusID — 状态效果整数 ID(Autoload: 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 起
|
||||
@@ -0,0 +1 @@
|
||||
uid://dke3xnu50nshg
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://vm1hbvxu51s8
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ckxpvasdeb8gi
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bx3w1yacjl3qh
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfobw7w7hcchm
|
||||
@@ -0,0 +1,46 @@
|
||||
## WandPreset — Core(法杖)工厂(Autoload: WandPreset)
|
||||
## 纯数据驱动:Core 定义唯一来源 res://data/cores.json(游戏设计器「Core」面板维护)
|
||||
## 法术已迁至 SpellRegistry(data/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}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfgjbhdg6b17l
|
||||
@@ -0,0 +1,174 @@
|
||||
## WaveManager — 波次管理(Autoload: WaveManager)
|
||||
## S6 内容:Wave 1~20 完整配置(敌人原型/精英寻路/Mini Boss W8/Final Boss W20)
|
||||
## 设计参考:game_design.md §3.3(W1-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 基础血量由 SettingsManager(balance.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()
|
||||
# 超过 W20(Endless):数值膨胀
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dj1o00ehrwfwj
|
||||
@@ -0,0 +1,76 @@
|
||||
## ZoneManager — 地面效果区域管理(Autoload: ZoneManager)
|
||||
## 权威来源:architecture_design.md §8 ADR-R5-N1(P6-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-01,64 区×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:
|
||||
# 顶掉最旧 Zone(index 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_interval(P6-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 控制有效范围
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpk50hi3o0lg4
|
||||
Reference in New Issue
Block a user