初次提交

This commit is contained in:
2026-07-20 10:56:52 +08:00
commit 7bcc0026e0
462 changed files with 50191 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
## AudioBusID — AudioBus 索引常量(Autoload: AudioBusID
## 与 AudioServer 路由层(architecture_design.md ADR-A3)对应。
## 禁止在 SettingsManager 等处使用字符串查 Bus(跨切片约束)。
extends Node
const MASTER: int = 0
const BGM: int = 1
const BGM_BASE: int = 2
const BGM_LAYER: int = 3
const SFX: int = 4
const SFX_COMBAT: int = 5
const SFX_SPATIAL: int = 6
const UI: int = 7
const AMBIENCE: int = 8
const VOICE: int = 9
+1
View File
@@ -0,0 +1 @@
uid://douqjxwt18yns
+110
View File
@@ -0,0 +1,110 @@
## AudioManager — 音效池 + 节流(Autoload: AudioManager
## 权威来源:development_plan.md S6 P132 AudioStreamPlayer2D 池、0.1s 节流)
## 无音频素材时用程序化蜂鸣占位(同 API,放素材即替换)。SFX 总线 send→Master,受 SettingsManager 音量影响。
extends Node
const POOL_SIZE: int = 32
const THROTTLE_SEC: float = 0.1 # 同一 sound_id 两次播放最小间隔
## sound_id → 占位蜂鸣参数 [freq, ms](有真实 res://audio/sfx/<id>.* 时优先加载)
const _SFX_DEFS: Dictionary = {
"hit": [900.0, 60.0],
"kill": [420.0, 90.0],
"hurt": [180.0, 140.0],
"wave_complete": [660.0, 220.0],
"boss": [120.0, 400.0],
"shop": [780.0, 120.0],
"buy": [1040.0, 80.0],
"level_up": [880.0, 180.0],
}
var _players: Array = [] # Array[AudioStreamPlayer2D]
var _next: int = 0 # round-robin 游标
var _streams: Dictionary = {} # sound_id → AudioStream(懒构建缓存)
var _last_play: Dictionary = {} # sound_id → 上次播放 ticks_msec
func _ready() -> void:
_ensure_sfx_bus()
for i in POOL_SIZE:
var p := AudioStreamPlayer2D.new()
p.bus = "SFX"
add_child(p)
_players.append(p)
# 事件挂钩(无素材时静默蜂鸣)
EventBus.subscribe(EventID.ENEMY_KILLED, func(_p): play("kill"))
EventBus.subscribe(EventID.BULLET_HIT, func(p): play("hit", p.get("hit_pos", Vector2.ZERO)))
EventBus.subscribe(EventID.PLAYER_DAMAGED, func(_p): play("hurt"))
EventBus.subscribe(EventID.WAVE_COMPLETE, func(_p): play("wave_complete"))
EventBus.subscribe(EventID.BOSS_SPAWNED, func(_p): play("boss"))
EventBus.subscribe(EventID.SHOP_OPENED, func(_p): play("shop"))
EventBus.subscribe(EventID.LEVEL_UP, func(_p): play("level_up"))
func _ensure_sfx_bus() -> void:
if AudioServer.get_bus_index("SFX") < 0:
var idx: int = AudioServer.bus_count
AudioServer.add_bus(idx)
AudioServer.set_bus_name(idx, "SFX")
AudioServer.set_bus_send(idx, "Master") # 受 Master 音量(SettingsManager)影响
## 播放音效;返回 true=已触发,false=节流跳过 / 无可用流
func play(sound_id: String, world_pos: Vector2 = Vector2.ZERO) -> bool:
var now: int = Time.get_ticks_msec()
var last: int = int(_last_play.get(sound_id, -100000))
if now - last < int(THROTTLE_SEC * 1000.0):
return false # 0.1s 节流:抑制同一音效高频刷屏
var stream: AudioStream = _get_stream(sound_id)
if stream == null:
return false
_last_play[sound_id] = now
var p: AudioStreamPlayer2D = _players[_next]
_next = (_next + 1) % POOL_SIZE
p.stream = stream
p.global_position = world_pos
p.play()
return true
func _get_stream(sound_id: String) -> AudioStream:
if _streams.has(sound_id):
return _streams[sound_id]
# 优先真实素材
for ext in [".ogg", ".wav", ".mp3"]:
var path: String = "res://audio/sfx/%s%s" % [sound_id, ext]
if ResourceLoader.exists(path):
var s = load(path)
if s is AudioStream:
_streams[sound_id] = s
return s
# 回退:程序化蜂鸣
if _SFX_DEFS.has(sound_id):
var def: Array = _SFX_DEFS[sound_id]
var beep := _make_beep(float(def[0]), float(def[1]))
_streams[sound_id] = beep
return beep
_streams[sound_id] = null
return null
## 生成短蜂鸣(16-bit PCM 正弦 + 线性衰减包络)
func _make_beep(freq: float, ms: float) -> AudioStreamWAV:
var sr: int = 22050
var n: int = max(1, int(sr * ms / 1000.0))
var data := PackedByteArray()
data.resize(n * 2)
for i in n:
var t: float = float(i) / float(sr)
var env: float = 1.0 - float(i) / float(n)
var sample: int = int(sin(t * freq * TAU) * 11000.0 * env)
data.encode_s16(i * 2, sample)
var w := AudioStreamWAV.new()
w.format = AudioStreamWAV.FORMAT_16_BITS
w.mix_rate = sr
w.stereo = false
w.data = data
return w
## 当前正在播放的池实例数(测试 / 调试用)
func active_voices() -> int:
var c: int = 0
for p in _players:
if p.playing:
c += 1
return c
+1
View File
@@ -0,0 +1 @@
uid://cufd7gicqywyq
+178
View File
@@ -0,0 +1,178 @@
## BulletManager — 子弹管理器 AutoloadGDScript 接口层)
## 热路径由 C# BulletManagerCs 子节点驱动。
## 权威来源:architecture_design.md §4.2
##
## SoA 完整布局(BULLET_STRIDE=12):
## [ x, y, vx, vy, lifetime, radius, base_damage, damage_mult, damage_type, owner_id, source_tags, acceleration ]
## 0 1 2 3 4 5 6 7 8 9 10 11
## 冷数据(pierce/bounce/homing/payload_id)→ _bullet_contexts: Dictionary
extends Node
const BULLET_STRIDE: int = 12 # 禁止裸整数 12(跨切片约束)
const MAX_BULLETS: int = 2048 # 预分配 SoA 大小
# SoA 热数组(由 BulletManagerCs 直接读写)
var _data: PackedFloat32Array = PackedFloat32Array()
var _active_count: int = 0
# 冷数据(非热路径)
var _bullet_contexts: Dictionary = {}
# 敌人位置快照(homing 共用,由 _physics_process 帧头建立)
var _enemy_pos_snapshot: PackedVector2Array = PackedVector2Array()
var _cs_node: Node = null
func _ready() -> void:
_data.resize(MAX_BULLETS * BULLET_STRIDE)
_data.fill(0.0)
func _physics_process(delta: float) -> void:
EnemyManager.fill_pos_snapshot(_enemy_pos_snapshot)
if not _cs_node:
_gd_integrate(delta)
# GDScript 回退路径(C# 未就绪时 / S0/S1 基线验证)
func _gd_integrate(delta: float) -> void:
var i: int = 0
while i < _active_count:
var base: int = i * BULLET_STRIDE
_data[base + 0] += _data[base + 2] * delta # x += vx * dt
_data[base + 1] += _data[base + 3] * delta # y += vy * dt
_data[base + 2] += _data[base + 11] * delta # vx += accel * dt
_data[base + 3] += _data[base + 11] * delta # vy += accel * dt
_data[base + 4] -= delta # lifetime -= dt
if _data[base + 4] <= 0.0:
_swap_and_pop(i)
elif _check_collision(i, base):
# 命中处理已在 _check_collision 内完成,子弹被回收
pass # _active_count 已减少,i 不递增
else:
i += 1
# S1 碰撞检测:数据驱动,使用 SpatialGrid 查询
# 命中后扣血;pierce_remaining=0 时回收子弹;返回 true 表示子弹已被回收
func _check_collision(bullet_idx: int, base: int) -> bool:
var bx: float = _data[base + 0]
var by: float = _data[base + 1]
var br: float = _data[base + 5] # bullet radius
var b_dmg: float = _data[base + 6] # base_damage
var b_mult: float = _data[base + 7] # damage_mult
var b_type: int = int(_data[base + 8])
var b_own: int = int(_data[base + 9])
var query_r: float = br + 16.0 # 16px = 敌人碰撞体估算半径
var hits: PackedInt32Array = SpatialGrid.query_circle(Vector2(bx, by), query_r)
if hits.is_empty():
return false
for entity_id in hits:
var ene_pos: Vector2 = EnemyManager.get_pos_by_id(entity_id)
if ene_pos == Vector2(-9999.0, -9999.0):
continue
var dx: float = bx - ene_pos.x
var dy: float = by - ene_pos.y
var dist_sq: float = dx * dx + dy * dy
if dist_sq > query_r * query_r:
continue
# 命中:通过 DamageContextPool 发出伤害
var ctx_id: int = DamageContextPool.acquire(b_dmg, b_mult, b_type, b_own, false, 0.0)
EnemyManager.apply_damage_from_context(entity_id, ctx_id)
DamageContextPool.release(ctx_id)
var hit_pos: Vector2 = EnemyManager.get_pos_by_id(entity_id)
if hit_pos == Vector2(-9999.0, -9999.0):
hit_pos = Vector2(bx, by)
VFXManager.play("hit_spark", hit_pos)
EventBus.emit(EventID.BULLET_HIT, {"bullet_id": bullet_idx, "target_id": entity_id, "hit_pos": hit_pos})
# S4: 命中施加状态 / 连击标记
var cold: Dictionary = _bullet_contexts.get(bullet_idx, {})
var status_id: int = int(cold.get("apply_status_id", -1))
if status_id > 0:
StatusManager.apply(entity_id, status_id, 1, -1.0, b_own)
if cold.get("apply_combo_mark", false):
StatusManager.apply(entity_id, StatusID.COMBO_MARK, 1, 2.0, b_own)
# S3: 命中触发子荷载
var payload_id: int = int(cold.get("on_hit_payload_id", -1))
if payload_id >= 0:
var parent_depth: int = int(cold.get("trigger_parent_depth", 0))
SpellEvaluator.execute_sub(payload_id, hit_pos, b_own, parent_depth + 1)
# pierce 处理
var pierce: int = int(cold.get("pierce_remaining", 0))
if pierce > 0:
cold["pierce_remaining"] = pierce - 1
_bullet_contexts[bullet_idx] = cold
return false # 穿透:子弹继续飞行
_swap_and_pop(bullet_idx)
return true # 子弹已回收
return false
func _swap_and_pop(idx: int) -> void:
var last: int = _active_count - 1
if idx != last:
var base_idx: int = idx * BULLET_STRIDE
var base_last: int = last * BULLET_STRIDE
for s in BULLET_STRIDE:
_data[base_idx + s] = _data[base_last + s]
if _bullet_contexts.has(last):
_bullet_contexts[idx] = _bullet_contexts[last]
_bullet_contexts.erase(last)
elif _bullet_contexts.has(idx):
_bullet_contexts.erase(idx)
_active_count -= 1
# ── 对外接口 ─────────────────────────────────────────────────────
## 生成子弹,返回 bullet_id(比 -1 表示满容)
## 参数附带冷数据:cold_data = {"on_hit_payload_id": int, "pierce_remaining": int, ...}
func spawn_bullet(pos: Vector2, vel: Vector2, lifetime: float, radius: float,
base_damage: float, damage_mult: float, damage_type: int,
owner_id: int, source_tags: int = 0, acceleration: float = 0.0,
cold_data: Dictionary = {}) -> int:
if _active_count >= MAX_BULLETS:
return -1
var bullet_id: int = _active_count
var base: int = bullet_id * BULLET_STRIDE
_data[base + 0] = pos.x
_data[base + 1] = pos.y
_data[base + 2] = vel.x
_data[base + 3] = vel.y
_data[base + 4] = lifetime
_data[base + 5] = radius
_data[base + 6] = base_damage
_data[base + 7] = damage_mult
_data[base + 8] = float(damage_type)
_data[base + 9] = float(owner_id)
_data[base + 10] = float(source_tags)
_data[base + 11] = acceleration
if not cold_data.is_empty():
_bullet_contexts[bullet_id] = cold_data
_active_count += 1
return bullet_id
## 提前回收:lifetime 置 0C# 下帧 SoA 清理
func despawn_bullet(bullet_id: int) -> void:
if bullet_id < 0 or bullet_id >= _active_count:
return
_data[bullet_id * BULLET_STRIDE + 4] = 0.0 # slot +4: lifetime
func get_active_count() -> int:
return _active_count
## 渲染:将活跃子弹的 SoA 位置批量写入 MultiMesh(§4.4SoA 索引 ↔ instance 索引共享)
## visible_instance_count 控制渲染数量,避免每帧重分配 instance_count
func sync_multimesh(mm: MultiMesh) -> void:
if mm == null:
return
var n: int = min(_active_count, mm.instance_count)
mm.visible_instance_count = n
for i in n:
var base: int = i * BULLET_STRIDE
var r: float = _data[base + 5]
mm.set_instance_transform_2d(i, Transform2D(
0.0, Vector2(r * 2.0, r * 2.0), 0.0,
Vector2(_data[base + 0], _data[base + 1])))
func get_nearest_enemy_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
return EnemyManager.get_nearest_pos(origin, max_dist)
func reset() -> void:
_active_count = 0
_data.fill(0.0)
_bullet_contexts.clear()
+1
View File
@@ -0,0 +1 @@
uid://d07qoobgfgyr3
+39
View File
@@ -0,0 +1,39 @@
## ConfigMgr — JSON 配置加载器(Autoload: ConfigMgr
## 权威来源:architecture_design.md §2 数据层
extends Node
var _cache: Dictionary = {} # { file_path: String → Dictionary }
## 加载 JSON 配置文件(带缓存)
func load_json(path: String) -> Dictionary:
if _cache.has(path):
return _cache[path]
if not FileAccess.file_exists(path):
push_warning("ConfigMgr: 文件不存在 %s" % path)
return {}
var text: String = FileAccess.get_file_as_string(path)
var parsed = JSON.parse_string(text)
if parsed == null:
push_error("ConfigMgr: JSON 解析失败 %s" % path)
return {}
if parsed is Dictionary:
_cache[path] = parsed
return parsed
push_error("ConfigMgr: JSON 根节点不是 Dictionary %s" % path)
return {}
## 查询键值(支持点号路径,如 "enemies.basic.hp"
func get_value(path: String, key: String, default_val = null):
var data: Dictionary = load_json(path)
var keys: PackedStringArray = key.split(".")
var node = data
for k in keys:
if node is Dictionary and node.has(k):
node = node[k]
else:
return default_val
return node
## 清除缓存(热重载时调用)
func clear_cache() -> void:
_cache.clear()
+1
View File
@@ -0,0 +1 @@
uid://ca4ohbfymmam7
+9
View File
@@ -0,0 +1,9 @@
## CoreFeatureTag — Core 特性标签整数常量(Autoload: CoreFeatureTag
## 禁止在判断逻辑中使用裸字符串(跨切片约束 ADR-R5-N2)。
extends Node
const PERSISTENT_MEMORY: int = 1 # 寄存器跨帧保留(wand_memory Core
const DUAL_STREAM: int = 2 # 双流执行(P6-N12S6 P1 实现)
const ALWAYS_CAST_LAST: int = 3 # 尾槽 MODIFIER/TRIGGER/LOGIC 静默跳过(P6-N7
const SHUFFLE_DECK: int = 4 # ACTION 原子单元乱序(P5-N2
const INFINITE_SPELLS: int = 5 # 卖血流,配合 heavy_costP6-N18
@@ -0,0 +1 @@
uid://c7sxbv1ede8h5
+108
View File
@@ -0,0 +1,108 @@
## CrashReporter — 全局崩溃/异常日志 Autoload
##
## 职责:
## 1. 捕获 NOTIFICATION_CRASH(引擎级崩溃信号)并写入 user://crash_log.txt
## 2. 捕获 NOTIFICATION_WM_CLOSE_REQUEST(正常退出),记录最后一帧状态
## 3. 游戏启动时检查上次是否有未完成的崩溃日志并上报 EventBus(供 HUD 展示提示)
##
## 注意:此 Autoload 必须排在所有其他 Autoload 之前注册(project.godot [autoload] 顺序),
## 确保其他系统崩溃时本 Reporter 已初始化。
##
## 使用方式:
## CrashReporter.log_error("模块名", "错误描述") → 写入 crash_log.txt(不崩溃)
## CrashReporter.log_fatal("模块名", "致命错误") → 写入后 crash(开发模式下)
extends Node
const LOG_PATH: String = "user://crash_log.txt"
const MAX_LOG_BYTES: int = 512 * 1024 # 512KB 上限,防止无限增长
var _session_start_time: float = 0.0
var _last_wave: int = 0 # 由 WaveManager 每波更新,崩溃时记录当前波次
# ── 生命周期 ────────────────────────────────────────────────────────────────
func _ready() -> void:
_session_start_time = Time.get_unix_time_from_system()
_rotate_log_if_oversized()
_check_previous_crash()
func _notification(what: int) -> void:
match what:
NOTIFICATION_CRASH:
_write_crash_entry("CRASH", "引擎级崩溃(NOTIFICATION_CRASH")
NOTIFICATION_WM_CLOSE_REQUEST:
_write_crash_entry("SHUTDOWN", "正常退出")
get_tree().quit()
# ── 公共接口 ────────────────────────────────────────────────────────────────
## 记录非致命错误(push_error 级别,不终止运行)
func log_error(module: String, message: String) -> void:
push_error("[%s] %s" % [module, message])
_append_log("ERROR", module, message)
## 记录致命错误(开发模式下触发断言失败,发布模式下仅记录)
func log_fatal(module: String, message: String) -> void:
_write_crash_entry("FATAL", "[%s] %s" % [module, message])
assert(false, "[CrashReporter] Fatal: %s%s" % [module, message])
## 由 WaveManager 调用,更新崩溃时记录的波次信息
func set_current_wave(wave: int) -> void:
_last_wave = wave
# ── 内部实现 ─────────────────────────────────────────────────────────────────
func _write_crash_entry(level: String, reason: String) -> void:
var elapsed: float = Time.get_unix_time_from_system() - _session_start_time
var entry: String = (
"\n=== %s @ %s (session +%.1fs, wave=%d) ===\n%s\n" % [
level,
Time.get_datetime_string_from_system(),
elapsed,
_last_wave,
reason
]
)
_append_log_raw(entry)
func _append_log(level: String, module: String, message: String) -> void:
var line: String = "[%s][%s] %s%s\n" % [
Time.get_time_string_from_system(),
level, module, message
]
_append_log_raw(line)
func _append_log_raw(text: String) -> void:
var f := FileAccess.open(LOG_PATH, FileAccess.READ_WRITE)
if f == null:
f = FileAccess.open(LOG_PATH, FileAccess.WRITE)
if f == null:
push_warning("CrashReporter: 无法打开 %s" % LOG_PATH)
return
f.seek_end(0)
f.store_string(text)
func _rotate_log_if_oversized() -> void:
if not FileAccess.file_exists(LOG_PATH):
return
if FileAccess.get_file_as_bytes(LOG_PATH).size() > MAX_LOG_BYTES:
var archived: String = LOG_PATH.replace(".txt", "_prev.txt")
DirAccess.rename_absolute(
ProjectSettings.globalize_path(LOG_PATH),
ProjectSettings.globalize_path(archived)
)
func _check_previous_crash() -> void:
## 检查上次是否有 CRASH 或 FATAL 记录,若有则在下次启动时通过 EventBus 通知
if not FileAccess.file_exists(LOG_PATH):
return
var content: String = FileAccess.get_file_as_string(LOG_PATH)
if "=== CRASH" in content or "=== FATAL" in content:
# 延迟到第一帧,确保 EventBus 已初始化
call_deferred("_emit_crash_detected")
func _emit_crash_detected() -> void:
if Engine.has_singleton("EventBus"):
# EventID 暂用 0(特殊保留 ID),HUD 订阅后展示"上次游戏异常退出"提示
EventBus.emit(0, {"source": "crash_reporter", "log_path": LOG_PATH})
+1
View File
@@ -0,0 +1 @@
uid://dk2lnqdqniw0h
+28
View File
@@ -0,0 +1,28 @@
## DamageContext — 伤害上下文数据类(必须单独放在此文件)
## 权威来源:implementation_plan.md §2.1 E-N2 P6-N49
## 禁止将 class_name DamageContext 写入 damage_context_pool.gd
extends RefCounted
class_name DamageContext
var base_damage: float = 0.0
var mult: float = 1.0
var damage_type: int = 0 # DamageType.PHYSICAL
var owner_id: int = -1
var source_tags: int = 0
var is_crit: bool = false
var pierce_rate: float = 0.0 # 护甲穿透率 0.0~1.0
func reset() -> void:
base_damage = 0.0
mult = 1.0
damage_type = 0
owner_id = -1
source_tags = 0
is_crit = false
pierce_rate = 0.0
## 伤害公式:((base_damage + add) × mult) × (1 resistance) armor
## S1 简化版:返回 base_damage * mult
func calc_damage(armor: float = 0.0, resistance: float = 0.0) -> float:
var effective_armor: float = armor * (1.0 - pierce_rate)
return max(0.0, base_damage * mult * (1.0 - resistance) - effective_armor)
+1
View File
@@ -0,0 +1 @@
uid://d3xq216d8r3kh
+69
View File
@@ -0,0 +1,69 @@
## DamageContextPool — 伤害上下文对象池(Autoload: DamageContextPool
## 权威来源:implementation_plan.md §2.1 E-N2 P6-N67
## 本文件不声明 class_name!禁止将 DamageContext 写入这里
extends Node
const POOL_SIZE: int = 64
var _slots: Array = []
var _free_ids: PackedInt32Array = PackedInt32Array()
var _in_use: PackedByteArray = PackedByteArray()
func _ready() -> void:
_slots.resize(POOL_SIZE)
_free_ids.resize(POOL_SIZE)
_in_use.resize(POOL_SIZE)
for i in POOL_SIZE:
_slots[i] = DamageContext.new()
_free_ids[i] = POOL_SIZE - 1 - i
_in_use[i] = 0
## acquire 内部已调用 ctx.reset()P6-N67
func acquire(base_dmg: float, m: float, dtype: int, owner: int, crit: bool, pierce: float) -> int:
if _free_ids.is_empty():
push_warning("DamageContextPool: 池已耗尽 (POOL_SIZE=%d)" % POOL_SIZE)
var new_id: int = _slots.size()
_slots.append(DamageContext.new())
_in_use.append(0)
_fill_slot(new_id, base_dmg, m, dtype, owner, crit, pierce)
return new_id
var id: int = _free_ids[_free_ids.size() - 1]
_free_ids.resize(_free_ids.size() - 1)
_fill_slot(id, base_dmg, m, dtype, owner, crit, pierce)
return id
func _fill_slot(id: int, base_dmg: float, m: float, dtype: int, owner: int, crit: bool, pierce: float) -> void:
var ctx: DamageContext = _slots[id]
ctx.reset()
ctx.base_damage = base_dmg
ctx.mult = m
ctx.damage_type = dtype
ctx.owner_id = owner
ctx.is_crit = crit
ctx.pierce_rate = pierce
_in_use[id] = int(1)
func get_context(id: int) -> DamageContext:
if id < 0 or id >= _slots.size() or _in_use[id] == 0:
return null
return _slots[id]
func release(id: int) -> void:
if id < 0 or id >= _slots.size() or _in_use[id] == 0:
push_warning("DamageContextPool.release: 无效或重复归还 ID=%d" % id)
return
_in_use[id] = 0
_free_ids.append(id)
func get_pool_usage() -> String:
var used: int = 0
for i in _in_use.size():
if _in_use[i] != 0:
used += 1
return "%d / %d" % [used, _slots.size()]
func reset() -> void:
_free_ids.clear()
for i in _slots.size():
_in_use[i] = 0
_free_ids.append(i)
@@ -0,0 +1 @@
uid://cw107yf7db43l
+11
View File
@@ -0,0 +1,11 @@
## DamageType — 伤害类型整数常量(Autoload: DamageType
## 与 BulletManager SoA slot+8 (damage_type) 字段对应。
## 扩展新类型时从 6 起递增,并同步更新 implementation_plan.md §2.1 DamageContext 表。
extends Node
const PHYSICAL: int = 0
const FIRE: int = 1
const ICE: int = 2
const LIGHTNING: int = 3
const POISON: int = 4
const ARCANE: int = 5
+1
View File
@@ -0,0 +1 @@
uid://gydgmnbchqig
+50
View File
@@ -0,0 +1,50 @@
## DpsTracker — DPS 环形缓冲区(Autoload: DpsTracker
## 权威来源:implementation_plan.md §2.5.EP6-N38、P6-N47
extends Node
const DPS_WINDOW_SEC: float = 3.0
const _RB_SIZE: int = 256
var _rb_time: PackedFloat64Array = PackedFloat64Array()
var _rb_dmg: PackedFloat32Array = PackedFloat32Array()
var _rb_head: int = 0
var _rb_count: int = 0
var _accumulated_dmg: float = 0.0
func _ready() -> void:
_rb_time.resize(_RB_SIZE)
_rb_dmg.resize(_RB_SIZE)
func record_damage(amount: float) -> void:
if amount <= 0.0:
return
var now: float = Time.get_ticks_msec() / 1000.0
_rb_time[_rb_head] = now
_rb_dmg[_rb_head] = amount
_rb_head = (_rb_head + 1) % _RB_SIZE
if _rb_count < _RB_SIZE:
_rb_count += 1
_accumulated_dmg += amount
func get_dps() -> float:
_recalc_window(Time.get_ticks_msec() / 1000.0)
return _accumulated_dmg / DPS_WINDOW_SEC
func _recalc_window(now: float) -> void:
var sum: float = 0.0
var valid: int = 0
var tail: int = (_rb_head - _rb_count + _RB_SIZE) % _RB_SIZE
for k in _rb_count:
var idx: int = (tail + k) % _RB_SIZE
if (now - _rb_time[idx]) <= DPS_WINDOW_SEC:
sum += _rb_dmg[idx]
valid += 1
_accumulated_dmg = sum
_rb_count = valid
func reset() -> void:
_rb_head = 0
_rb_count = 0
_accumulated_dmg = 0.0
_rb_time.fill(0.0)
_rb_dmg.fill(0.0)
+1
View File
@@ -0,0 +1 @@
uid://dpjv1tbiar8cm
+18
View File
@@ -0,0 +1,18 @@
## DropManager — 掉落物管理(Autoload: DropManager
## S2 范围:敌人死亡时直接发放 XP/金币(无拾取物步骤)
## S3+ 再加入地面拾取物
extends Node
## S2 内置掉落参数
const XP_PER_KILL: int = 5
const GOLD_PER_KILL: int = 2
func _ready() -> void:
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
func _on_enemy_killed(payload: Dictionary) -> void:
PlayerStats.gain_xp(XP_PER_KILL)
PlayerStats.gain_gold(GOLD_PER_KILL)
func reset() -> void:
pass # 无状态需要重置
+1
View File
@@ -0,0 +1 @@
uid://dbwhq00c61nxt
+76
View File
@@ -0,0 +1,76 @@
## EndlessRecords — 本地排行榜(Autoload: EndlessRecords
## 权威来源:certification_checklist.md ST-40/41HMAC-SHA256 防篡改)、P6-N19 评分公式
## 排名:到达波次为主、同波次按用时升序;在线分 = wave*100000 + (86400 - elapsed_sec)
extends Node
const RECORDS_PATH: String = "user://endless_records.json"
const MAX_RECORDS: int = 50
## HMAC 密钥(不入存档;仅防普通玩家手改,无法对抗逆向,见 ST-40)
## PackedByteArray(...) 非常量表达式,故用 var 在加载时初始化
var _HMAC_KEY: PackedByteArray = PackedByteArray([
0xA3, 0x7F, 0x2C, 0x91, 0x4E, 0xD8, 0x12, 0x6B,
0xF0, 0x39, 0x5A, 0xCC, 0x77, 0x1D, 0x8E, 0x44,
0x22, 0xB9, 0x60, 0x3F, 0xE1, 0x05, 0x9C, 0x7A,
0x53, 0xD4, 0x08, 0x6F, 0xAB, 0x31, 0xC7, 0x90,
])
## P6-N19:单整数双维度评分(波次高优先;同波次剩余秒数高优先)
static func compute_score(wave: int, elapsed_sec: int) -> int:
return wave * 100000 + (86400 - clampi(elapsed_sec, 0, 86400))
func _sign(data: String) -> String:
var crypto := Crypto.new()
var hmac := crypto.hmac_digest(HashingContext.HASH_SHA256, _HMAC_KEY, data.to_utf8_buffer())
return hmac.hex_encode()
## 追加一条记录并持久化;返回排序后的全部记录
func add_record(wave: int, elapsed_sec: int, kills: int) -> Array:
var records: Array = load_records()
records.append({
"wave": wave,
"elapsed": elapsed_sec,
"kills": kills,
"score": compute_score(wave, elapsed_sec),
})
records = _sort_records(records)
if records.size() > MAX_RECORDS:
records.resize(MAX_RECORDS)
save_records(records)
return records
## data 以 JSON 字符串存储并对该精确字符串签名(避免 JSON int↔float 往返破坏签名)
func save_records(records: Array) -> void:
var payload: String = JSON.stringify(records)
var signed: Dictionary = {"data": payload, "sig": _sign(payload)}
var f := FileAccess.open(RECORDS_PATH, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(signed))
f.close()
func load_records() -> Array:
if not FileAccess.file_exists(RECORDS_PATH):
return []
var raw = JSON.parse_string(FileAccess.get_file_as_string(RECORDS_PATH))
if not (raw is Dictionary):
return []
var payload: String = String(raw.get("data", ""))
if _sign(payload) != String(raw.get("sig", "")):
push_warning("EndlessRecords: 签名验证失败,分数不可信,拒绝载入") # ST-41
return []
var data = JSON.parse_string(payload)
return data if data is Array else []
## 波次降序、同波次用时升序(P6-N19)
func _sort_records(records: Array) -> Array:
records.sort_custom(func(a, b):
var wa: int = int(a.get("wave", 0))
var wb: int = int(b.get("wave", 0))
if wa != wb:
return wa > wb
return int(a.get("elapsed", 99999)) < int(b.get("elapsed", 99999)))
return records
func get_top(n: int = 10) -> Array:
var r: Array = load_records()
return r.slice(0, min(n, r.size()))
+1
View File
@@ -0,0 +1 @@
uid://docfp01fd1red
+329
View File
@@ -0,0 +1,329 @@
## EnemyManager — 敌人管理器 AutoloadGDScript 接口层)
## 热路径由 C# EnemyManagerCs 子节点驱动。
## 权威来源:implementation_plan.md §2.3.C
##
## SoA 布局(ENEMY_STRIDE=8):
## [ x, y, vx, vy, hp, hp_max, enemy_type, state ]
## 0 1 2 3 4 5 6 7
extends Node
const ENEMY_STRIDE: int = 8 # 禁止裸整数 8P6-N54
const MAX_ENEMIES: int = 1024
const OFFSCREEN_SEPARATION_INTERVAL: int = 10 # 屏外低频 Boid 分离力更新频率(P6-N1)
# SoA 热数组
var _data: PackedFloat32Array = PackedFloat32Array()
var _active_count: int = 0
# entity_id ↔ SoA 槽位 双向映射
var _entity_index_map: Dictionary = {} # { entity_id → slot_idx }
var _slot_entity_map: Dictionary = {} # { slot_idx → entity_id }
var _next_entity_id: int = 1
# LOD:屏内可见标志(PackedByteArray0=屏外,1=屏内
var _visible_flags: PackedByteArray = PackedByteArray()
var _offscreen_sep_counter: int = 0
var _cs_node: Node = null
## ADR-A4:精英寻路。W114 杂鱼永远 BoidW15+ Elite/Boss 按需挂 NavigationAgent2D
## 并发寻路上限 MAX_PATHFINDING_ENEMIES=20,超出降级 BoidP-S5-AI-01)。
const MAX_PATHFINDING_ENEMIES: int = 20
const ELITE_SPEED: float = 90.0
## 敌人原型(enemy_type 存于 SoA[+6])。S6 内容:基础/快速/护甲/精英/Boss
enum Type { BASIC = 0, FAST = 1, ARMORED = 2, ELITE = 3, MINIBOSS = 4, BOSS = 5 }
## 各原型数值:纯数据驱动,唯一来源 data/enemies.json(游戏设计器「敌人」面板维护)
## get() 的单值默认仅为防崩溃,非内容副本
var _SPEED: Dictionary = {} # type → 移动速度
var _ARMOR: Dictionary = {} # type → 护甲
var _RENDER_SIZE: Dictionary = {} # type → 渲染直径
var _RENDER_COLOR: Dictionary = {} # type → 渲染颜色
const ARCHETYPE_JSON: String = "res://data/enemies.json"
## 数据驱动:从 data/enemies.json 加载原型数值(速度/护甲/尺寸/颜色)
func _load_json_archetypes() -> void:
if not FileAccess.file_exists(ARCHETYPE_JSON):
push_error("EnemyManager: 缺失 res://data/enemies.json(敌人原型数值)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(ARCHETYPE_JSON))
if not (data is Dictionary):
push_error("EnemyManager: enemies.json 格式错误")
return
for k in data:
var t: int = int(k)
var d: Dictionary = data[k]
_SPEED[t] = float(d.get("speed", 80.0))
_ARMOR[t] = float(d.get("armor", 0.0))
_RENDER_SIZE[t] = float(d.get("size", 14.0))
var c: Array = d.get("color", [0.9, 0.3, 0.3])
_RENDER_COLOR[t] = Color(float(c[0]), float(c[1]), float(c[2]))
var _nav_region: NavigationRegion2D = null # 程序化矩形导航区
var _pathfinders: Dictionary = {} # entity_id → {"agent":NavigationAgent2D, "host":Node2D}
var _pathfinder_order: Array = [] # FIFO,用于超上限淘汰
func _ready() -> void:
_load_json_archetypes() # 数据驱动:覆盖原型数值(游戏设计器面板维护)
_data.resize(MAX_ENEMIES * ENEMY_STRIDE)
_data.fill(0.0)
_visible_flags.resize(MAX_ENEMIES)
_visible_flags.fill(0)
func _physics_process(delta: float) -> void:
_offscreen_sep_counter += 1
var do_sep: bool = (_offscreen_sep_counter >= OFFSCREEN_SEPARATION_INTERVAL)
if do_sep:
_offscreen_sep_counter = 0
if not _cs_node:
_gd_update_movement(delta)
# ADR-A4:精英寻路移动(NavigationAgent2D 跟随),仅当存在 pathfinder 时执行
if not _pathfinders.is_empty():
_update_pathfinding_movement(delta)
# S1: 移动后更新 SpatialGrid(碰撞检测依赖)
_update_spatial_grid()
func _update_spatial_grid() -> void:
for i in _active_count:
var base: int = i * ENEMY_STRIDE
var eid: int = _slot_entity_map.get(i, -1)
if eid >= 0:
SpatialGrid.insert(eid, Vector2(_data[base + 0], _data[base + 1]))
# GDScript 回退:直线追玩家(C# 没就绪时使用)
func _gd_update_movement(delta: float) -> void:
var player_pos: Vector2 = PlayerManager.get_position()
var has_pf: bool = not _pathfinders.is_empty() # 无精英时零额外开销
for i in _active_count:
if has_pf and _pathfinders.has(_slot_entity_map.get(i, -1)):
continue # 寻路精英由 _update_pathfinding_movement 处理
var base: int = i * ENEMY_STRIDE
var ex: float = _data[base + 0]
var ey: float = _data[base + 1]
var spd: float = _SPEED.get(int(_data[base + 6]), 80.0) # 按原型查速度
var dx: float = player_pos.x - ex
var dy: float = player_pos.y - ey
var dist: float = sqrt(dx * dx + dy * dy)
if dist > 1.0:
_data[base + 2] = (dx / dist) * spd
_data[base + 3] = (dy / dist) * spd
_data[base + 0] += _data[base + 2] * delta
_data[base + 1] += _data[base + 3] * delta
# ── 对外接口 ───────────────────────────────────────────────────
func spawn_enemy(pos: Vector2, hp: float, enemy_type: int = 0, has_pathfinding: bool = false) -> int:
if _active_count >= MAX_ENEMIES:
return -1
var entity_id: int = _next_entity_id
_next_entity_id += 1
var slot: int = _active_count
var base: int = slot * ENEMY_STRIDE
_data[base + 0] = pos.x
_data[base + 1] = pos.y
_data[base + 2] = 0.0
_data[base + 3] = 0.0
_data[base + 4] = hp # hp
_data[base + 5] = hp # hp_max
_data[base + 6] = float(enemy_type)
_data[base + 7] = 0.0 # state: 0=alive
_entity_index_map[entity_id] = slot
_slot_entity_map[slot] = entity_id
_active_count += 1
# ADR-A4W15+ Elite 在并发上限内挂寻路(超限降级 Boid)
if has_pathfinding and _pathfinders.size() < MAX_PATHFINDING_ENEMIES:
_register_pathfinder(entity_id, pos)
return entity_id
# ── ADR-A4 精英寻路子系统 ──────────────────────────────────────
func _ensure_nav_region() -> void:
if _nav_region != null:
return
_nav_region = NavigationRegion2D.new()
var poly := NavigationPolygon.new()
# 覆盖竞技场的单个凸矩形导航多边形(无障碍占位;P-S5-AI-01 仅测寻路开销)
var ext: float = 4000.0
poly.vertices = PackedVector2Array([
Vector2(-ext, -ext), Vector2(-ext, ext), Vector2(ext, ext), Vector2(ext, -ext),
])
poly.add_polygon(PackedInt32Array([0, 1, 2, 3]))
_nav_region.navigation_polygon = poly
add_child(_nav_region)
func _register_pathfinder(entity_id: int, pos: Vector2) -> void:
_ensure_nav_region()
var host := Node2D.new()
host.position = pos
add_child(host)
var agent := NavigationAgent2D.new()
agent.path_desired_distance = 8.0
agent.target_desired_distance = 8.0
agent.avoidance_enabled = false # 与 SoA Boid 分离力共存,避免双重避障
host.add_child(agent)
_pathfinders[entity_id] = {"agent": agent, "host": host}
_pathfinder_order.append(entity_id)
func _unregister_pathfinder(entity_id: int) -> void:
var pf: Dictionary = _pathfinders.get(entity_id, {})
if pf.is_empty():
return
(pf["host"] as Node).queue_free() # agent 是 host 子节点,随之释放
_pathfinders.erase(entity_id)
_pathfinder_order.erase(entity_id)
## 寻路精英移动:host 同步到 SoA 坐标,agent 朝玩家求下一路径点并转向
func _update_pathfinding_movement(delta: float) -> void:
var player_pos: Vector2 = PlayerManager.get_position()
for eid in _pathfinders:
var slot: int = _entity_index_map.get(eid, -1)
if slot < 0:
continue
var base: int = slot * ENEMY_STRIDE
var pos: Vector2 = Vector2(_data[base + 0], _data[base + 1])
var pf: Dictionary = _pathfinders[eid]
var agent: NavigationAgent2D = pf["agent"]
(pf["host"] as Node2D).global_position = pos
agent.target_position = player_pos
var nxt: Vector2 = agent.get_next_path_position()
var dir: Vector2 = nxt - pos
if dir.length() > 1.0:
dir = dir.normalized()
_data[base + 2] = dir.x * ELITE_SPEED
_data[base + 3] = dir.y * ELITE_SPEED
_data[base + 0] += _data[base + 2] * delta
_data[base + 1] += _data[base + 3] * delta
func get_pathfinder_count() -> int:
return _pathfinders.size()
func despawn_enemy(entity_id: int) -> void:
var slot: int = _entity_index_map.get(entity_id, -1)
if slot < 0:
return
StatusManager.remove_all_for_entity(entity_id)
SpatialGrid.remove_entity(entity_id)
if _pathfinders.has(entity_id):
_unregister_pathfinder(entity_id)
var last: int = _active_count - 1
if slot != last:
var base_slot: int = slot * ENEMY_STRIDE
var base_last: int = last * ENEMY_STRIDE
for s in ENEMY_STRIDE:
_data[base_slot + s] = _data[base_last + s]
var moved_id: int = _slot_entity_map[last]
_entity_index_map[moved_id] = slot
_slot_entity_map[slot] = moved_id
_entity_index_map.erase(entity_id)
_slot_entity_map.erase(last)
_active_count -= 1
## 获取指定 entity_id 的世界坐标(找不到返回 Vector2(-9999,-9999)
func get_pos_by_id(entity_id: int) -> Vector2:
var slot: int = _entity_index_map.get(entity_id, -1)
if slot < 0:
return Vector2(-9999.0, -9999.0)
var base: int = slot * ENEMY_STRIDE
return Vector2(_data[base + 0], _data[base + 1])
## S1 完整伤害接口:从 DamageContextPool 取 context,计算公式后扣 HP
## 公式(S1 简化):final_dmg = base_damage * mult
## S4 起补充 resistance / armor(参见 combat_mechanics_depth.md §4
func apply_damage_from_context(entity_id: int, ctx_id: int) -> bool:
var ctx: DamageContext = DamageContextPool.get_context(ctx_id)
if ctx == null:
return false
var final_dmg: float = ctx.calc_damage()
return apply_damage(entity_id, final_dmg, ctx.owner_id, true)
## 直接扣血接口;source_id 用于击杀归属,record_dps 控制是否计入 DPS
func apply_damage(entity_id: int, damage: float, source_id: int = -1, record_dps: bool = true) -> bool:
var slot: int = _entity_index_map.get(entity_id, -1)
if slot < 0:
return false
var combo_stacks: int = StatusManager.get_stacks(entity_id, StatusID.COMBO_MARK)
var combo_bonus: float = 1.0 + float(combo_stacks) * 0.02 # 每层 +2% 伤害
var base: int = slot * ENEMY_STRIDE
var armor: float = _ARMOR.get(int(_data[base + 6]), 0.0) # 原型护甲,扣减后保底 1
var final_dmg: float = maxf(1.0, damage * combo_bonus - armor)
_data[base + 4] -= final_dmg
if record_dps:
DpsTracker.record_damage(final_dmg)
if _data[base + 4] <= 0.0:
StatusManager.remove_all_for_entity(entity_id)
EventBus.emit(EventID.ENEMY_KILLED, {"entity_id": entity_id, "killer_id": source_id})
despawn_enemy(entity_id)
return true
return false
func has_entity(entity_id: int) -> bool:
return _entity_index_map.has(entity_id)
## 返回敌人血量百分比(权威来源:arch §5.2 get_hp_percent
func get_hp_percent(entity_id: int) -> float:
var slot: int = _entity_index_map.get(entity_id, -1)
if slot < 0:
return 0.0
var base: int = slot * ENEMY_STRIDE
var hp: float = _data[base + 4] # slot +4: hp
var hp_max: float = _data[base + 5] # slot +5: hp_max
return hp / max(hp_max, 0.001)
func get_active_count() -> int:
return _active_count
## 渲染:活跃敌人 SoA 位置 → MultiMesh(按原型设尺寸/颜色,Boss 大且醒目)
func sync_multimesh(mm: MultiMesh) -> void:
if mm == null:
return
var n: int = min(_active_count, mm.instance_count)
mm.visible_instance_count = n
for i in n:
var base: int = i * ENEMY_STRIDE
var etype: int = int(_data[base + 6])
var sz: float = _RENDER_SIZE.get(etype, 14.0)
mm.set_instance_transform_2d(i, Transform2D(
0.0, Vector2(sz, sz), 0.0,
Vector2(_data[base + 0], _data[base + 1])))
mm.set_instance_color(i, _RENDER_COLOR.get(etype, Color(0.9, 0.3, 0.3)))
## 返回最近敌人位置
func get_nearest_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
var best_sq: float = max_dist * max_dist
var best: Vector2 = origin
for i in _active_count:
var base: int = i * ENEMY_STRIDE
var dx: float = _data[base + 0] - origin.x
var dy: float = _data[base + 1] - origin.y
var dsq: float = dx * dx + dy * dy
if dsq < best_sq:
best_sq = dsq
best = Vector2(_data[base + 0], _data[base + 1])
return best
## 建立敌人位置快照(BulletManager homing 共用)
func fill_pos_snapshot(out_arr: PackedVector2Array) -> void:
out_arr.resize(_active_count)
for i in _active_count:
var base: int = i * ENEMY_STRIDE
out_arr[i] = Vector2(_data[base + 0], _data[base + 1])
## 同步 Node2D 显示位置(屏内 dirty sync
func sync_node_positions(enemy_nodes: Array) -> void:
for i in min(_active_count, enemy_nodes.size()):
if _visible_flags[i] == int(1):
var base: int = i * ENEMY_STRIDE
enemy_nodes[i].position = Vector2(_data[base + 0], _data[base + 1])
func reset() -> void:
_active_count = 0
_data.fill(0.0)
_visible_flags.fill(0)
_entity_index_map.clear()
_slot_entity_map.clear()
_next_entity_id = 1
# ADR-A4:释放所有寻路精英节点
for eid in _pathfinders.keys():
(_pathfinders[eid]["host"] as Node).queue_free()
_pathfinders.clear()
_pathfinder_order.clear()
+1
View File
@@ -0,0 +1 @@
uid://ctmujoxxqga04
+42
View File
@@ -0,0 +1,42 @@
## EventBus — 全局事件总线(Autoload: EventBus
## 权威接口定义:architecture_design.md §6.2
##
## 使用约定:
## - 订阅:EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
## - 发送:EventBus.emit(EventID.ENEMY_KILLED, {"entity_id": id})
## - 取消:EventBus.unsubscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
extends Node
var _listeners: Dictionary = {} # { event_id: int → Array[Callable] }
func subscribe(event_id: int, callback: Callable) -> void:
if not _listeners.has(event_id):
_listeners[event_id] = []
var arr: Array = _listeners[event_id]
if not arr.has(callback):
arr.append(callback)
func unsubscribe(event_id: int, callback: Callable) -> void:
if not _listeners.has(event_id):
return
_listeners[event_id].erase(callback)
func emit(event_id: int, payload: Dictionary = {}) -> void:
if not _listeners.has(event_id):
return
var arr: Array = _listeners[event_id]
# 逆序迭代防止回调内部 unsubscribe 导致跳过
for i in range(arr.size() - 1, -1, -1):
arr[i].call(payload)
## C# 批量通知接口:一次调用发送多个事件(ADR-L1 规则3)
## events: Array of [event_id: int, payload: Dictionary]
func emit_batch(events: Array) -> void:
for ev in events:
var eid: int = ev[0]
var pay: Dictionary = ev[1] if ev.size() > 1 else {}
emit(eid, pay)
## 清空所有订阅(关卡结束 / 场景切换时调用)
func reset() -> void:
_listeners.clear()
+1
View File
@@ -0,0 +1 @@
uid://c1aikk2kvjgiq
+49
View File
@@ -0,0 +1,49 @@
## EventID — 全局事件 ID 常量目录(Autoload: EventID
## 权威来源:implementation_plan.md §2.1
## 规则:新增事件从 22 起递增,并同步更新本文件。禁止硬编码整数替代常量。
extends Node
# ── 基础系统事件 (1-4) ────────────────────────────────────────────
const GAME_STARTED: int = 1
const GAME_OVER: int = 2
const GAME_PAUSED: int = 3
const GAME_RESUMED: int = 4
# ── 战斗事件 (5-10) ───────────────────────────────────────────────
const PLAYER_DAMAGED: int = 5
const PLAYER_DIED: int = 6
const ENEMY_KILLED: int = 7
const WAVE_COMPLETE: int = 8
const CHARGE_STATE_CHANGED: int = 9 # 供 UI 使用,非边沿信号
const ON_PLAYER_HURT: int = 10 # 受击反馈(VFX/音效触发)
# ── 法术事件 (11-15) ──────────────────────────────────────────────
const SPELL_CAST_BEGIN: int = 11
const SPELL_CAST_END: int = 12
const DASH_TRIGGERED: int = 13 # 冲刺边沿检测(P6-N36
const CHARGE_FIRED: int = 14 # 蓄力完成一次性边沿(P6-N69);SpellEvaluator 订阅此而非 CHARGE_STATE_CHANGED
const SPELL_EQUIPPED: int = 15
# ── 进度 / UI 事件 (16-21) ────────────────────────────────────────
const LEVEL_UP: int = 16
const SHOP_OPENED: int = 17
const ACHIEVEMENT_UNLOCKED: int = 18 # 负载:{ "achievement_id": String }ST-10 Steam 成就钩子
const BOSS_SPAWNED: int = 19
const GAME_STATE_CHANGED: int = 20 # 负载:{ "from": String, "to": String }
const SPELL_DROP_PICKUP: int = 21 # 负载:{ "spell_id": String, "pos": Vector2 }
# ── S1 新增事件 (22+) ─────────────────────────────────────────────
const APPLY_DAMAGE: int = 22 # 负载:damage_context_id: intEnemyManager 订阅处理伤害
# ── S3 新增事件 (23+) ─────────────────────────────────────────────
const SPELL_DEPTH_EXCEEDED: int = 23 # 负载:{caster_id, depth}HUD 订阅显示警告
const BULLET_HIT_TRIGGER: int = 24 # 负载:{bullet_id, payload_id, hit_pos};调试用
# ── S4 新增事件 (25+) ─────────────────────────────────────────────
const STATUS_APPLIED: int = 25 # 负载:{target_id, status_type, stacks}
const BULLET_HIT: int = 26 # 负载:{bullet_id, target_id, hit_pos}
# ── S5 新增事件 (27+) ─────────────────────────────────────────────
const MINION_SPAWNED: int = 27 # 负载:{minion_id, owner_id, current_count}
const MINION_EXPIRED: int = 28 # 负载:{minion_id, owner_id, current_count}
# 每新增一条须同步更新 implementation_plan.md §2.1 事件目录表
+1
View File
@@ -0,0 +1 @@
uid://chtsfdmd0ykor
+33
View File
@@ -0,0 +1,33 @@
## LocaleManager — 本地化加载(Autoload: Locale
## 权威来源:architecture_design.md ADR-A1tr("KEY") + .po + TranslationServer
## 加载 translations/*.po 并注册到 TranslationServer,设默认语言。
## 商业发行至少支持 简中 / 繁中 / 英 / 日 四语(P-S6-06)。
extends Node
const LOCALES: Array = ["zh_CN", "zh_TW", "en", "ja"]
const DEFAULT_LOCALE: String = "zh_CN"
func _ready() -> void:
for loc in LOCALES:
var path: String = "res://translations/%s.po" % loc
if ResourceLoader.exists(path):
var t = load(path)
if t is Translation:
TranslationServer.add_translation(t)
TranslationServer.set_locale(DEFAULT_LOCALE)
func set_locale(loc: String) -> void:
TranslationServer.set_locale(loc)
func get_locale() -> String:
return TranslationServer.get_locale()
## 调试 / 设置菜单用:循环切换语言
func cycle_locale() -> String:
var cur: String = TranslationServer.get_locale()
var idx: int = LOCALES.find(cur)
if idx < 0:
idx = 0
var nxt: String = LOCALES[(idx + 1) % LOCALES.size()]
TranslationServer.set_locale(nxt)
return nxt
+1
View File
@@ -0,0 +1 @@
uid://dpk1b28i6qybx
+103
View File
@@ -0,0 +1,103 @@
## MinionManager — 玩家召唤物管理(Autoload: MinionManager
## 权威来源:architecture_design.md §5.12 + ADR-R4-N4(独立管理器,方案 B)
## 数量上限小(MAX_MINIONS=20ADR-R4-N4/plan S5),不需 C# 热路径,用 Array[Dictionary]
## 炮台型(Stationary)AI:定点对范围内最近敌人开火
##
## 注:plan S5 提到 "SoA stride=8",但 §5.12 权威实现明确用 Array[Dictionary]
## 且 "不需要 C# 热路径",故按 §5.12 实现;SoA 化为后续可选优化。
extends Node
const MAX_MINIONS: int = 20 # ADR-R4-N4:同场最大召唤物,超出 FIFO 顶替最旧
const DEFAULT_LIFETIME: float = 30.0
## 活跃召唤物:{ id, owner_id, pos, lifetime_rem, range, fire_interval, fire_cd,
## damage, bullet_speed, bullet_radius, bullet_lifetime, damage_type }
var _minions: Array = []
var _next_id: int = 0
## def: Dictionary(项目无 .tres,用字典描述召唤物面板)
## 可选键:lifetime, range, fire_interval, damage, bullet_speed, bullet_radius,
## bullet_lifetime, damage_type
func spawn_minion(def: Dictionary, owner_id: int, pos: Vector2) -> int:
if _minions.size() >= MAX_MINIONS:
_expire(_minions[0]["id"]) # FIFO:顶掉最旧
var lifetime: float = float(def.get("lifetime", DEFAULT_LIFETIME))
if lifetime <= 0.0:
lifetime = DEFAULT_LIFETIME
var id: int = _next_id
_next_id = (_next_id + 1) % 100000
var entry: Dictionary = {
"id": id,
"owner_id": owner_id,
"pos": pos,
"lifetime_rem": lifetime,
"range": float(def.get("range", 350.0)),
"fire_interval": maxf(0.05, float(def.get("fire_interval", 0.8))),
"fire_cd": 0.0,
"damage": float(def.get("damage", 4.0)),
"bullet_speed": float(def.get("bullet_speed", 360.0)),
"bullet_radius": float(def.get("bullet_radius", 5.0)),
"bullet_lifetime": float(def.get("bullet_lifetime", 2.0)),
"damage_type": int(def.get("damage_type", 0)),
}
_minions.append(entry)
EventBus.emit(EventID.MINION_SPAWNED, {
"minion_id": id, "owner_id": owner_id, "current_count": _minions.size(),
})
return id
func _physics_process(delta: float) -> void:
var i: int = 0
while i < _minions.size():
var m: Dictionary = _minions[i]
m["lifetime_rem"] -= delta
if m["lifetime_rem"] <= 0.0:
_expire(m["id"])
continue # 不自增 i:当前槽已被 remove_at 后移的元素填补
# 炮台 AI:冷却到点且范围内有敌人则朝最近敌人开火
m["fire_cd"] -= delta
if m["fire_cd"] <= 0.0:
var pos: Vector2 = m["pos"]
var rng: float = m["range"]
if SpatialGrid.query_circle(pos, rng).size() > 0:
var tgt: Vector2 = EnemyManager.get_nearest_pos(pos, rng)
var dir: Vector2 = (tgt - pos)
dir = dir.normalized() if dir.length() > 0.001 else Vector2.RIGHT
BulletManager.spawn_bullet(
pos, dir * m["bullet_speed"], m["bullet_lifetime"],
m["bullet_radius"], m["damage"], 1.0, m["damage_type"],
m["owner_id"], 0, 0.0, {})
m["fire_cd"] = m["fire_interval"]
i += 1
func _expire(minion_id: int) -> void:
for i in _minions.size():
if _minions[i]["id"] == minion_id:
var owner: int = _minions[i]["owner_id"]
_minions.remove_at(i)
EventBus.emit(EventID.MINION_EXPIRED, {
"minion_id": minion_id, "owner_id": owner, "current_count": _minions.size(),
})
return
func recall_all(owner_id: int = -1) -> void:
# owner_id<0 → 全部召回(Run 结束);否则仅该 owner
var ids: Array = []
for m in _minions:
if owner_id < 0 or m["owner_id"] == owner_id:
ids.append(m["id"])
for mid in ids:
_expire(mid)
func get_count(owner_id: int = -1) -> int:
if owner_id < 0:
return _minions.size()
var c: int = 0
for m in _minions:
if m["owner_id"] == owner_id:
c += 1
return c
func reset() -> void:
_minions.clear()
_next_id = 0
+1
View File
@@ -0,0 +1 @@
uid://dkjoahbv8qtwl
+40
View File
@@ -0,0 +1,40 @@
## ObjectPool — 通用对象池(Autoload: ObjectPool
## 权威来源:implementation_plan.md §2.1 Layer-0
##
## 使用约定:
## 1. 被池化对象必须实现 reset() 方法 (签名:func reset() -> void
## 2. 取出:pool.acquire() → 返回对象(内部已调 reset())
## 3. 归还:pool.release(obj)
extends Node
## 创建一个指定工厂和容量的对象池
func create_pool(capacity: int, factory: Callable) -> PoolInstance:
var p := PoolInstance.new()
p._factory = factory
for _i in capacity:
p._stack.append(factory.call())
return p
# ── PoolInstance 内部类(业务系统持有实例) ──────────────────────────────
class PoolInstance:
var _factory: Callable
var _stack: Array = []
func acquire() -> Object:
var obj: Object
if _stack.is_empty():
obj = _factory.call()
else:
obj = _stack.pop_back()
if obj.has_method("reset"):
obj.reset()
return obj
func release(obj: Object) -> void:
_stack.append(obj)
func get_available_count() -> int:
return _stack.size()
func drain() -> void:
_stack.clear()
+1
View File
@@ -0,0 +1 @@
uid://b2ekg7pdggafm
+89
View File
@@ -0,0 +1,89 @@
## PlayerStats — 玩家属性统计(Autoload: PlayerStats
## 权威来源:development_plan.md S2、numerical_design.md §1.2
extends Node
signal stats_changed
signal leveled_up(new_level: int)
# ── HP ───────────────────────────────────────────────
var hp: float = 100.0
var hp_max: float = 100.0
# ── 资源 ──────────────────────────────────────────────
var gold: int = 0
var xp: int = 0
# ── 等级 ──────────────────────────────────────────────
var level: int = 1
var xp_to_next: int = 10 # 升级所需 XP
# ── 战斗属性 ───────────────────────────────────────────
var cpu_limit: int = 5 # 控制 MAX_OPS = cpu_limit * 40
var armor: float = 0.0
var resistance: float = 0.0 # 0.0~1.0
func _ready() -> void:
EventBus.subscribe(EventID.PLAYER_DAMAGED, _on_player_damaged)
## 升级曲线:roundf(10 × 1.4^(level-1))P6-N16
## Level 1→2: 10 XP; Level 5→6: ≈ 54 XP
static func xp_for_level(lv: int) -> int:
return roundi(10.0 * pow(1.4, float(lv - 1)))
func gain_xp(amount: int) -> void:
xp += amount
while xp >= xp_to_next:
xp -= xp_to_next
level += 1
xp_to_next = xp_for_level(level)
leveled_up.emit(level)
EventBus.emit(EventID.LEVEL_UP, {"level": level})
stats_changed.emit()
func gain_gold(amount: int) -> void:
gold += amount
stats_changed.emit()
func spend_gold(amount: int) -> bool:
if gold < amount:
return false
gold -= amount
stats_changed.emit()
return true
func take_damage(amount: float) -> void:
hp = max(0.0, hp - amount * SettingsManager.player_dmg_taken_mult()) # 难度减伤(初学者×0.7
stats_changed.emit()
if hp <= 0.0:
EventBus.emit(EventID.PLAYER_DIED, {})
func heal(amount: float) -> void:
hp = min(hp_max, hp + amount)
stats_changed.emit()
func get_hp_percent() -> float:
return hp / max(hp_max, 0.001)
func _on_player_damaged(payload: Dictionary) -> void:
take_damage(float(payload.get("damage", 0.0)))
func reset_for_run() -> void:
hp = hp_max
gold = 0
xp = 0
level = 1
xp_to_next = xp_for_level(1)
stats_changed.emit()
func get_save_data() -> Dictionary:
return {"hp": hp, "hp_max": hp_max, "gold": gold, "xp": xp, "level": level, "cpu_limit": cpu_limit}
func load_save_data(data: Dictionary) -> void:
hp = float(data.get("hp", 100.0))
hp_max = float(data.get("hp_max", 100.0))
gold = int(data.get("gold", 0))
xp = int(data.get("xp", 0))
level = int(data.get("level", 1))
cpu_limit = int(data.get("cpu_limit", 5))
xp_to_next = xp_for_level(level)
stats_changed.emit()
+1
View File
@@ -0,0 +1 @@
uid://dl4lp1m675v82
+118
View File
@@ -0,0 +1,118 @@
## ProfileManager — 局内 Run 存档 A/B 双槽(Autoload: ProfileManager
## 权威来源:architecture_design.md ADR-A2
## 下面实现了:
## - A/B 双槽交替写入防崩溃
## - schema_version 字段必须
## - 所有读取必须经过 _migrate_run()
## - NOTIFICATION_WM_CLOSE_REQUEST 尽力写最后一笔
extends Node
const SCHEMA_VERSION: int = 2 # v2:新增 wandCore/deck/bench)持久化
const PATH_A: String = "user://run_a.json"
const PATH_B: String = "user://run_b.json"
var _current_slot: String = PATH_A # 上次写入的槽
var _dirty: bool = false
## 法杖状态提供者(CombatManager 在 _ready 注册);须有 get_wand_save_data/apply_wand_save_data
var _wand_provider: Object = null
func set_wand_provider(p: Object) -> void:
_wand_provider = p
func _ready() -> void:
get_tree().root.connect("close_requested", _on_close_request)
func _on_close_request() -> void:
if _dirty:
save_run()
_dirty = false
## 写入当前 Run 状态(交替槽位)
func save_run() -> void:
var data: Dictionary = _collect_run_data()
data["schema_version"] = SCHEMA_VERSION
data["saved_at"] = Time.get_unix_time_from_system()
var json_str: String = JSON.stringify(data)
var target: String = _next_slot()
var file := FileAccess.open(target, FileAccess.WRITE)
if file:
file.store_string(json_str)
file.close()
_current_slot = target
_dirty = false
## 加载 Run,自动选择未损坏的最新槽
func load_run() -> Dictionary:
var data_a: Dictionary = _read_slot(PATH_A)
var data_b: Dictionary = _read_slot(PATH_B)
var best: Dictionary
if data_a.is_empty() and data_b.is_empty():
return {}
elif data_a.is_empty():
best = data_b
elif data_b.is_empty():
best = data_a
else:
var ta: int = int(data_a.get("saved_at", 0))
var tb: int = int(data_b.get("saved_at", 0))
best = data_a if ta > tb else data_b
return _migrate_run(best)
## 应用局内状态
func apply_run(data: Dictionary) -> void:
if data.is_empty():
return
PlayerStats.load_save_data(data.get("player_stats", {}))
WaveManager.current_wave = int(data.get("wave_num", 0))
var shop_seed: int = int(data.get("shop_seed", 0))
if shop_seed > 0:
ShopManager._shop_seed = shop_seed
# 恢复法杖(Core/deck/bench
if data.has("wand") and is_instance_valid(_wand_provider) and _wand_provider.has_method("apply_wand_save_data"):
_wand_provider.apply_wand_save_data(data.get("wand", {}))
## 清除 Run(死亡 / 新开局)
func clear_run() -> void:
for path in [PATH_A, PATH_B]:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
_dirty = false
func mark_dirty() -> void:
_dirty = true
func _next_slot() -> String:
return PATH_B if _current_slot == PATH_A else PATH_A
func _collect_run_data() -> Dictionary:
var d: Dictionary = {
"wave_num": WaveManager.current_wave,
"shop_seed": ShopManager.get_shop_seed(),
"player_stats": PlayerStats.get_save_data(),
}
if is_instance_valid(_wand_provider) and _wand_provider.has_method("get_wand_save_data"):
d["wand"] = _wand_provider.get_wand_save_data()
return d
func _read_slot(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var text: String = FileAccess.get_file_as_string(path)
if text.is_empty():
return {}
var parsed = JSON.parse_string(text)
if parsed is Dictionary:
return parsed
return {}
## 迁移旧存档
func _migrate_run(data: Dictionary) -> Dictionary:
var ver: int = int(data.get("schema_version", 0))
if ver < 1:
# v0→v1: 无破坏性字段变更
data["schema_version"] = 1
if ver < 2:
# v1→v2: 旧档无 wand 字段;apply_run 检测缺失即保留默认法杖,无需补字段
data["schema_version"] = 2
return data
+1
View File
@@ -0,0 +1 @@
uid://bix3rk2gyfmvu
+93
View File
@@ -0,0 +1,93 @@
## SceneManager — 场景路由 + 黑幕淡入淡出(Autoload: SceneManager
## 所有场景跳转必须经过此单例,保证转场动画一致。
extends Node
const FADE_DURATION: float = 0.35
## 下一次加载游戏场景时的启动模式
## "new"=新游戏 "continue"=读档继续 "endless"=无尽模式
var start_mode: String = "new"
var is_transitioning: bool = false
var _overlay: ColorRect = null
var _tween: Tween = null
# 场景路径注册表
const SCENES: Dictionary = {
"splash": "res://scenes/ui/splash.tscn",
"main_menu": "res://scenes/ui/main_menu.tscn",
"game": "res://scenes/main/combat_s2.tscn",
}
func _ready() -> void:
# 转场需在暂停态下也能运行(从暂停菜单返回主菜单等)
process_mode = Node.PROCESS_MODE_ALWAYS
# 全屏黑幕覆盖层(始终在最顶层)
var cl := CanvasLayer.new()
cl.layer = 127
add_child(cl)
_overlay = ColorRect.new()
_overlay.color = Color.BLACK
_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
cl.add_child(_overlay)
_overlay.modulate.a = 1.0 # 从黑屏开始,首屏淡入
func _fade_in(on_done: Callable = Callable()) -> void:
if _tween:
_tween.kill()
_tween = create_tween()
_tween.tween_property(_overlay, "modulate:a", 0.0, FADE_DURATION)
if not on_done.is_null():
_tween.tween_callback(on_done)
func _fade_out(on_done: Callable = Callable()) -> void:
if _tween:
_tween.kill()
_tween = create_tween()
_tween.tween_property(_overlay, "modulate:a", 1.0, FADE_DURATION)
if not on_done.is_null():
_tween.tween_callback(on_done)
## 通用跳转(不建议直接调用,优先用下方语义方法)
func goto(scene_id: String) -> void:
if is_transitioning:
return
is_transitioning = true
_fade_out(func():
get_tree().change_scene_to_file(SCENES[scene_id])
call_deferred("_after_scene_load")
)
func _after_scene_load() -> void:
_fade_in(func(): is_transitioning = false)
## ── 语义跳转方法 ────────────────────────────────────────────
func goto_splash() -> void:
goto("splash")
func goto_main_menu() -> void:
goto("main_menu")
## 启动新游戏(从头开始)
func start_new_game() -> void:
start_mode = "new"
goto("game")
## 继续上局存档
func continue_game() -> void:
start_mode = "continue"
goto("game")
## 初始淡入(场景 _ready 调用)。经 goto 进入时由 _after_scene_load 独占淡入,
## 此处跳过以免 kill 掉带"复位 is_transitioning"回调的转场 Tween(仅单独启动场景时淡入)。
func fade_in_first() -> void:
if is_transitioning:
return
_fade_in()
## 当前是否有可继续的存档
func has_save() -> bool:
return FileAccess.file_exists(ProfileManager.PATH_A) or \
FileAccess.file_exists(ProfileManager.PATH_B)
+1
View File
@@ -0,0 +1 @@
uid://broymfifhvw2n
+149
View File
@@ -0,0 +1,149 @@
## SettingsManager — 全局设置 + 难度(Autoload: SettingsManager
## 权威来源:development_plan.md S6 P1(难度三档持久化)、certification_checklist.md ST-03(清除本地数据)
## 持久化 user://save_data.json{ difficulty:int, locale:String, master_volume:float }
extends Node
const SAVE_PATH: String = "user://save_data.json"
enum Difficulty { BEGINNER = 0, STANDARD = 1, CHALLENGE = 2 }
var difficulty: int = Difficulty.STANDARD
var locale: String = "zh_CN"
var master_volume: float = 1.0
var ftue_done: bool = false # 首次启动难度引导是否已完成(FTUE)
func _ready() -> void:
_load()
_load_balance()
_apply_audio()
if Locale:
Locale.set_locale(locale)
## 是否需要首启难度引导(FTUE
func needs_ftue() -> bool:
return not ftue_done
## 完成 FTUE:设定难度并持久化标志
func complete_ftue(chosen_difficulty: int) -> void:
difficulty = clampi(chosen_difficulty, 0, 2)
ftue_done = true
_save()
## 难度乘子 + Boss 血量:纯数据驱动,唯一来源 data/balance.json(游戏设计器「平衡」面板维护)
## get() 的默认仅为防崩溃,非内容副本
var _MULTS: Dictionary = {} # key → [初学者, 标准, 挑战]
var _BOSS_HP: Dictionary = {} # 原型→基础血量
const BALANCE_JSON: String = "res://data/balance.json"
func _mult(key: String) -> float:
var arr: Array = _MULTS.get(key, [1.0, 1.0, 1.0])
return float(arr[clampi(difficulty, 0, arr.size() - 1)])
func enemy_hp_mult() -> float: return _mult("enemy_hp")
func player_dmg_taken_mult() -> float: return _mult("player_dmg")
func wave_count_mult() -> float: return _mult("wave_count")
func boss_hp_mult() -> float: return _mult("boss_hp")
## Boss 基础血量(WaveManager._spawn_boss 查询;balance.json 可调)
func get_boss_base_hp(boss_type: int) -> float:
return float(_BOSS_HP.get(str(boss_type), 500.0))
## 数据驱动:加载 balance.json(难度乘子 + Boss 血量)
func _load_balance() -> void:
if not FileAccess.file_exists(BALANCE_JSON):
push_error("SettingsManager: 缺失 res://data/balance.json(难度乘子/Boss血量)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(BALANCE_JSON))
if not (data is Dictionary):
push_error("SettingsManager: balance.json 格式错误")
return
if data.has("difficulty_mults") and data["difficulty_mults"] is Dictionary:
for k in data["difficulty_mults"]:
_MULTS[k] = data["difficulty_mults"][k]
if data.has("boss_hp") and data["boss_hp"] is Dictionary:
_BOSS_HP = data["boss_hp"]
func difficulty_name() -> String:
return ["DIFF_BEGINNER", "DIFF_STANDARD", "DIFF_CHALLENGE"][difficulty]
# ── 设置变更(即时保存)──────────────────────────────────────
func set_difficulty(d: int) -> void:
difficulty = clampi(d, 0, 2)
_save()
func cycle_difficulty() -> int:
set_difficulty((difficulty + 1) % 3)
return difficulty
func set_locale(loc: String) -> void:
locale = loc
if Locale:
Locale.set_locale(loc)
_save()
func set_master_volume(v: float) -> void:
master_volume = clampf(v, 0.0, 1.0)
_apply_audio()
_save()
func _apply_audio() -> void:
var bus: int = AudioServer.get_bus_index("Master")
if bus >= 0:
AudioServer.set_bus_volume_db(bus, linear_to_db(maxf(0.0001, master_volume)))
# ── ST-03:清除所有本地数据 ─────────────────────────────────
func clear_all_local_data() -> void:
var files: Array = [
"user://crash_log.txt", "user://crash_log_prev.txt",
"user://run_a.json", "user://run_b.json",
"user://endless_records.json", "user://save_data.json",
]
for path in files:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
_remove_dir_recursive("user://runs")
# 重置内存态
difficulty = Difficulty.STANDARD
master_volume = 1.0
ftue_done = false # 清数据后重新触发首启难度引导
# 用路径引用避免编译时 autoload 依赖(打破 SettingsManager→ProfileManager→WaveManager→SettingsManager 循环)
var pm: Node = get_node_or_null("/root/ProfileManager")
if pm and pm.has_method("clear_run"):
pm.clear_run()
_apply_audio()
func _remove_dir_recursive(path: String) -> void:
var d := DirAccess.open(path)
if d == null:
return
d.list_dir_begin()
var fn := d.get_next()
while fn != "":
if not d.current_is_dir():
DirAccess.remove_absolute(ProjectSettings.globalize_path(path + "/" + fn))
fn = d.get_next()
d.list_dir_end()
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
# ── 持久化 ──────────────────────────────────────────────────
func _save() -> void:
var data: Dictionary = {
"difficulty": difficulty, "locale": locale,
"master_volume": master_volume, "ftue_done": ftue_done,
}
var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(data))
f.close()
func _load() -> void:
if not FileAccess.file_exists(SAVE_PATH):
return
var parsed = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH))
if not (parsed is Dictionary):
return
difficulty = clampi(int(parsed.get("difficulty", 1)), 0, 2)
locale = String(parsed.get("locale", "zh_CN"))
master_volume = clampf(float(parsed.get("master_volume", 1.0)), 0.0, 1.0)
ftue_done = bool(parsed.get("ftue_done", false))
@@ -0,0 +1 @@
uid://dvs14n88hmy06
+86
View File
@@ -0,0 +1,86 @@
## ShopManager — 商店管理(Autoload: ShopManager
## S2 范围:3 选 1 法术、金币扣减、刷新费用公式(P6-N23)
## 权威来源:development_plan.md S2、numerical_design.md §2.3
extends Node
signal shop_refreshed
signal shop_closed
const SLOT_COUNT: int = 3
const REROLL_BASE_COST: int = 20 # 第 1 次刷新费用
const REROLL_STEP: int = 10 # 每次递增价格
var current_slots: Array = [] # Array[SpellNode] null 表示已售出)
var reroll_count: int = 0 # 本波已刷新次数(WAVE_COMPLETE 后重置)
var _shop_seed: int = 0 # 随机种(ADR-A2 P-S2-07
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
func _ready() -> void:
EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete)
func _on_wave_complete(_payload: Dictionary) -> void:
reroll_count = 0 # P6-N23:波次结算后重置刷新次数
func open_shop(seed_override: int = -1) -> void:
if seed_override >= 0:
_shop_seed = seed_override
else:
_shop_seed = randi()
_rng.seed = _shop_seed
_refresh_slots()
EventBus.emit(EventID.SHOP_OPENED, {"seed": _shop_seed})
func _refresh_slots() -> void:
# 纯数据驱动池:SpellRegistrydata/spells.json)中可购买(shop_cost>0)的法术
var pool: Array = SpellRegistry.get_all_ids().filter(func(id):
var s: SpellNode = SpellRegistry.get_spell(id)
return s != null and int(s.meta.get("shop_cost", 0)) > 0)
current_slots.clear()
for _i in SLOT_COUNT:
if pool.is_empty():
current_slots.append(null)
continue
var idx: int = _rng.randi_range(0, pool.size() - 1)
current_slots.append(SpellRegistry.get_spell(pool[idx]))
pool.remove_at(idx)
shop_refreshed.emit()
## 购买指定槽位的法术,返回购买的 SpellNode、失败返回 null
func buy_spell(slot_idx: int) -> SpellNode:
if slot_idx < 0 or slot_idx >= current_slots.size():
return null
var spell: SpellNode = current_slots[slot_idx]
if spell == null:
return null
var cost: int = int(spell.meta.get("shop_cost", 20))
if not PlayerStats.spend_gold(cost):
return null
current_slots[slot_idx] = null
shop_refreshed.emit()
return spell
## 刷新商店,返回 true 表示成功
func reroll() -> bool:
var cost: int = get_reroll_cost()
if not PlayerStats.spend_gold(cost):
return false
reroll_count += 1
_rng.seed = _shop_seed + reroll_count * 31337
_refresh_slots()
return true
## 刷新费用公式:base + step * reroll_countP6-N23
func get_reroll_cost() -> int:
return REROLL_BASE_COST + reroll_count * REROLL_STEP
func close_shop() -> void:
current_slots.clear()
shop_closed.emit()
func get_shop_seed() -> int:
return _shop_seed
func reset() -> void:
current_slots.clear()
reroll_count = 0
_shop_seed = 0
+1
View File
@@ -0,0 +1 @@
uid://cu5ace51jtmf4
+75
View File
@@ -0,0 +1,75 @@
## SpatialGrid — 2D 空间哈希网格 AutoloadGDScript 外壳)
## 热路径由 C# SpatialGridCs 子节点执行(就绪后接管)
## 权威来源:architecture_design.md §4.3
extends Node
const CELL_SIZE: int = 64 # 格子大小(像素)
const GRID_COLS: int = 128 # 128×128 格 × 64px = 8192×8192 覆盖范围
const GRID_ROWS: int = 128
const GRID_OFFSET: int = 4096 # 将世界中心映射到网格中心(支持负坐标)
var _cs_node: Node = null # SpatialGridCs C# 子节点(就绪后挂载)
## GDScript 回退网格
var _grid: Array = [] # Array[Array[int]]
var _entity_cell: Dictionary = {} # { entity_id → cell_idx }
func _ready() -> void:
_grid.resize(GRID_COLS * GRID_ROWS)
for i in _grid.size():
_grid[i] = []
## 插入或更新实体位置(由 EnemyManager 每物理帧调用)
func insert(entity_id: int, pos: Vector2) -> void:
if _cs_node and _cs_node.has_method("Insert"):
_cs_node.call("Insert", entity_id, pos)
return
var cx: int = clamp(int((pos.x + GRID_OFFSET) / CELL_SIZE), 0, GRID_COLS - 1)
var cy: int = clamp(int((pos.y + GRID_OFFSET) / CELL_SIZE), 0, GRID_ROWS - 1)
var cell: int = cx + cy * GRID_COLS
var old_cell: int = _entity_cell.get(entity_id, -1)
if old_cell == cell:
return
if old_cell >= 0:
_grid[old_cell].erase(entity_id)
_grid[cell].append(entity_id)
_entity_cell[entity_id] = cell
## 查询圆形范围内的所有实体 ID
func query_circle(center: Vector2, radius: float) -> PackedInt32Array:
if _cs_node and _cs_node.has_method("QueryCircle"):
return _cs_node.call("QueryCircle", center, radius)
var result := PackedInt32Array()
var min_cx: int = clamp(int((center.x - radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_COLS - 1)
var max_cx: int = clamp(int((center.x + radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_COLS - 1)
var min_cy: int = clamp(int((center.y - radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_ROWS - 1)
var max_cy: int = clamp(int((center.y + radius + GRID_OFFSET) / CELL_SIZE), 0, GRID_ROWS - 1)
for cy in range(min_cy, max_cy + 1):
for cx in range(min_cx, max_cx + 1):
for eid in _grid[cx + cy * GRID_COLS]:
result.append(eid)
return result
## 每帧由 EnemyManager._physics_process 驱动(dirty-list 方案)
func rebuild() -> void:
if _cs_node and _cs_node.has_method("Rebuild"):
_cs_node.call("Rebuild")
## 移除实体
func remove_entity(entity_id: int) -> void:
if _cs_node and _cs_node.has_method("Remove"):
_cs_node.call("Remove", entity_id)
return
var old_cell: int = _entity_cell.get(entity_id, -1)
if old_cell >= 0:
_grid[old_cell].erase(entity_id)
_entity_cell.erase(entity_id)
## 清空(关卡重置)
func clear() -> void:
if _cs_node and _cs_node.has_method("Clear"):
_cs_node.call("Clear")
return
for cell_list in _grid:
(cell_list as Array).clear()
_entity_cell.clear()
+1
View File
@@ -0,0 +1 @@
uid://x822ya5c7tlk
+54
View File
@@ -0,0 +1,54 @@
## SpellContextPool — SpellContext 对象池(Autoload: SpellContextPool
## 权威来源:implementation_plan.md §2.1、S1 验收 P-S1-04
extends Node
const POOL_SIZE: int = 32 # P-S1-04: pool size 不超过 32
var _pool: Array = [] # Array[SpellContext]
var _in_use: PackedByteArray = PackedByteArray()
var _slots: Array = [] # Array[SpellContext] 固定槽位
func _ready() -> void:
_slots.resize(POOL_SIZE)
_in_use.resize(POOL_SIZE)
for i in POOL_SIZE:
_slots[i] = SpellContext.new()
_in_use[i] = 0
_pool.append(i) # 空闲 ID 表
## 取出一个 SpellContext。若 has_persistent_memory=true 则保留 registers
func acquire(caster_id: int, has_persistent_memory: bool = false) -> SpellContext:
if _pool.is_empty():
push_warning("SpellContextPool: 池已耗尽,动态创建 SpellContext(建议上调 POOL_SIZE=%d" % POOL_SIZE)
var ctx_dyn := SpellContext.new()
ctx_dyn.caster_id = caster_id
if not has_persistent_memory:
ctx_dyn.clear_registers()
return ctx_dyn
var slot_id: int = _pool[_pool.size() - 1]
_pool.resize(_pool.size() - 1)
var ctx: SpellContext = _slots[slot_id]
_in_use[slot_id] = int(1)
ctx.reset()
ctx.caster_id = caster_id
if not has_persistent_memory:
ctx.clear_registers()
return ctx
func release(ctx: SpellContext) -> void:
for i in _slots.size():
if _slots[i] == ctx:
if _in_use[i] != 0:
_in_use[i] = 0
_pool.append(i)
return
# 动态分配的(池已耗尽时)直接丢弃
func get_available_count() -> int:
return _pool.size()
func reset() -> void:
_pool.clear()
for i in _slots.size():
_in_use[i] = 0
_pool.append(i)
@@ -0,0 +1 @@
uid://dgqs614fymhqe
+46
View File
@@ -0,0 +1,46 @@
## SpellRegistry — 法术注册表(Autoload: SpellRegistry
## 纯数据驱动:唯一来源 res://data/spells.json(游戏设计器「法术」面板维护)
## 无硬编码回退——法术定义只存在于 JSON。
extends Node
const JSON_DATA_PATH: String = "res://data/spells.json"
var _registry: Dictionary = {} # { spell_id: String → SpellNode }
func _ready() -> void:
if not FileAccess.file_exists(JSON_DATA_PATH):
push_error("SpellRegistry: 缺失 res://data/spells.json(无任何法术定义)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(JSON_DATA_PATH))
if not (data is Dictionary):
push_error("SpellRegistry: spells.json 格式错误(应为对象)")
return
for sid in data:
_registry[String(sid)] = _spell_from_dict(String(sid), data[sid])
func _spell_from_dict(sid: String, d: Dictionary) -> SpellNode:
var n := SpellNode.new()
n.id = sid
n.type = int(d.get("type", 0))
n.display_name = String(d.get("display_name", sid))
n.description = String(d.get("description", ""))
n.element_tags = d.get("element_tags", [])
n.meta = d.get("meta", {})
return n
## 运行时动态注册(如共鸣产出、未来扩展),非数据来源
func register_spell(spell: SpellNode) -> void:
if spell and spell.id != "":
_registry[spell.id] = spell
func get_spell(id: String) -> SpellNode:
return _registry.get(id, null)
func get_all_ids() -> Array:
return _registry.keys()
func has_spell(id: String) -> bool:
return _registry.has(id)
func get_registry_size() -> int:
return _registry.size()
+1
View File
@@ -0,0 +1 @@
uid://dbdjccwuktibn
+14
View File
@@ -0,0 +1,14 @@
## StatusID — 状态效果整数 IDAutoload: StatusID
## 规则:禁止使用 StatusType.XXX 枚举(跨切片约束 P6-N30),必须用本常量。
## 新增状态从 9 起递增,不得复用或跳号。
extends Node
const BURN: int = 1
const FREEZE: int = 2
const POISON: int = 3
const WET: int = 4
const OILY: int = 5
const STUN: int = 6
const COMBO_MARK: int = 7 # 连击叠加(consecutive_hits_stacking.md
const VULNERABILITY: int = 8 # 易伤标记
# 新增从 9 起
+1
View File
@@ -0,0 +1 @@
uid://dke3xnu50nshg
+122
View File
@@ -0,0 +1,122 @@
## StatusManager — 状态效果管理(Autoload: StatusManager
## 权威来源:combat_mechanics_depth.md §3.2.5、implementation_plan.md §2.4
extends Node
const STATUS_BATCH_LIMIT: int = 500
var _active_statuses: Array = [] # Array[StatusInstance]
var _physics_frame: int = 0
func _physics_process(delta: float) -> void:
_physics_frame += 1
var use_batch: bool = _active_statuses.size() > STATUS_BATCH_LIMIT
var batch_parity: int = _physics_frame % 2
var i: int = _active_statuses.size() - 1
while i >= 0:
var inst: StatusInstance = _active_statuses[i]
if use_batch and (inst.entity_id % 2) != batch_parity:
i -= 1
continue
inst.remaining_duration -= delta
inst.tick_accumulator += delta
if inst.type_def and not inst.type_def.is_combo_tracker and inst.type_def.dot_damage_per_tick > 0.0:
var interval: float = max(0.001, inst.type_def.tick_interval)
while inst.tick_accumulator >= interval:
inst.tick_accumulator -= interval
_apply_dot_tick(inst)
if inst.remaining_duration <= 0.0:
_active_statuses[i] = _active_statuses[_active_statuses.size() - 1]
_active_statuses.pop_back()
i -= 1
## 施加状态;duration < 0 时使用 StatusTypeDef 默认 duration
func apply(entity_id: int, status_type_id: int, stacks: int = 1, duration: float = -1.0, owner_id: int = -1) -> void:
var typedef: StatusTypeDef = StatusRegistry.get_type(status_type_id)
if typedef == null:
push_warning("StatusManager.apply: 未知 status_id=%d" % status_type_id)
return
var dur: float = typedef.duration if duration < 0.0 else duration
var existing_idx: int = _find(entity_id, status_type_id)
if existing_idx >= 0:
var inst: StatusInstance = _active_statuses[existing_idx]
match typedef.stack_mode:
StatusTypeDef.StackMode.REFRESH:
inst.remaining_duration = dur
inst.stacks = max(inst.stacks, stacks)
StatusTypeDef.StackMode.INTENSITY:
var cap: int = typedef.max_stacks if typedef.max_stacks > 0 else 9999
inst.stacks = min(inst.stacks + stacks, cap)
inst.remaining_duration = dur
StatusTypeDef.StackMode.INDEPENDENT:
pass # 独立实例:追加新条目
if typedef.stack_mode != StatusTypeDef.StackMode.INDEPENDENT:
EventBus.emit(EventID.STATUS_APPLIED, {
"target_id": entity_id, "status_type": status_type_id, "stacks": inst.stacks
})
return
var inst_new := StatusInstance.new()
inst_new.setup(entity_id, typedef, stacks, dur, owner_id)
_active_statuses.append(inst_new)
EventBus.emit(EventID.STATUS_APPLIED, {
"target_id": entity_id, "status_type": status_type_id, "stacks": inst_new.stacks
})
func get_stacks(entity_id: int, status_type_id: int) -> int:
var idx: int = _find(entity_id, status_type_id)
if idx < 0:
return 0
return _active_statuses[idx].stacks
func has_status(entity_id: int, status_type_id: int) -> bool:
return _find(entity_id, status_type_id) >= 0
func remove_all_for_entity(entity_id: int) -> void:
var i: int = _active_statuses.size() - 1
while i >= 0:
if _active_statuses[i].entity_id == entity_id:
_active_statuses[i] = _active_statuses[_active_statuses.size() - 1]
_active_statuses.pop_back()
i -= 1
func remove_source(owner_id: int) -> void:
var i: int = _active_statuses.size() - 1
while i >= 0:
if _active_statuses[i].owner_id == owner_id:
_active_statuses[i] = _active_statuses[_active_statuses.size() - 1]
_active_statuses.pop_back()
i -= 1
func get_active_count() -> int:
return _active_statuses.size()
func reset() -> void:
_active_statuses.clear()
_physics_frame = 0
func _find(entity_id: int, status_type_id: int) -> int:
for i in _active_statuses.size():
var inst: StatusInstance = _active_statuses[i]
if inst.entity_id == entity_id and inst.status_type_id == status_type_id:
return i
return -1
func _apply_dot_tick(inst: StatusInstance) -> void:
if not EnemyManager.has_entity(inst.entity_id):
return
var dmg: float = inst.type_def.dot_damage_per_tick * float(inst.stacks)
if dmg <= 0.0:
return
EnemyManager.apply_damage(inst.entity_id, dmg, inst.owner_id, false)
func _swap_and_pop(idx: int) -> void:
var last: int = _active_statuses.size() - 1
if idx != last:
_active_statuses[idx] = _active_statuses[last]
_active_statuses.pop_back()
+1
View File
@@ -0,0 +1 @@
uid://vm1hbvxu51s8
+40
View File
@@ -0,0 +1,40 @@
## StatusRegistry — 状态效果注册表(Autoload: StatusRegistry
## 纯数据驱动:唯一来源 res://data/status_effects.json(游戏设计器「状态」面板维护)
extends Node
const STATUS_JSON: String = "res://data/status_effects.json"
var _registry: Dictionary = {} # { status_id: int → StatusTypeDef }
func _ready() -> void:
if not FileAccess.file_exists(STATUS_JSON):
push_error("StatusRegistry: 缺失 res://data/status_effects.json(无状态效果定义)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(STATUS_JSON))
if not (data is Dictionary):
push_error("StatusRegistry: status_effects.json 格式错误")
return
for k in data:
var sid: int = int(k)
var d: Dictionary = data[k]
var t := StatusTypeDef.new()
t.id = sid
t.display_name = String(d.get("name", "Status %d" % sid))
t.duration = float(d.get("duration", 3.0))
t.tick_interval = float(d.get("tick_interval", 1.0))
t.stack_mode = int(d.get("stack_mode", 0))
t.max_stacks = int(d.get("max_stacks", 1))
t.dot_damage_per_tick = float(d.get("dot_damage", 0.0))
t.dot_damage_type = int(d.get("dot_damage_type", 0))
t.vfx_id = String(d.get("vfx_id", ""))
t.is_combo_tracker = bool(d.get("is_combo_tracker", false))
_registry[sid] = t
func get_type(status_id: int) -> StatusTypeDef:
return _registry.get(status_id, null)
func has_type(status_id: int) -> bool:
return _registry.has(status_id)
func get_registry_size() -> int:
return _registry.size()
+1
View File
@@ -0,0 +1 @@
uid://ckxpvasdeb8gi
+34
View File
@@ -0,0 +1,34 @@
## TimeManager — 时间缩放与 GameTick 计数器(Autoload: TimeManager
## 权威来源:implementation_plan.md §2.1 Layer-0
##
## - time_scale: 全局时间缩放(慢动作 / 冻结时间 Core 效果)
## - game_tick: 物理帧计数器(每 _physics_process 递增 1)— 不受 time_scale 影响
extends Node
var time_scale: float = 1.0
var game_tick: int = 0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS # 暂停时仍持续计数
func _physics_process(_delta: float) -> void:
game_tick += 1
Engine.time_scale = time_scale
## 返回当前帧缩放后 delta(供战斗系统使用)
func scaled_delta(raw_delta: float) -> float:
return raw_delta * time_scale
## 设置时间缩放(clamp 防止异常値)
func set_time_scale(scale: float) -> void:
time_scale = clamp(scale, 0.0, 10.0)
## 恢复正常速度
func reset_time_scale() -> void:
time_scale = 1.0
Engine.time_scale = 1.0
## 忽略帧计数器(关卡重置)
func reset() -> void:
game_tick = 0
reset_time_scale()
+1
View File
@@ -0,0 +1 @@
uid://bx3w1yacjl3qh
+125
View File
@@ -0,0 +1,125 @@
## VFXManager — 视觉特效管理(Autoload: VFXManager
## S4:程序化色块占位特效 + 对象池(P6-N72 one_shot=true
extends Node2D
const MAX_ACTIVE_VFX: int = 200
var _vfx_pool: Dictionary = {} # { effect_id: Array[Node2D] }
var _active_count: int = 0
func _ready() -> void:
EventBus.subscribe(EventID.STATUS_APPLIED, _on_status_applied)
func play(effect_id: String, world_pos: Vector2, override_scale: float = 1.0) -> void:
if _active_count >= MAX_ACTIVE_VFX:
return
var node: Node2D = _pool_pop(effect_id)
node.global_position = world_pos
node.scale = Vector2.ONE * override_scale
node.visible = true
_active_count += 1
# 触发一次性粒子爆发
var p: GPUParticles2D = node.get_node_or_null("Particles")
if p:
p.restart()
p.emitting = true
var timer: Timer = node.get_node_or_null("AutoReturn")
if timer:
timer.start()
func _pool_pop(effect_id: String) -> Node2D:
if _vfx_pool.has(effect_id) and not _vfx_pool[effect_id].is_empty():
return _vfx_pool[effect_id].pop_back()
return _instantiate_vfx(effect_id)
func _pool_return(effect_id: String, node: Node2D) -> void:
node.visible = false
_active_count = max(0, _active_count - 1)
if not _vfx_pool.has(effect_id):
_vfx_pool[effect_id] = []
_vfx_pool[effect_id].append(node)
func _instantiate_vfx(effect_id: String) -> Node2D:
# 程序化美术占位:GPUParticles2D 爆裂粒子(放 scenes/vfx/<id>.tscn 可整体替换)
var node := Node2D.new()
node.name = "VFX_" + effect_id
var col: Color = _color_for(effect_id)
var p := GPUParticles2D.new()
p.name = "Particles"
p.amount = _amount_for(effect_id)
p.lifetime = 0.45
p.one_shot = true # P6-N72
p.explosiveness = 0.9 # 一次性爆发
p.local_coords = false
var mat := ParticleProcessMaterial.new()
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
mat.emission_sphere_radius = 4.0
mat.direction = Vector3(0, 0, 0)
mat.spread = 180.0
mat.gravity = Vector3(0, 0, 0)
mat.initial_velocity_min = _speed_for(effect_id) * 0.5
mat.initial_velocity_max = _speed_for(effect_id)
mat.scale_min = 1.5
mat.scale_max = 4.0
# 颜色随生命淡出
var grad := Gradient.new()
grad.set_color(0, Color(col.r, col.g, col.b, col.a))
grad.set_color(1, Color(col.r, col.g, col.b, 0.0))
var gtex := GradientTexture1D.new()
gtex.gradient = grad
mat.color_ramp = gtex
p.process_material = mat
node.add_child(p)
# 自动回收(粒子播完)
var timer := Timer.new()
timer.name = "AutoReturn"
timer.one_shot = true
timer.wait_time = 0.55
timer.timeout.connect(_pool_return.bind(effect_id, node))
node.add_child(timer)
add_child(node)
node.visible = false
return node
## 不同特效的粒子数 / 速度
func _amount_for(effect_id: String) -> int:
match effect_id:
"death_burst": return 24
"hit_spark": return 10
"cast_flash": return 8
_: return 12
func _speed_for(effect_id: String) -> float:
match effect_id:
"death_burst": return 220.0
"hit_spark": return 160.0
_: return 110.0
func _color_for(effect_id: String) -> Color:
match effect_id:
"hit_spark": return Color(1.0, 0.9, 0.3, 0.9)
"status_burn": return Color(1.0, 0.4, 0.1, 0.8)
"status_poison": return Color(0.3, 0.9, 0.2, 0.8)
"cast_flash": return Color(0.6, 0.8, 1.0, 0.7)
"death_burst": return Color(1.0, 0.2, 0.2, 0.8)
_: return Color(1, 1, 1, 0.6)
func _on_status_applied(payload: Dictionary) -> void:
var status_type: int = int(payload.get("status_type", 0))
var typedef: StatusTypeDef = StatusRegistry.get_type(status_type)
if typedef and typedef.vfx_id != "":
var eid: int = int(payload.get("target_id", -1))
var pos: Vector2 = EnemyManager.get_pos_by_id(eid)
if pos != Vector2(-9999.0, -9999.0):
play(typedef.vfx_id, pos)
func get_active_count() -> int:
return _active_count
func reset() -> void:
for key in _vfx_pool:
for n in _vfx_pool[key]:
if is_instance_valid(n):
n.queue_free()
_vfx_pool.clear()
_active_count = 0
+1
View File
@@ -0,0 +1 @@
uid://cfobw7w7hcchm
+46
View File
@@ -0,0 +1,46 @@
## WandPreset — Core(法杖)工厂(Autoload: WandPreset
## 纯数据驱动:Core 定义唯一来源 res://data/cores.json(游戏设计器「Core」面板维护)
## 法术已迁至 SpellRegistrydata/spells.json),此处不再定义法术。
extends Node
const CORES_JSON: String = "res://data/cores.json"
var _json_cores: Dictionary = {}
func _ready() -> void:
if not FileAccess.file_exists(CORES_JSON):
push_error("WandPreset: 缺失 res://data/cores.json(无 Core 定义)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(CORES_JSON))
if data is Dictionary:
_json_cores = data
else:
push_error("WandPreset: cores.json 格式错误")
## 按 ID 创建 Core(纯 cores.json);未知 ID 报错并返回错误安全默认(5 槽 LINEAR)
func make_core_by_id(cid: String) -> CoreDefinition:
if _json_cores.has(cid):
return _core_from_dict(cid, _json_cores[cid])
push_error("WandPreset: Core '%s' 不在 cores.json" % cid)
return _core_from_dict(cid, {})
func _core_from_dict(cid: String, d: Dictionary) -> CoreDefinition:
var c := CoreDefinition.new()
c.id = cid
c.display_name = String(d.get("display_name", cid))
c.slot_count = int(d.get("slot_count", 5))
c.topology = int(d.get("topology", 0))
c.cpu_limit = int(d.get("cpu_limit", 5))
c.cast_interval = float(d.get("cast_interval", 0.5))
c.feature_tags = int(d.get("feature_tags", 0))
c.grid_rows = int(d.get("grid_rows", 1))
c.grid_cols = int(d.get("grid_cols", 5))
c.edges = d.get("edges", [])
return c
## 默认装备(combat_test 等用):wand_basic + 第一个 ACTION 法术
func make_default_loadout() -> Dictionary:
var core: CoreDefinition = make_core_by_id("wand_basic")
var spell: SpellNode = SpellRegistry.get_spell("action_spark_bolt")
var spells: Array = [spell] if spell else []
var compiled = SpellEvaluator.compile_wand(core, spells)
return {"core": core, "spells": spells, "compiled": compiled}
+1
View File
@@ -0,0 +1 @@
uid://bfgjbhdg6b17l
+174
View File
@@ -0,0 +1,174 @@
## WaveManager — 波次管理(Autoload: WaveManager
## S6 内容:Wave 1~20 完整配置(敌人原型/精英寻路/Mini Boss W8/Final Boss W20
## 设计参考:game_design.md §3.3W1-5 数值 / W6-10 机制 / W11-20 弹幕)、§4.5 Boss
extends Node
enum WaveState { IDLE = 0, BATTLE = 1, COMPLETE = 2 }
var current_wave: int = 0
var wave_state: int = WaveState.IDLE
var enemies_alive: int = 0
var _wave_start_time: float = 0.0
const ARENA_RADIUS: float = 700.0
const MAX_WAVE: int = 20
## Boss 追踪
var _boss_id: int = -1
var _boss_max_hp: float = 0.0
var _boss_phase: int = 0
var _boss_wave: bool = false
## 波次配置表:纯数据驱动,唯一来源 data/waves.json(游戏设计器「波次」面板维护)
## 字段:count, hp, type, elite, gold, xp, boss(-1=无 / 4=MiniBoss / 5=Boss)
var WAVE_CONFIG: Array = []
const WAVES_JSON: String = "res://data/waves.json"
func _ready() -> void:
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
_load_json_waves()
## 数据驱动:从 data/waves.json 加载波次表
func _load_json_waves() -> void:
if not FileAccess.file_exists(WAVES_JSON):
push_error("WaveManager: 缺失 res://data/waves.json(无波次配置)")
return
var data = JSON.parse_string(FileAccess.get_file_as_string(WAVES_JSON))
if data is Array and not data.is_empty():
WAVE_CONFIG = data
else:
push_error("WaveManager: waves.json 格式错误(应为非空数组)")
func _physics_process(_delta: float) -> void:
if _boss_wave and _boss_id >= 0:
_update_boss_phase()
## 开始指定波次(由 CombatManager 调用)
func start_wave(wave_num: int) -> void:
current_wave = wave_num
wave_state = WaveState.BATTLE
_wave_start_time = Time.get_ticks_msec() / 1000.0
_boss_id = -1
_boss_phase = 0
_boss_wave = false
var cfg: Dictionary = _get_wave_config(wave_num)
enemies_alive = int(cfg["count"])
_spawn_wave_enemies(cfg)
if int(cfg.get("boss", -1)) >= 0:
_spawn_boss(int(cfg["boss"]), wave_num)
EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "SHOP", "to": "BATTLE", "wave": wave_num})
print("[WaveManager] Wave %d 开始:%d 杂兵 + %d 精英%s" % [
wave_num, int(cfg["count"]), int(cfg.get("elite", 0)),
("Boss!)" if int(cfg.get("boss", -1)) >= 0 else "")])
func _spawn_wave_enemies(cfg: Dictionary) -> void:
# 难度乘子:怪物数量(挑战×1.2)、怪物血量(初学者×0.7)
var count: int = int(round(int(cfg["count"]) * SettingsManager.wave_count_mult()))
var hp: float = float(cfg["hp"]) * SettingsManager.enemy_hp_mult()
var etype: int = int(cfg.get("type", 0))
# enemies_alive 已按 cfg.count 初始化,按难度修正差额
enemies_alive += count - int(cfg["count"])
for _i in count:
EnemyManager.spawn_enemy(_ring_pos(), hp, etype)
# W15+ 精英:带 NavigationAgent2D 寻路(ADR-A4),更高血量
var elites: int = int(cfg.get("elite", 0))
for _e in elites:
EnemyManager.spawn_enemy(_ring_pos(), hp * 2.5, EnemyManager.Type.ELITE, true)
enemies_alive += elites # 精英计入存活
func _spawn_boss(boss_type: int, wave_num: int) -> void:
# Boss 基础血量由 SettingsManagerbalance.json 可调)提供 × 难度乘子
var hp: float = SettingsManager.get_boss_base_hp(boss_type) * SettingsManager.boss_hp_mult()
_boss_max_hp = hp
# Boss 在玩家上方稍远处入场
_boss_id = EnemyManager.spawn_enemy(Vector2(0.0, -ARENA_RADIUS * 0.8), hp, boss_type)
_boss_wave = true
_boss_phase = 1
enemies_alive += 1
EventBus.emit(EventID.BOSS_SPAWNED, {
"boss_id": _boss_id, "boss_type": boss_type, "wave": wave_num, "max_hp": hp})
print("[WaveManager] Boss 入场!type=%d hp=%.0f (wave %d)" % [boss_type, hp, wave_num])
## Boss 阶段:HP 跨越阈值时进阶并召唤援军(简化 AI,game_design §4.5
func _update_boss_phase() -> void:
if not EnemyManager.has_entity(_boss_id):
return
var frac: float = EnemyManager.get_hp_percent(_boss_id)
var target_phase: int = 1
if frac <= 0.33:
target_phase = 3
elif frac <= 0.66:
target_phase = 2
if target_phase > _boss_phase:
_boss_phase = target_phase
_on_boss_phase_enter(target_phase)
func _on_boss_phase_enter(phase: int) -> void:
# 进阶召唤一批援军施压(Phase 2/3)
var add_count: int = 4 + phase * 2
var boss_pos: Vector2 = EnemyManager.get_pos_by_id(_boss_id)
for _i in add_count:
var off := Vector2(randf_range(-120, 120), randf_range(-120, 120))
var aid: int = EnemyManager.spawn_enemy(boss_pos + off, 40.0, EnemyManager.Type.FAST)
if aid >= 0:
enemies_alive += 1
EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "BATTLE", "to": "BOSS_PHASE_%d" % phase})
print("[WaveManager] Boss 进入 Phase %d,召唤 %d 援军" % [phase, add_count])
func _ring_pos() -> Vector2:
var angle: float = randf() * TAU
var radius: float = randf_range(250.0, ARENA_RADIUS)
return Vector2(cos(angle) * radius, sin(angle) * radius)
func _on_enemy_killed(payload: Dictionary) -> void:
if wave_state != WaveState.BATTLE:
return
var killed_id: int = int(payload.get("entity_id", -1))
# Boss 波:Boss 死亡即通关本波(援军为附赠)
if _boss_wave and killed_id == _boss_id:
_complete_wave()
return
enemies_alive -= 1
if enemies_alive <= 0 and not _boss_wave:
_complete_wave()
func _complete_wave() -> void:
if wave_state != WaveState.BATTLE:
return
wave_state = WaveState.COMPLETE
_boss_wave = false
_boss_id = -1
var cfg: Dictionary = _get_wave_config(current_wave)
PlayerStats.gain_gold(int(cfg["gold"]))
PlayerStats.gain_xp(int(cfg["xp"]))
var elapsed: float = Time.get_ticks_msec() / 1000.0 - _wave_start_time
EventBus.emit(EventID.WAVE_COMPLETE, {"wave": current_wave, "elapsed_sec": elapsed})
print("[WaveManager] Wave %d 完成!奖励: %dG, %d XP" % [current_wave, int(cfg["gold"]), int(cfg["xp"])])
func _get_wave_config(wave_num: int) -> Dictionary:
var idx: int = clamp(wave_num - 1, 0, WAVE_CONFIG.size() - 1)
var base: Dictionary = WAVE_CONFIG[idx].duplicate()
# 超过 W20Endless):数值膨胀
if wave_num > MAX_WAVE:
var extra: int = wave_num - MAX_WAVE
base["count"] = int(base["count"]) + extra * 6
base["hp"] = float(base["hp"]) * pow(1.12, extra)
base["gold"] = int(base["gold"]) + extra * 8
base["xp"] = int(base["xp"]) + extra * 12
base["boss"] = -1 # Endless 不重复终 Boss
return base
func get_wave_count(wave_num: int) -> int:
return int(_get_wave_config(wave_num)["count"])
func is_boss_wave(wave_num: int) -> bool:
return int(_get_wave_config(wave_num).get("boss", -1)) >= 0
func reset() -> void:
current_wave = 0
wave_state = WaveState.IDLE
enemies_alive = 0
_boss_id = -1
_boss_phase = 0
_boss_wave = false
+1
View File
@@ -0,0 +1 @@
uid://dj1o00ehrwfwj
+76
View File
@@ -0,0 +1,76 @@
## ZoneManager — 地面效果区域管理(Autoload: ZoneManager
## 权威来源:architecture_design.md §8 ADR-R5-N1P6-N71 tick 用 while + -=
## SoA PackedFloat32Array stride=8;基于 SpatialGrid 空间索引(禁用 Area2D
## [0]=cx [1]=cy [2]=radius [3]=status_id [4]=duration [5]=tick_interval [6]=tick_accum [7]=owner_id
##
## 注:本 GDScript 外壳承载 tick 热循环为 S5 功能验收路径(P-S5-04);
## C# 子节点 ZoneManagerCs 驱动的压力路径(P-S5-ZM-0164 区×1000 敌 <1ms)留作后续优化,
## 与项目既有 C# 回退策略(R-08)一致。
extends Node
const ZONE_STRIDE: int = 8
const MAX_ZONES: int = 64
var _zone_data: PackedFloat32Array = PackedFloat32Array()
var _active_zones: int = 0
func _ready() -> void:
_zone_data.resize(MAX_ZONES * ZONE_STRIDE)
## 生成一个地面效果区域;满员时顶掉最旧(index 0 前移)
func spawn_zone(cx: float, cy: float, radius: float, status_id: int,
duration: float, tick_interval: float, owner_id: int) -> void:
if _active_zones >= MAX_ZONES:
# 顶掉最旧 Zoneindex 0),整体前移(O(N)N≤64)
for j in range(ZONE_STRIDE, _active_zones * ZONE_STRIDE):
_zone_data[j - ZONE_STRIDE] = _zone_data[j]
_active_zones -= 1
var base: int = _active_zones * ZONE_STRIDE
_zone_data[base + 0] = cx
_zone_data[base + 1] = cy
_zone_data[base + 2] = radius
_zone_data[base + 3] = float(status_id)
_zone_data[base + 4] = duration
_zone_data[base + 5] = maxf(0.01, tick_interval)
_zone_data[base + 6] = 0.0 # tick_accum
_zone_data[base + 7] = float(owner_id)
_active_zones += 1
func _physics_process(delta: float) -> void:
if _active_zones == 0:
return
var i: int = 0
while i < _active_zones:
var base: int = i * ZONE_STRIDE
_zone_data[base + 4] -= delta # duration 倒计时
_zone_data[base + 6] += delta # tick_accum 累积
# 速率限制:外层 if 守卫 + 内层 while + -= tick_intervalP6-N71,保留余量精度)
# SpatialGrid.query_circle 仅在 if 守卫内查询一次,不在 while 内重复查询
if _zone_data[base + 6] >= _zone_data[base + 5]:
var cx: float = _zone_data[base + 0]
var cy: float = _zone_data[base + 1]
var rad: float = _zone_data[base + 2]
var sid: int = int(_zone_data[base + 3])
var oid: int = int(_zone_data[base + 7])
var targets: PackedInt32Array = SpatialGrid.query_circle(Vector2(cx, cy), rad)
while _zone_data[base + 6] >= _zone_data[base + 5]:
_zone_data[base + 6] -= _zone_data[base + 5] # -= tick_interval 保留余量
for t_id in targets:
StatusManager.apply(t_id, sid, 1, -1.0, oid) # stacks=1, duration=默认, owner
if _zone_data[base + 4] <= 0.0: # duration 耗尽 → swap-and-pop 移除 + VFX 淡出
VFXManager.play("zone_expire", Vector2(_zone_data[base + 0], _zone_data[base + 1]))
if i < _active_zones - 1:
var last: int = (_active_zones - 1) * ZONE_STRIDE
for k in ZONE_STRIDE:
_zone_data[base + k] = _zone_data[last + k]
_active_zones -= 1
else:
i += 1
func get_active_count() -> int:
return _active_zones
func reset() -> void:
_active_zones = 0 # 惰性清理:SoA 数据不清零,_active_zones 控制有效范围
+1
View File
@@ -0,0 +1 @@
uid://dpk50hi3o0lg4
+288
View File
@@ -0,0 +1,288 @@
## CombatManager — 游戏循环状态机(属于 CombatScene 节点)
## 状态:BATTLE → SETTLEMENT → SHOP → BATTLE…
## 权威来源:development_plan.md S2 CombatManager
extends Node
enum GameState {
INIT = 0,
BATTLE = 1,
SETTLEMENT = 2,
SHOP = 3,
GAME_OVER = 4,
}
var state: int = GameState.INIT
## 玩家法杯法术列表(已安装到 Core 的 ID"" = 空槽,供 MATRIX/CIRCUIT 位置化布局)
var _deck_spell_ids: Array = ["action_spark_bolt"]
var _equipped_core: CoreDefinition = null
var _equipped_compiled: CompiledDeck = null
## 可切换的 Core 名册(商店内 Tab 循环切换;S5 高级 Core 实际可玩入口)
const _CORE_ROSTER: Array = ["wand_basic", "wand_fast", "wand_memory", "matrix_board", "circuit_fork"]
var _core_idx: int = 0
## 备牌区:拥有但未装入插槽的法术 id(背包 UI 在此与插槽间移动)
var _bench: Array = []
## 本局开始时刻(秒),用于排行榜评分的 elapsed_sec
var _run_start_time: float = 0.0
## 商店 UI 回调(由场景设置)
var _on_shop_closed_cb: Callable = Callable()
func _ready() -> void:
EventBus.subscribe(EventID.WAVE_COMPLETE, _on_wave_complete)
EventBus.subscribe(EventID.PLAYER_DIED, _on_player_died)
ProfileManager.set_wand_provider(self) # 法杖状态纳入 A/B 存档(ADR-A2)
## 开始新局
func start_game() -> void:
PlayerStats.reset_for_run()
WaveManager.reset()
BulletManager.reset()
EnemyManager.reset()
StatusManager.reset()
DpsTracker.reset()
VFXManager.reset()
ZoneManager.reset()
MinionManager.reset()
SpatialGrid.clear()
_core_idx = 0
_equipped_core = WandPreset.make_core_by_id("wand_basic")
_deck_spell_ids = ["action_spark_bolt"]
_bench.clear()
_run_start_time = Time.get_ticks_msec() / 1000.0
_rebuild_wand()
_transition_to_battle(1)
## 继续现有进度恢复
func resume_game() -> void:
var saved: Dictionary = ProfileManager.load_run()
if saved.is_empty():
start_game()
return
ProfileManager.apply_run(saved)
_rebuild_wand()
var wave: int = max(1, WaveManager.current_wave)
_transition_to_battle(wave)
func _transition_to_battle(wave_num: int) -> void:
var prev: String = GameState.keys()[state] if state < GameState.size() else "UNKNOWN"
state = GameState.BATTLE
SubPayloadRegistry.lock_for_battle() # P6-N10: 战斗期间锁定,防止竞争条件
PlayerManager.equip_wand(_equipped_core, _equipped_compiled)
WaveManager.start_wave(wave_num)
EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": prev, "to": "BATTLE"})
func _on_wave_complete(payload: Dictionary) -> void:
var wave: int = int(payload.get("wave", WaveManager.current_wave))
state = GameState.SETTLEMENT
ProfileManager.save_run()
EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "BATTLE", "to": "SETTLEMENT"})
# 厂他延迟进入商店(等待结算动画, S2 直接进入)
call_deferred("_open_shop", wave)
func _open_shop(wave: int) -> void:
state = GameState.SHOP
ShopManager.open_shop()
EventBus.emit(EventID.GAME_STATE_CHANGED, {"from": "SETTLEMENT", "to": "SHOP"})
if not _on_shop_closed_cb.is_null():
_on_shop_closed_cb.call(wave)
## 外部调用:购买法术并安装到法杯
## LINEARMODIFIER 插首个 ACTION 前、ACTION 追加尾部(Noita 风格)
## MATRIX/CIRCUIT:位置化——填入首个空槽("") 以保持拓扑布局
func install_spell(spell: SpellNode) -> void:
if spell == null:
return
var max_slots: int = _equipped_core.slot_count if _equipped_core else 5
if _is_positional_core():
var placed: bool = false
for i in _deck_spell_ids.size():
if String(_deck_spell_ids[i]) == "":
_deck_spell_ids[i] = spell.id
placed = true
break
if not placed:
if _deck_spell_ids.size() < max_slots:
_deck_spell_ids.append(spell.id)
else:
_bench.append(spell.id) # 满了进备牌区,玩家可在背包内重排
elif _deck_spell_ids.size() >= max_slots:
_bench.append(spell.id) # 满了进备牌区
elif spell.type == SpellNode.SpellType.MODIFIER:
var insert_pos: int = 0
for i in _deck_spell_ids.size():
var sn: SpellNode = SpellRegistry.get_spell(_deck_spell_ids[i])
if sn and sn.type == SpellNode.SpellType.ACTION:
insert_pos = i
break
insert_pos = i + 1
_deck_spell_ids.insert(insert_pos, spell.id)
else:
_deck_spell_ids.append(spell.id)
_rebuild_wand()
ProfileManager.mark_dirty()
func _is_positional_core() -> bool:
return _equipped_core != null and _equipped_core.topology != CoreDefinition.Topology.LINEAR
## 切换装备的 Core(商店内调用);载入该 Core 的演示默认 Deck 并重编译
func switch_core() -> void:
_core_idx = (_core_idx + 1) % _CORE_ROSTER.size()
var cid: String = _CORE_ROSTER[_core_idx]
_equipped_core = _make_core_by_id(cid)
_deck_spell_ids = _default_deck_for(cid)
_rebuild_wand()
PlayerManager.equip_wand(_equipped_core, _equipped_compiled) # 即时换装
ProfileManager.mark_dirty()
func get_core_display() -> String:
return _equipped_core.display_name if _equipped_core else ""
func _make_core_by_id(cid: String) -> CoreDefinition:
return WandPreset.make_core_by_id(cid) # 数据驱动(cores.json+ 硬编码回退
## 每个 Core 的演示默认 Deck"" = 空槽):让玩家切换即见到该拓扑机制
func _default_deck_for(cid: String) -> Array:
match cid:
"wand_memory": return ["logic_every_n_shots", "action_spark_bolt"]
"matrix_board": return ["action_spark_bolt", "", "", "", "modifier_damage_plus", "", "", ""]
"circuit_fork": return ["", "action_spark_bolt", "action_fire_bolt"]
_: return ["action_spark_bolt"]
# ── 背包 / 插槽编辑(inventory UI 调用)──────────────────────────
func get_slot_count() -> int:
return _equipped_core.slot_count if _equipped_core else 5
## 返回补齐到 slot_count 长度的插槽视图("" = 空槽)
func get_padded_deck() -> Array:
var n: int = get_slot_count()
var out: Array = _deck_spell_ids.duplicate()
while out.size() < n:
out.append("")
if out.size() > n:
out.resize(n)
return out
func get_bench() -> Array:
return _bench
## 写回插槽视图:LINEAR 压实去空槽,MATRIX/CIRCUIT 保留位置
func _apply_padded(arr: Array) -> void:
if _is_positional_core():
_deck_spell_ids = arr.duplicate()
else:
var compact: Array = []
for s in arr:
if String(s) != "":
compact.append(s)
_deck_spell_ids = compact
_rebuild_wand()
PlayerManager.equip_wand(_equipped_core, _equipped_compiled)
ProfileManager.mark_dirty()
## 交换两个插槽内容
func swap_slots(a: int, b: int) -> void:
var d: Array = get_padded_deck()
if a < 0 or b < 0 or a >= d.size() or b >= d.size():
return
var t = d[a]; d[a] = d[b]; d[b] = t
_apply_padded(d)
## 清空插槽 → 内容移入备牌区
func slot_to_bench(idx: int) -> void:
var d: Array = get_padded_deck()
if idx < 0 or idx >= d.size():
return
var sid: String = String(d[idx])
if sid == "":
return
_bench.append(sid)
d[idx] = ""
_apply_padded(d)
## 备牌 → 插槽(插槽已占用则原内容退回备牌区)
func bench_to_slot(bench_idx: int, slot_idx: int) -> void:
var d: Array = get_padded_deck()
if bench_idx < 0 or bench_idx >= _bench.size() or slot_idx < 0 or slot_idx >= d.size():
return
var sid: String = String(_bench[bench_idx])
var existing: String = String(d[slot_idx])
_bench.remove_at(bench_idx)
if existing != "":
_bench.append(existing)
d[slot_idx] = sid
_apply_padded(d)
## 关闭商店进入下一波
func close_shop_and_next_wave() -> void:
ShopManager.close_shop()
BulletManager.reset()
EnemyManager.reset()
ZoneManager.reset()
MinionManager.reset()
SpatialGrid.clear()
var next_wave: int = WaveManager.current_wave + 1
_transition_to_battle(next_wave)
func _on_player_died(_payload: Dictionary) -> void:
state = GameState.GAME_OVER
# 先采集本局战绩(reset 前),写入排行榜
var wave_reached: int = max(1, WaveManager.current_wave)
var kills: int = PlayerManager.kill_count
var elapsed: int = int(Time.get_ticks_msec() / 1000.0 - _run_start_time)
var score: int = EndlessRecords.compute_score(wave_reached, elapsed)
EndlessRecords.add_record(wave_reached, elapsed, kills)
PlayerManager.reset()
BulletManager.reset()
EnemyManager.reset()
ZoneManager.reset()
MinionManager.reset()
SpatialGrid.clear()
ProfileManager.clear_run()
EventBus.emit(EventID.GAME_OVER, {
"wave": wave_reached, "elapsed_sec": elapsed, "kills": kills, "score": score})
func _rebuild_wand() -> void:
if _equipped_core == null:
_equipped_core = WandPreset.make_core_by_id("wand_basic")
var nodes: Array = []
for sid in _deck_spell_ids:
if String(sid) == "":
nodes.append(null) # 空槽:保持 MATRIX/CIRCUIT 位置(LINEAR 扁平化会跳过 null
continue
nodes.append(SpellRegistry.get_spell(sid)) # 纯数据驱动;未知 id → null(下游按位置跳过)
_equipped_compiled = SpellEvaluator.compile_wand(_equipped_core, nodes)
# ── 法杖存档(ProfileManager A/B 槽,ADR-A2)─────────────────
func get_wand_save_data() -> Dictionary:
return {
"core_id": _equipped_core.id if _equipped_core else "wand_basic",
"deck": _deck_spell_ids.duplicate(),
"bench": _bench.duplicate(),
}
func apply_wand_save_data(d: Dictionary) -> void:
var cid: String = String(d.get("core_id", "wand_basic"))
_equipped_core = _make_core_by_id(cid)
_core_idx = _CORE_ROSTER.find(cid)
if _core_idx < 0:
_core_idx = 0
# JSON 反序列化为 Array;逐项转 String 保证类型一致
_deck_spell_ids = []
for s in d.get("deck", ["action_spark_bolt"]):
_deck_spell_ids.append(String(s))
if _deck_spell_ids.is_empty():
_deck_spell_ids = ["action_spark_bolt"]
_bench = []
for s in d.get("bench", []):
_bench.append(String(s))
_rebuild_wand()
func get_deck_spell_ids() -> Array:
return _deck_spell_ids
func get_equipped_compiled() -> CompiledDeck:
return _equipped_compiled
@@ -0,0 +1 @@
uid://47u2amfg0hfg
+119
View File
@@ -0,0 +1,119 @@
## PlayerManager — 玩家管理 Autoload
## S0WASD 移动 + 位置查询
## S1:自动施法
## S4:停步蓄力 / 冲刺边沿检测(P6-N69、P6-N36
extends Node
const MOVE_SPEED: float = 200.0
const MOVE_THRESHOLD_NORM: float = 0.1
const CHARGE_TRIGGER_TIME: float = 1.5
var _position: Vector2 = Vector2.ZERO
var _velocity: Vector2 = Vector2.ZERO
var _player_node: Node2D = null
var _equipped_core: CoreDefinition = null
var _equipped_compiled: CompiledDeck = null
var _cast_timer: float = 0.0
var _cast_interval: float = 0.5
var _is_moving: bool = false
var _stationary_time: float = 0.0
var _charge_triggered: bool = false
var _last_dash_time: float = -999.0
var kill_count: int = 0
func _ready() -> void:
EventBus.subscribe(EventID.ENEMY_KILLED, _on_enemy_killed)
func _physics_process(delta: float) -> void:
_handle_movement(delta)
_handle_charge_state(delta)
_handle_auto_cast(delta)
func _handle_movement(delta: float) -> void:
var dir := Vector2(
Input.get_axis("ui_left", "ui_right"),
Input.get_axis("ui_up", "ui_down")
)
_is_moving = dir.length() > MOVE_THRESHOLD_NORM
if _is_moving:
dir = dir.normalized()
_velocity = dir * MOVE_SPEED
else:
_velocity = Vector2.ZERO
_position += _velocity * delta
if _player_node:
_player_node.global_position = _position
func _handle_charge_state(delta: float) -> void:
if Input.is_action_just_pressed("ui_accept"):
_last_dash_time = Time.get_ticks_msec() / 1000.0
EventBus.emit(EventID.DASH_TRIGGERED, {
"caster_id": 0,
"stationary_time": _stationary_time,
})
_stationary_time = 0.0
_charge_triggered = false
return
if _is_moving:
if _stationary_time > 0.0:
_stationary_time = 0.0
_charge_triggered = false
EventBus.emit(EventID.CHARGE_STATE_CHANGED, {"caster_id": 0, "progress": 0.0})
else:
_stationary_time += delta
if _stationary_time >= 0.3:
var prog: float = clampf(_stationary_time / CHARGE_TRIGGER_TIME, 0.0, 1.0)
EventBus.emit(EventID.CHARGE_STATE_CHANGED, {"caster_id": 0, "progress": prog})
if _stationary_time >= CHARGE_TRIGGER_TIME and not _charge_triggered:
_charge_triggered = true
EventBus.emit(EventID.CHARGE_FIRED, {"caster_id": 0})
func _handle_auto_cast(delta: float) -> void:
if _equipped_compiled == null or _equipped_compiled.is_empty():
return
_cast_timer -= delta
if _cast_timer <= 0.0:
_cast_timer = _cast_interval
SpellEvaluator.execute_compiled(_equipped_compiled, 0, _position)
func equip_wand(core: CoreDefinition, compiled: CompiledDeck) -> void:
_equipped_core = core
_equipped_compiled = compiled
_cast_interval = core.cast_interval if core else 0.5
_cast_timer = 0.0
func get_position() -> Vector2:
return _position
func get_velocity() -> Vector2:
return _velocity
func get_stationary_time() -> float:
return _stationary_time
func is_charge_triggered() -> bool:
return _charge_triggered
func set_player_node(node: Node2D) -> void:
_player_node = node
if node:
_position = node.global_position
func _on_enemy_killed(_payload: Dictionary) -> void:
kill_count += 1
func reset() -> void:
_position = Vector2.ZERO
_velocity = Vector2.ZERO
_player_node = null
_equipped_compiled = null
_equipped_core = null
_cast_timer = 0.0
_is_moving = false
_stationary_time = 0.0
_charge_triggered = false
kill_count = 0
+1
View File
@@ -0,0 +1 @@
uid://pqv4usxmsnqv
+23
View File
@@ -0,0 +1,23 @@
## CastStats — 单次施法的可修改弹道参数负载
## 由 MODIFIER 节点在执行间修改;每次 execute_compiled 开始时从 SpellContext 隶新
extends RefCounted
class_name CastStats
var damage_add: float = 0.0 # 加法伤害加成
var damage_mult: float = 1.0 # 乘法伤害倍率
var spread_count: int = 1 # 单 ACTION 同时发射数量(扩散)
var multicast_count: int = 0 # S3:额外可执行的 ACTION 数(0=仅执行1个)
var lifetime: float = 0.0 # 对默认 lifetime 的差値(<0 缩短)
var speed_mult: float = 1.0 # 弹速倍率
var radius_mult: float = 1.0 # 弹体半径倍率
var crit_chance: float = 0.0 # 暴击概率
func reset() -> void:
damage_add = 0.0
damage_mult = 1.0
spread_count = 1
multicast_count = 0
lifetime = 0.0
speed_mult = 1.0
radius_mult = 1.0
crit_chance = 0.0
@@ -0,0 +1 @@
uid://c73iiht30kan3
@@ -0,0 +1,21 @@
## CompiledDeck — 预编译产物,关卡期间只读
## 权威来源:implementation_plan.md §2.2
extends RefCounted
class_name CompiledDeck
var nodes: Array = [] # Array[SpellNode] 拓扑扁平化 + SCOPE 虚节点插入
var label_table: Dictionary = {} # { label_id: int → node_index: int }LOGIC_LABEL 专用)
var sub_payload_ids: Array = [] # 本 Deck 预编译时注册到 SubPayloadRegistry 的所有 ID
var topology_type: int = 0 # 0=LINEAR 1=MATRIX 2=CIRCUIT
var feature_tags: int = 0 # Core 特性位掩码快照(CoreFeatureTag.*),供执行期判断 PERSISTENT_MEMORY 等
var consumed_indices: Array = [] # 共鸣 consume_inputs 消费的 nodes 索引(P6-N29),运行时跳过
var checksum: int = 0 # 插槽内容哈希判断是否需要重新编译
## 创建用于执行的 SpellDeck 实例(拷贝 nodes 并重置 cursor
func make_runtime_deck() -> SpellDeck:
var deck := SpellDeck.new()
deck.setup(nodes.duplicate(), consumed_indices)
return deck
func is_empty() -> bool:
return nodes.is_empty()
@@ -0,0 +1 @@
uid://ii4xb7qe2ok8
@@ -0,0 +1,28 @@
## CoreDefinition — 法杖 Core 插槽定义
## 权威来源:architecture_design.md §3.1, core_wand_design.md
extends Resource
class_name CoreDefinition
enum Topology {
LINEAR = 0,
MATRIX = 1,
CIRCUIT = 2,
}
@export var id: String = ""
@export var display_name: String = ""
@export var slot_count: int = 5 # LINEAR 局线插槽数
var topology: int = Topology.LINEAR
@export var cpu_limit: int = 5 # cpu_limit * MAX_OPS_PER_CPU = 单次最大执行步数
@export var cast_interval: float = 0.5 # 默认施法间隔(秒)
## MATRIX 专用:行数 × 列数
@export var grid_rows: int = 1
@export var grid_cols: int = 5
## CIRCUIT 专用:有向边列表(P6-N42 权威格式 Array[Dictionary]{ "from": int, "to": int }
## _flatten_circuit() 从此处读取做 Kahn 拓扑排序;LINEAR/MATRIX 留空。也兼容 [from, to] 数组对。
@export var edges: Array = []
## CoreFeatureTag 位掉:PERSISTENT_MEMORY=1, DUAL_STREAM=2, 等
@export var feature_tags: int = 0
@@ -0,0 +1 @@
uid://6q8o5ss8ntse
@@ -0,0 +1,39 @@
## ProjectileDef — 弹道生成参数描述
## ACTION 节点写入,SpellEvaluator 读取并调用 BulletManager.spawn_bullet
## 跨切片约束:reset() 必须重置 spawn_positionP6-N41
extends RefCounted
class_name ProjectileDef
var spawn_position: Vector2 = Vector2.ZERO # P6-N41: 必须在 reset() 中重置
var direction: Vector2 = Vector2.RIGHT
var speed: float = 300.0
var lifetime: float = 4.0
var radius: float = 6.0
var base_damage: float = 3.0
var damage_mult: float = 1.0
var damage_type: int = 0 # DamageType.PHYSICAL
var owner_id: int = 0
var source_tags: int = 0
var acceleration: float = 0.0
var on_hit_payload_id: int = -1 # -1 = 无触发
var pierce_remaining: int = 0
var bounce_remaining: int = 0
func reset() -> void:
spawn_position = Vector2.ZERO # P6-N41
direction = Vector2.RIGHT
speed = 300.0
lifetime = 4.0
radius = 6.0
base_damage = 3.0
damage_mult = 1.0
damage_type = 0
owner_id = 0
source_tags = 0
acceleration = 0.0
on_hit_payload_id = -1
pierce_remaining = 0
bounce_remaining = 0
func get_velocity() -> Vector2:
return direction.normalized() * speed
@@ -0,0 +1 @@
uid://cql1wa8fgnhb0
@@ -0,0 +1,44 @@
## SpellContext — 法术执行上下文(对象池化)
## 权威来源:architecture_design.md §3.2, implementation_plan.md §2.2
## registers 跨帧持久(LOGIC_EVERY_N_SHOTS 必须使用此处,禁用 CastState.registers
extends RefCounted
class_name SpellContext
var caster_id: int = 0
var stats: CastStats = null # 本次施法的修改层
## 跨帧持久寄存器(长度固定 4,P6-N48)
## R1-R4:供 LOGIC_EVERY_N_SHOTS 等指令跨帧计数
var registers: PackedFloat32Array = PackedFloat32Array()
func _init() -> void:
stats = CastStats.new()
registers.resize(4)
registers.fill(0.0)
func reset() -> void:
caster_id = 0
if stats:
stats.reset()
else:
stats = CastStats.new()
# registers 小心:仅当 Core 没有 PERSISTENT_MEMORY Feature 时才清零
# 具体清空逻辑在 SpellContextPool.acquire_for_wand() 中处理
func clear_registers() -> void:
registers.fill(0.0)
## CastState — 单次 execute() 的临时工作状态(不入池,每次 new)
## 注意区分:CastState.registers 每次 execute() 开始时重置,不跨调用保留
## LOGIC_EVERY_N_SHOTS 「必须」使用 SpellContext.registers,严禁使用 CastState.registers
class CastState:
var trigger_depth: int = 0
var registers: PackedFloat32Array = PackedFloat32Array()
func _init() -> void:
registers.resize(4)
registers.fill(0.0)
func reset() -> void:
trigger_depth = 0
registers.fill(0.0)
@@ -0,0 +1 @@
uid://ud1ynycvopp4
+74
View File
@@ -0,0 +1,74 @@
## SpellDeck — 运行时执行游标
## 权威来源:implementation_plan.md §2.2
extends RefCounted
class_name SpellDeck
var _nodes: Array = [] # Array[SpellNode]
var _cursor: int = 0
var _consumed: PackedByteArray = PackedByteArray() # 消费掩码(共鸣 consume_inputsP6-N29);1=跳过
func setup(nodes: Array, consumed_indices: Array = []) -> void:
_nodes = nodes
_cursor = 0
_consumed = PackedByteArray()
_consumed.resize(_nodes.size()) # 全 0
for idx in consumed_indices:
if idx >= 0 and idx < _consumed.size():
_consumed[idx] = int(1)
func has_next() -> bool:
var c: int = _cursor
while c < _nodes.size() and c < _consumed.size() and _consumed[c] != 0:
c += 1
return c < _nodes.size()
func pop() -> SpellNode:
while _cursor < _nodes.size() and _cursor < _consumed.size() and _consumed[_cursor] != 0:
_cursor += 1 # 跳过被共鸣消费的槽位
if _cursor >= _nodes.size():
return null
var node: SpellNode = _nodes[_cursor]
_cursor += 1
return node
func peek() -> SpellNode:
if _cursor >= _nodes.size():
return null
return _nodes[_cursor]
func reset_cursor() -> void:
_cursor = 0
## 跳过直到遇到 SCOPE_CLOSE(深度计数器处理嵌套)
func skip_until_scope_end() -> void:
var depth := 1
while _cursor < _nodes.size():
var n: SpellNode = _nodes[_cursor]
_cursor += 1
if n.type == SpellNode.SpellType.SCOPE_CLOSE:
depth -= 1
if depth <= 0:
return
elif n.type == SpellNode.SpellType.TRIGGER or n.type == SpellNode.SpellType.LOGIC:
depth += 1
## 将游标到下一个 SCOPE_CLOSE 之间的节点列表取出(用于打包 SubPayload)
func consume_until_scope_end() -> Array:
var result: Array = []
var depth := 1
while _cursor < _nodes.size():
var n: SpellNode = _nodes[_cursor]
_cursor += 1
if n.type == SpellNode.SpellType.SCOPE_CLOSE:
depth -= 1
if depth <= 0:
break
result.append(n)
else:
if n.type == SpellNode.SpellType.TRIGGER or n.type == SpellNode.SpellType.LOGIC:
depth += 1
result.append(n)
return result
func get_remaining_count() -> int:
return _nodes.size() - _cursor
@@ -0,0 +1 @@
uid://imeqchdnn158
@@ -0,0 +1,619 @@
## SpellEvaluator — 法术虚拟机(Autoload: SpellEvaluator
## S1compile_wand(LINEAR)execute_compiled(ACTION)
## S2MODIFIER 分支
## S3_flatten_linear 处理 TRIGGER、execute_sub、multicast_count、actions_remaining
## S5LOGIC 分支(条件门 + LOOP)、跨帧寄存器持久化(PERSISTENT_MEMORY Core
extends Node
const MAX_OPS_PER_CPU: int = 40
const MAX_TRIGGER_DEPTH: int = 3
## LOGIC 求值返回信号
const _LOGIC_CONTINUE: int = 0 # 条件通过 / 无副作用 → 继续执行后续节点
const _LOGIC_SKIP_REST: int = 1 # 条件不成立 → 跳过本次施法后续全部节点
## 跨帧持久寄存器上下文:caster_id → SpellContext(不入池,registers 跨施法保留)
## 仅 Core 带 CoreFeatureTag.PERSISTENT_MEMORY 时使用;compile_wand 时清空(换杖即重置记忆)
var _persistent_ctx: Dictionary = {}
## 共鸣配方表(§3.4.C):纯数据驱动,唯一来源 res://data/resonance.json
## 字段:pattern[2]、match("adjacent"/"anywhere_in_deck")、result_spell_id、consume_inputs
const RESONANCE_JSON: String = "res://data/resonance.json"
var _resonance_recipes: Array = []
func _ready() -> void:
if FileAccess.file_exists(RESONANCE_JSON):
var d = JSON.parse_string(FileAccess.get_file_as_string(RESONANCE_JSON))
if d is Array:
_resonance_recipes = d
else:
push_error("SpellEvaluator: resonance.json 格式错误(应为数组)")
else:
push_error("SpellEvaluator: 缺失 res://data/resonance.json(共鸣系统无配方)")
## compile_wand(core, raw_nodes) → CompiledDeck
## core 在前(P6-N70
func compile_wand(core: CoreDefinition, raw_nodes: Array) -> CompiledDeck:
SubPayloadRegistry.clear_for_compile()
_persistent_ctx.clear() # 换杖/重编译即重置跨帧寄存器记忆
# P6-N3 修复:若 Core 带 PERSISTENT_MEMORY,在非热路径的 compile_wand 阶段预分配 SpellContext
# 避免首次施法时在 _physics_process 热路径内 SpellContext.new()
if (core.feature_tags & CoreFeatureTag.PERSISTENT_MEMORY) != 0:
var prewarm := SpellContext.new()
prewarm.caster_id = 0 # 玩家 caster_id,下次 acquire 时覆写
_persistent_ctx[0] = prewarm # 预热 id=0(玩家唯一 id
var deck := CompiledDeck.new()
deck.topology_type = core.topology
deck.feature_tags = core.feature_tags # 执行期据此判断 PERSISTENT_MEMORY 等
match core.topology:
CoreDefinition.Topology.CIRCUIT:
_flatten_circuit(raw_nodes, core, deck)
CoreDefinition.Topology.MATRIX:
_flatten_matrix(raw_nodes, core, deck)
_:
_flatten_linear(raw_nodes, deck)
# 拓扑扁平化后做共鸣模式匹配(§3.4.C),可能注入结果节点并标记 consume 掩码
_check_resonance(deck)
deck.checksum = _calc_checksum(core, deck.nodes)
# 战斗开始时 CombatManager 调用 lock_for_battle()
return deck
## 扣除算法:处理 MODIFIER / TRIGGER / ACTION
## TRIGGER 节点消耗后续节点作为子荷载
func _flatten_linear(raw_nodes: Array, deck: CompiledDeck) -> void:
var i: int = 0
while i < raw_nodes.size():
var n: SpellNode = raw_nodes[i]
if not (n is SpellNode):
i += 1
continue
if n.type == SpellNode.SpellType.TRIGGER:
# 消耗 i+1 起的节点为子荷载(ACTION 节点也属于子荷载范围)
# 仅遇到下一个同级 TRIGGER 时才停止(不跨 TRIGGER 作用域)
var sub_nodes: Array = []
var j: int = i + 1
while j < raw_nodes.size():
var sn: SpellNode = raw_nodes[j]
if sn.type == SpellNode.SpellType.TRIGGER:
break # 下一个 TRIGGER 是独立作用域
sub_nodes.append(sn) # MODIFIER / ACTION 均归入子荷载
j += 1
# 注册子荷载
var payload_id: int = SubPayloadRegistry.register(sub_nodes)
# 克隆 TRIGGER 节点并注入 sub_payload_id
var trigger_clone: SpellNode = SpellNode.new()
trigger_clone.id = n.id
trigger_clone.type = n.type
trigger_clone.display_name = n.display_name
trigger_clone.description = n.description
trigger_clone.meta = n.meta.duplicate()
trigger_clone.meta["sub_payload_id"] = payload_id
deck.nodes.append(trigger_clone)
deck.sub_payload_ids.append(payload_id)
i = j # 跳过已消耗节点
else:
deck.nodes.append(n)
i += 1
func _calc_checksum(core: CoreDefinition, nodes: Array) -> int:
var h: int = core.topology ^ nodes.size() ^ hash(core.id)
for i in nodes.size():
h = h * 31 ^ hash(nodes[i].id)
return h
# ── CIRCUIT 拓扑扁平化(architecture_design.md §3.4.AP6-N57/63/64/66)─────
## 读 core.edges,对有向图做 Kahn 拓扑排序;分叉点(出度>1)展开为 SubPayload + LOGIC_FORK
## edges 为空 → 降级 LINEAR。结果写入 deck.nodes;分支 payload id 记入 deck.sub_payload_ids
func _flatten_circuit(raw_nodes: Array, core: CoreDefinition, deck: CompiledDeck) -> void:
var edges: Array = core.edges
if edges.is_empty():
push_warning("_flatten_circuit: core.edges 为空,降级 LINEARCore=%s" % core.id)
_flatten_linear(raw_nodes, deck)
return
var n: int = core.slot_count
# 1. 构建邻接表 + 入度
var in_degree: Array = []
var adj: Array = []
in_degree.resize(n)
adj.resize(n)
for i in n:
in_degree[i] = 0
adj[i] = []
for e in edges:
var ep: Vector2i = _edge_endpoints(e)
if ep.x < 0 or ep.x >= n or ep.y < 0 or ep.y >= n:
push_warning("_flatten_circuit: 非法边 %sslot_count=%d)跳过" % [str(e), n])
continue
adj[ep.x].append(ep.y)
in_degree[ep.y] += 1
# P6-N63Kahn 前保存原始入度快照,供 _collect_branch_path 判断汇聚点
var orig_in_degree: Array = in_degree.duplicate()
# 2. Kahn BFS 拓扑排序
var queue: Array = []
for i in n:
if in_degree[i] == 0:
queue.append(i)
var topo_order: Array = []
while not queue.is_empty():
var cur: int = queue.pop_front()
topo_order.append(cur)
for nxt in adj[cur]:
in_degree[nxt] -= 1
if in_degree[nxt] == 0:
queue.append(nxt)
if topo_order.size() != n:
push_error("_flatten_circuit: 检测到环路,无法线性化!Core=%s" % core.id)
return # deck.nodes 保持空,该法杖无法施法但不崩溃
# 3. 按拓扑序输出;P6-N64in_branch_payload 防止分支节点被主链重复执行
var in_branch_payload: Dictionary = {}
for slot_idx in topo_order:
if in_branch_payload.has(slot_idx):
continue # 已被某分支 SubPayload 收纳
var node: SpellNode = raw_nodes[slot_idx] if slot_idx < raw_nodes.size() else null
if adj[slot_idx].size() > 1:
# 分叉点(先于 null 守卫判断:分叉槽通常为空 splitter)
if node != null:
deck.nodes.append(node) # 分叉槽自身节点先执行(通常空,允许 MODIFIER/ACTION
var branch_ids: Array = []
for neighbor_idx in adj[slot_idx]:
var bp: Array = _collect_branch_path(neighbor_idx, adj, orig_in_degree, raw_nodes, in_branch_payload)
if not bp.is_empty():
var pid: int = SubPayloadRegistry.register(bp)
branch_ids.append(pid)
deck.sub_payload_ids.append(pid)
if not branch_ids.is_empty():
deck.nodes.append(_make_fork_node(branch_ids))
elif node != null:
deck.nodes.append(node)
# else:空的非分叉槽,跳过
## 从 start_idx 沿单出边收集分支节点序列(P6-N66 嵌套分叉递归)
## 终止:叶节点(出度0/ 汇聚点(orig_in_degree>1,交主链处理)/ 嵌套分叉(出度>1,递归+插 LOGIC_FORK
func _collect_branch_path(start_idx: int, adj: Array, orig_in_degree: Array,
raw_nodes: Array, in_branch_payload: Dictionary) -> Array:
var path: Array = []
var cur: int = start_idx
var visited: Dictionary = {}
while cur >= 0 and not visited.has(cur):
visited[cur] = true
in_branch_payload[cur] = true # 登记已纳入 SubPayload,主链循环跳过
if cur < raw_nodes.size() and raw_nodes[cur] != null:
path.append(raw_nodes[cur])
if adj[cur].size() > 1:
# 嵌套分叉:每条子出边递归收集并注册,插入嵌套 LOGIC_FORK 后本路径结束
var nested_ids: Array = []
for nxt_idx in adj[cur]:
var np: Array = _collect_branch_path(nxt_idx, adj, orig_in_degree, raw_nodes, in_branch_payload)
if not np.is_empty():
nested_ids.append(SubPayloadRegistry.register(np))
if not nested_ids.is_empty():
path.append(_make_fork_node(nested_ids))
break
elif adj[cur].size() == 1:
var nxt: int = adj[cur][0]
if orig_in_degree[nxt] <= 1:
cur = nxt # 单入边,继续延伸
else:
break # 汇聚点(多入边),交主链处理
else:
break # 叶节点
return path
func _make_fork_node(branch_ids: Array) -> SpellNode:
var fork := SpellNode.new()
fork.type = SpellNode.SpellType.LOGIC
fork.id = "LOGIC_FORK"
fork.meta = {"fork_branch_ids": branch_ids}
return fork
## 解析单条边为 (from, to);兼容 {"from","to"} 字典(P6-N42 权威)与 [from, to] 数组对
func _edge_endpoints(e) -> Vector2i:
if e is Dictionary:
return Vector2i(int(e.get("from", -1)), int(e.get("to", -1)))
elif e is Array and e.size() >= 2:
return Vector2i(int(e[0]), int(e[1]))
return Vector2i(-1, -1)
# ── MATRIX 拓扑扁平化(architecture_design.md §3.4.A,邻接加成 P6-N13/P6-N20)────
## 仅 Row A(前 grid_cols 槽)进入执行序列;Row B 仅提供邻接加成,不独立执行(P6-N20)
func _flatten_matrix(raw_nodes: Array, core: CoreDefinition, deck: CompiledDeck) -> void:
var cols: int = max(1, core.grid_cols)
for i in cols:
var adj_idx: int = i + cols # Row B 中与 slot[i] 竖向对齐的槽
var row_a: SpellNode = raw_nodes[i] if i < raw_nodes.size() else null
var row_b: SpellNode = raw_nodes[adj_idx] if adj_idx < raw_nodes.size() else null
if row_a == null:
continue # 空槽不传递邻接(P6-N13
if row_b != null and _has_adjacency_bonus(row_a, row_b):
deck.nodes.append(_make_adjacency_mod(row_a, row_b)) # 注入隐式 MODIFIER(在 Row A 前)
deck.nodes.append(row_a) # 仅追加 Row A 节点
## 邻接加成是否成立(P6-N13;LOGIC / 空槽不参与)
func _has_adjacency_bonus(a: SpellNode, b: SpellNode) -> bool:
if a == null or b == null:
return false
if a.type == SpellNode.SpellType.LOGIC or b.type == SpellNode.SpellType.LOGIC:
return false
var A := SpellNode.SpellType
if a.type == A.ACTION and b.type == A.ACTION:
return a.id == b.id # 同 ID ACTION 对齐 → 强化同类弹
if a.type == A.ACTION and b.type == A.MODIFIER:
return true # 增幅器加倍
if a.type == A.MODIFIER and b.type == A.MODIFIER:
return a.id == b.id # 同 ID MODIFIER 对齐 → 共鸣修正
return false
## 生成隐式邻接 MODIFIER 节点(P6-N13 效果表)
func _make_adjacency_mod(a: SpellNode, b: SpellNode) -> SpellNode:
var m := SpellNode.new()
m.type = SpellNode.SpellType.MODIFIER
m.id = "implicit_adjacency"
m.display_name = "邻接加成"
var A := SpellNode.SpellType
if a.type == A.ACTION and b.type == A.ACTION:
m.meta = {"damage_mult": 1.5} # 同类弹 ×1.5
elif a.type == A.MODIFIER and b.type == A.MODIFIER:
# 同 ID MODIFIER 对齐 → 数值 ×2(平直叠加):复制 b 的可加字段
m.meta = b.meta.duplicate()
else:
# ACTION + MODIFIER:注入 B 的修正效果(作用于对齐的 Row A 动作)
m.meta = b.meta.duplicate()
return m
# ── 共鸣系统(§3.4.C_consumed 掩码 P6-N29)─────────────────────
## 在扁平化后的 deck.nodes 上做模式匹配;命中 adjacent 配方时注入结果节点并标记 consume
func _check_resonance(deck: CompiledDeck) -> void:
if _resonance_recipes.is_empty() or deck.nodes.is_empty():
return
var nodes: Array = deck.nodes
var consumed: PackedByteArray = PackedByteArray()
consumed.resize(nodes.size())
for recipe in _resonance_recipes:
if String(recipe.get("match", "adjacent")) != "adjacent":
continue # anywhere_in_deck 留 stub(仅传说配方)
var pattern: Array = recipe.get("pattern", [])
if pattern.size() < 2:
continue
var i: int = 0
while i < nodes.size():
if consumed[i] != 0 or not _node_has_tag(nodes[i], pattern[0]):
i += 1
continue
# 向右扫描 i+1..i+2(跳过 MODIFIER),寻找 pattern[1]
var hit_j: int = -1
for j in range(i + 1, min(i + 3, nodes.size())):
if consumed[j] != 0:
continue
if nodes[j].type == SpellNode.SpellType.MODIFIER:
continue # MODIFIER 不参与匹配,但不中断扫描
if _node_has_tag(nodes[j], pattern[1]):
hit_j = j
break # 遇到非 MODIFIER 非目标节点即停(不跨 ACTION/TRIGGER
if hit_j < 0:
i += 1
continue
var result: SpellNode = SpellRegistry.get_spell(String(recipe.get("result_spell_id", "")))
if result == null:
i += 1
continue
# 在 i 位置插入共鸣结果,同步扩展 consumed
nodes.insert(i, result)
consumed.insert(i, 0)
if bool(recipe.get("consume_inputs", false)):
consumed[i + 1] = int(1) # 原 pattern[0](现 i+1
consumed[hit_j + 1] = int(1) # 原 pattern[1](现 hit_j+1
i += 2 # 跳过刚插入的结果与已消费的 pattern[0]
# 收集 consume 索引供运行时 SpellDeck 跳过
for k in consumed.size():
if consumed[k] != 0:
deck.consumed_indices.append(k)
## 标签匹配:tag:xxx 查 element_tags;否则按 id 精确匹配
func _node_has_tag(node: SpellNode, tag_pattern: String) -> bool:
if node == null:
return false
if tag_pattern.begins_with("tag:"):
return tag_pattern in node.element_tags
return node.id == tag_pattern
## execute_compiled(compiled, caster_id, spawn_pos)
## 每次施法可执行的 ACTION 数量 = 1 + multicast_count
func execute_compiled(compiled: CompiledDeck, caster_id: int, spawn_pos: Vector2) -> void:
if compiled == null or compiled.is_empty():
return
var persistent: bool = (compiled.feature_tags & CoreFeatureTag.PERSISTENT_MEMORY) != 0
var ctx: SpellContext = _acquire_ctx(caster_id, persistent)
var deck: SpellDeck = compiled.make_runtime_deck()
var ops_count: int = 0
var max_ops: int = MAX_OPS_PER_CPU * 5
var actions_remaining: int = 1 # 初始为 1,由 multicast_count 加成
while deck.has_next() and ops_count < max_ops:
ops_count += 1
var node: SpellNode = deck.pop()
match node.type:
SpellNode.SpellType.ACTION:
_push_projectile(node, ctx, spawn_pos, -1)
actions_remaining -= 1
if actions_remaining <= 0:
break
SpellNode.SpellType.MODIFIER:
_apply_modifier(node, ctx)
# multicast_count 已在 _apply_modifier 内更新
actions_remaining = 1 + ctx.stats.multicast_count
SpellNode.SpellType.TRIGGER:
_push_trigger(node, ctx, spawn_pos)
actions_remaining -= 1
if actions_remaining <= 0:
break
SpellNode.SpellType.LOGIC:
if node.meta.has("fork_branch_ids"):
# CIRCUIT 分叉:各分支在施法点立即并行执行
for bid in node.meta["fork_branch_ids"]:
_run_branch_payload(int(bid), ctx, spawn_pos)
elif String(node.meta.get("logic_op", "")) == "loop":
ops_count = _run_logic_loop(node, ctx, spawn_pos, deck, ops_count, max_ops)
break # LOOP 已消耗剩余节点
elif _eval_logic(node, ctx, spawn_pos) == _LOGIC_SKIP_REST:
break # 条件不成立 → 跳过后续法术
if ops_count >= max_ops:
push_warning("SpellEvaluator: MAX_OPS reached (caster=%d)" % caster_id)
_release_ctx(ctx, persistent)
## execute_sub — 子荷载执行(MAX_TRIGGER_DEPTH 限制)
## depth 表示当前嵌套深度(0=顶层子荷载)
func execute_sub(payload_id: int, hit_pos: Vector2, owner_id: int, depth: int) -> void:
if depth >= MAX_TRIGGER_DEPTH:
EventBus.emit(EventID.SPELL_DEPTH_EXCEEDED, {"owner_id": owner_id, "depth": depth})
return
var nodes: Array = SubPayloadRegistry.get_nodes(payload_id)
if nodes.is_empty():
return
var ctx: SpellContext = SpellContextPool.acquire(owner_id) # P-S3-04: 从池取用
var ops: int = 0
var actions_remaining: int = 1
for node in nodes:
ops += 1
if ops > MAX_OPS_PER_CPU * 2:
break
match node.type:
SpellNode.SpellType.ACTION:
_push_projectile(node, ctx, hit_pos, depth)
actions_remaining -= 1
if actions_remaining <= 0:
break
SpellNode.SpellType.MODIFIER:
_apply_modifier(node, ctx)
actions_remaining = 1 + ctx.stats.multicast_count
SpellNode.SpellType.TRIGGER:
_push_trigger(node, ctx, hit_pos, depth)
actions_remaining -= 1
if actions_remaining <= 0:
break
SpellContextPool.release(ctx)
# ── 内部方法 ──────────────────────────────────────────────
func _push_projectile(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2, trigger_depth: int) -> void:
# ACTION 分派:zone(地面效果)/ summon(召唤物)/ 默认弹道
match String(node.meta.get("action_kind", "")):
"zone":
_spawn_zone_action(node, ctx, spawn_pos)
return
"summon":
_spawn_minion_action(node, ctx, spawn_pos)
return
var meta: Dictionary = node.meta
var speed: float = float(meta.get("speed", 300.0))
var lifetime: float = float(meta.get("lifetime", 4.0))
var radius: float = float(meta.get("radius", 6.0))
var base_dmg: float = float(meta.get("base_damage", 3.0))
var dtype: int = int(meta.get("damage_type", 0))
var spread: int = max(1, ctx.stats.spread_count)
var aim_dir: Vector2 = _get_aim_direction(spawn_pos)
for i in spread:
var angle_offset: float = 0.0
if spread > 1:
angle_offset = (float(i) / float(spread - 1) - 0.5) * 0.5
var vel: Vector2 = aim_dir.rotated(angle_offset) * speed * ctx.stats.speed_mult
var actual_dmg: float = (base_dmg + ctx.stats.damage_add) * ctx.stats.damage_mult
var actual_r: float = radius * ctx.stats.radius_mult
var pierce: int = int(meta.get("pierce", 0))
var cold: Dictionary = {}
if pierce > 0:
cold["pierce_remaining"] = pierce
# S3: 将 trigger_depth 写入冷数据供子弹命中时使用
if trigger_depth >= 0:
cold["trigger_depth"] = trigger_depth
if meta.has("apply_status_id"):
cold["apply_status_id"] = int(meta["apply_status_id"])
if meta.get("apply_combo_mark", false):
cold["apply_combo_mark"] = true
BulletManager.spawn_bullet(
spawn_pos, vel,
lifetime + ctx.stats.lifetime,
actual_r, actual_dmg, 1.0, dtype, ctx.caster_id,
0, 0.0, cold
)
## ACTION(zone):在施法点生成地面效果区域(action_poison_pool 等)
func _spawn_zone_action(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2) -> void:
var meta: Dictionary = node.meta
var radius: float = float(meta.get("radius", 200.0)) * ctx.stats.radius_mult
ZoneManager.spawn_zone(
spawn_pos.x, spawn_pos.y, radius,
int(meta.get("status_id", StatusID.POISON)),
float(meta.get("duration", 5.0)) + ctx.stats.lifetime,
float(meta.get("tick_interval", 1.0)),
ctx.caster_id)
## ACTION(summon):在施法点召唤一个炮台(action_summon_turret 等)
func _spawn_minion_action(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2) -> void:
var meta: Dictionary = node.meta
var def: Dictionary = {
"lifetime": float(meta.get("lifetime", 20.0)),
"range": float(meta.get("range", 350.0)),
"fire_interval": float(meta.get("fire_interval", 0.8)),
"damage": float(meta.get("base_damage", 4.0)) + ctx.stats.damage_add,
"bullet_speed": float(meta.get("speed", 360.0)),
"damage_type": int(meta.get("damage_type", 0)),
}
MinionManager.spawn_minion(def, ctx.caster_id, spawn_pos)
## TRIGGER 节点:发射子弹+子荷载参数
func _push_trigger(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2, trigger_depth: int = 0) -> void:
var payload_id: int = int(node.meta.get("sub_payload_id", -1))
if payload_id < 0 or not SubPayloadRegistry.has_payload(payload_id):
return
var meta: Dictionary = node.meta
var speed: float = float(meta.get("speed", 250.0))
var lifetime: float = float(meta.get("lifetime", 4.0))
var radius: float = float(meta.get("radius", 6.0))
var base_dmg: float = float(meta.get("base_damage", 3.0))
var dtype: int = int(meta.get("damage_type", 0))
var aim_dir: Vector2 = _get_aim_direction(spawn_pos)
var vel: Vector2 = aim_dir * speed
var actual_dmg: float = (base_dmg + ctx.stats.damage_add) * ctx.stats.damage_mult
var actual_r: float = radius * ctx.stats.radius_mult
var cold: Dictionary = {
"on_hit_payload_id": payload_id,
"trigger_parent_depth": trigger_depth,
}
BulletManager.spawn_bullet(
spawn_pos, vel,
lifetime, actual_r, actual_dmg, 1.0, dtype, ctx.caster_id,
0, 0.0, cold
)
## MODIFIER 分支
func _apply_modifier(node: SpellNode, ctx: SpellContext) -> void:
var meta: Dictionary = node.meta
if meta.has("damage_add"):
ctx.stats.damage_add += float(meta["damage_add"])
if meta.has("damage_mult"):
ctx.stats.damage_mult *= float(meta["damage_mult"])
if meta.has("spread_add"):
ctx.stats.spread_count += int(meta["spread_add"])
if meta.has("speed_mult"):
ctx.stats.speed_mult *= float(meta["speed_mult"])
if meta.has("lifetime_add"):
ctx.stats.lifetime += float(meta["lifetime_add"])
if meta.has("multicast"):
ctx.stats.multicast_count += int(meta["multicast"])
## ── 上下文获取(区分跨帧持久 / 池化)─────────────────────────
func _acquire_ctx(caster_id: int, persistent: bool) -> SpellContext:
if persistent:
var ctx: SpellContext = _persistent_ctx.get(caster_id, null)
if ctx == null:
ctx = SpellContext.new()
_persistent_ctx[caster_id] = ctx
ctx.reset() # 重置 statsregisters 保留(reset() 不触碰 registers
ctx.caster_id = caster_id
return ctx
return SpellContextPool.acquire(caster_id, false)
func _release_ctx(ctx: SpellContext, persistent: bool) -> void:
if not persistent:
SpellContextPool.release(ctx)
# 持久上下文留在 _persistent_ctx 中,registers 跨施法保留
## ── LOGIC 条件门求值 ────────────────────────────────────────
## 返回 _LOGIC_CONTINUE(执行后续)或 _LOGIC_SKIP_REST(跳过后续)
## EVERY_N_SHOTS 读写 ctx.registersP6-N37);持久化由 PERSISTENT_MEMORY Core 保证
func _eval_logic(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2) -> int:
var meta: Dictionary = node.meta
var op: String = String(meta.get("logic_op", ""))
match op:
"every_n_shots":
var n: int = max(1, int(meta.get("n", 3)))
var reg: int = clampi(int(meta.get("reg", 0)), 0, 3)
var count: int = int(ctx.registers[reg]) + 1
ctx.registers[reg] = float(count)
return _LOGIC_CONTINUE if (count % n == 0) else _LOGIC_SKIP_REST
"if_hp_below":
var threshold: float = float(meta.get("threshold", 0.5))
var frac: float = PlayerStats.hp / maxf(1.0, PlayerStats.hp_max)
return _LOGIC_CONTINUE if (frac < threshold) else _LOGIC_SKIP_REST
"if_enemy_nearby":
# 用 SpatialGrid 查询范围内是否有敌方实体(plan: LOGIC_IF_ENEMY_NEARBY
# 注意:query_circle 为格子粒度,略宽于精确圆,作为条件门可接受
var rng: float = float(meta.get("range", 200.0))
var near: bool = SpatialGrid.query_circle(spawn_pos, rng).size() > 0
return _LOGIC_CONTINUE if near else _LOGIC_SKIP_REST
_:
push_warning("SpellEvaluator: 未知 logic_op '%s'" % op)
return _LOGIC_CONTINUE
## ── LOGIC_LOOP:将后续剩余节点重复执行 count 次 ──────────────
## 简化实现:LOOP 体内不再处理嵌套 LOGIC(顺延后续迭代);尊重 ops 预算
## 返回更新后的 ops_count
func _run_logic_loop(node: SpellNode, ctx: SpellContext, spawn_pos: Vector2,
deck: SpellDeck, ops_count: int, max_ops: int) -> int:
var count: int = clampi(int(node.meta.get("count", 2)), 1, 16)
var body: Array = []
while deck.has_next():
body.append(deck.pop())
for _rep in count:
for n in body:
ops_count += 1
if ops_count >= max_ops:
return ops_count
match n.type:
SpellNode.SpellType.ACTION:
_push_projectile(n, ctx, spawn_pos, -1)
SpellNode.SpellType.MODIFIER:
_apply_modifier(n, ctx)
SpellNode.SpellType.TRIGGER:
_push_trigger(n, ctx, spawn_pos)
# LOGIC:LOOP 体内嵌套逻辑顺延(S5 后续迭代)
return ops_count
## ── CIRCUIT 分支执行(LOGIC_FORK 触发)──────────────────────
## 在施法点立即执行某分支 SubPayload 的节点;分支内 MODIFIER 作用域隔离(不泄漏给兄弟分支/主链)
## 嵌套 LOGIC_FORK 递归展开(P6-N66
func _run_branch_payload(payload_id: int, ctx: SpellContext, spawn_pos: Vector2) -> void:
var nodes: Array = SubPayloadRegistry.get_nodes(payload_id)
if nodes.is_empty():
return
# 快照分支前 CastStats(8 字段,无分配),分支结束后还原 → 分支局部 MODIFIER 不外泄
var s: CastStats = ctx.stats
var b_dadd: float = s.damage_add
var b_dmul: float = s.damage_mult
var b_spr: int = s.spread_count
var b_mc: int = s.multicast_count
var b_life: float = s.lifetime
var b_spd: float = s.speed_mult
var b_rad: float = s.radius_mult
var b_crit: float = s.crit_chance
for node in nodes:
match node.type:
SpellNode.SpellType.ACTION:
_push_projectile(node, ctx, spawn_pos, -1)
SpellNode.SpellType.MODIFIER:
_apply_modifier(node, ctx)
SpellNode.SpellType.TRIGGER:
_push_trigger(node, ctx, spawn_pos)
SpellNode.SpellType.LOGIC:
if node.meta.has("fork_branch_ids"):
for bid in node.meta["fork_branch_ids"]:
_run_branch_payload(int(bid), ctx, spawn_pos)
elif _eval_logic(node, ctx, spawn_pos) == _LOGIC_SKIP_REST:
break
# 还原 stats
s.damage_add = b_dadd
s.damage_mult = b_dmul
s.spread_count = b_spr
s.multicast_count = b_mc
s.lifetime = b_life
s.speed_mult = b_spd
s.radius_mult = b_rad
s.crit_chance = b_crit
func _get_aim_direction(from_pos: Vector2) -> Vector2:
var target: Vector2 = EnemyManager.get_nearest_pos(from_pos, 9999.0)
if target.distance_to(from_pos) < 1.0:
return Vector2.RIGHT
return (target - from_pos).normalized()
func get_pool_stats() -> String:
return "SpellCtx: " + str(SpellContextPool.get_available_count()) + "/" + str(SpellContextPool.POOL_SIZE)
@@ -0,0 +1 @@
uid://dmjvhkuljlffi
+27
View File
@@ -0,0 +1,27 @@
## SpellNode — 法术节点 Resource(可加载 .tres
## 权威来源:architecture_design.md §3.2
extends Resource
class_name SpellNode
## 法术节点类型枚举
## 使用方式:SpellNode.SpellType.ACTION
enum SpellType {
ACTION = 0, # 发射弹道
MODIFIER = 1, # 修改 CastStatsS3 实现)
TRIGGER = 2, # 命中触发 SubPayloadS3 实现)
LOGIC = 3, # 条件指令(S5 实现)
SCOPE_CLOSE = 4, # 虚拟节点:SCOPE 边界标记(预编译时注入)
}
## 全局唤一法术 ID(格式建议: category_name
@export var id: String = ""
@export var type: int = SpellType.ACTION
@export var display_name: String = ""
@export var description: String = ""
## 法术类型相关参数(不同类型内容不同)
@export var meta: Dictionary = {}
## 元素亲和标签(供共鸣系统 §3.4.C 模式匹配,如 ["tag:water"]、["tag:lightning"]
@export var element_tags: Array = []
func _to_string() -> String:
return "SpellNode(%s, type=%d)" % [id, type]
@@ -0,0 +1 @@
uid://wl3m5km246d8
@@ -0,0 +1,47 @@
## SubPayloadRegistry — 子荷载登记表(Autoload: SubPayloadRegistry
## 权威来源:architecture_design.md §3.4.A
## 生命周期:
## - compile_wand 开始时调用 clear_for_wand(wand_id),清除该法杯旧荷载
## - 关卡进入 BATTLE 后,SubPayloadRegistry 进入只读状态(P6-N10
## - 战斗中敌人死亡触发的旧子荷载居然能正常结算
extends Node
const MAX_PAYLOAD_ID: int = 1024
## { payload_id: int → Array[SpellNode] }
var _payloads: Dictionary = {}
var _next_id: int = 0
var _read_only: bool = false # BATTLE 期间禁止修改
## compile_wand 开始时调用,清除属于该法杯的旧荷载
func clear_for_compile() -> void:
_payloads.clear()
_next_id = 0
_read_only = false
## 注册一组节点为子荷载,返回 payload_id
func register(nodes: Array) -> int:
if _read_only:
push_warning("SubPayloadRegistry: 战斗中禁止注册新荷载 (P6-N10)")
return -1
if _next_id >= MAX_PAYLOAD_ID:
push_error("SubPayloadRegistry: 已达上限 MAX_PAYLOAD_ID=%d" % MAX_PAYLOAD_ID)
return -1
var id: int = _next_id
_next_id += 1
_payloads[id] = nodes
return id
## 关卡开始后锁定登记表,全局只读
func lock_for_battle() -> void:
_read_only = true
## 查找子荷载节点列表(第三方不得修改返回值)
func get_nodes(payload_id: int) -> Array:
return _payloads.get(payload_id, [])
func has_payload(payload_id: int) -> bool:
return _payloads.has(payload_id)
func get_payload_count() -> int:
return _payloads.size()
@@ -0,0 +1 @@
uid://b6h6agfanqjuw
+21
View File
@@ -0,0 +1,21 @@
## StatusInstance — 运行时状态实例(平铺数组元素)
## 权威来源:combat_mechanics_depth.md §3.2.5
extends RefCounted
class_name StatusInstance
var entity_id: int = -1
var status_type_id: int = 0
var stacks: int = 1
var remaining_duration: float = 0.0
var tick_accumulator: float = 0.0
var owner_id: int = -1 # Root Source(击杀归属)
var type_def: StatusTypeDef = null
func setup(entity: int, typedef: StatusTypeDef, stacks_count: int, dur: float, source: int) -> void:
entity_id = entity
status_type_id = typedef.id if typedef else 0
type_def = typedef
stacks = max(1, stacks_count)
remaining_duration = dur
tick_accumulator = 0.0
owner_id = source
+1
View File
@@ -0,0 +1 @@
uid://c14cny7agd11b
+23
View File
@@ -0,0 +1,23 @@
## StatusTypeDef — 状态效果类型定义(.tres 资源)
## 权威来源:combat_mechanics_depth.md §3.0、architecture_design.md §5.5
extends Resource
class_name StatusTypeDef
enum StackMode {
REFRESH = 0, # 刷新持续时间
INTENSITY = 1, # 叠层强度
INDEPENDENT = 2, # 独立实例
}
@export var id: int = 0
@export var display_name: String = ""
@export var duration: float = 3.0
@export var tick_interval: float = 1.0
@export var stack_mode: int = StackMode.REFRESH
@export var max_stacks: int = 99
@export var dot_damage_per_tick: float = 0.0
@export var dot_damage_type: int = 0
@export var can_catalyze: Array = []
@export var is_combo_tracker: bool = false
@export var vfx_id: String = ""
@export var icon_key: String = ""
+1
View File
@@ -0,0 +1 @@
uid://dgawg5w5hr5kx