初次提交

This commit is contained in:
2026-07-20 10:56:52 +08:00
commit 7bcc0026e0
462 changed files with 50191 additions and 0 deletions
+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