Files
spellforge/docs_dev/plans/2026-07-21-meta-progression-mvp.md
T
2026-07-21 11:35:44 +08:00

22 KiB
Raw Blame History

元进展 MVP(碎片 + 解锁树·法术only)实现计划

For agentic workers: 用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现。步骤用 - [ ] 复选框跟踪。

Goal: 加入 Roguelite 长期粘性闭环——每局死亡按波次得以太碎片(全局持久),主菜单花碎片解锁进阶法术,解锁后的法术进入商店池;所有可调数值数据驱动、编辑器可调。

Architecture:MetaProgress autoload 管碎片+已解锁(user://meta.json);解锁费用在 spells.jsonmeta.unlock_cost(法术编辑器可调);碎片奖励曲线在 balance.json.aether_thresholds(平衡页签可调,SettingsManager.aether_for_wave 查表);商店池按解锁门控;死亡时 combat_manager 结算碎片。

Tech Stack: Godot 4.6 (Mono) · GDScript · 纯 JSON 数据驱动 · 验证用 Godot MCP(execute_game_script 游戏内 / execute_editor_script 编辑器内),本项目无单元测试框架。

验证约定:每任务的"测试"用 MCP 运行时断言。前置:Godot 编辑器开着、MCP 连通。改代码后 reload_project + get_editor_errors0+ play_scene(main)execute_game_script 调 autoload;编辑器 @tool 改动用 execute_editor_script + CACHE_MODE_IGNORE fresh-load 往返(绕过 @tool 热重载缓存)。跑完 stop_scene


Task 1: MetaProgress autoload(碎片+已解锁+持久化)

Files:

  • Create: scripts/autoloads/meta_progress.gd

  • Modify: project.godot[autoload] 段注册)

  • Step 1: 新建 meta_progress.gd

## MetaProgress — 全局元进展(Autoload: MetaProgress
## 以太碎片 + 已解锁法术,持久化 user://meta.json(死亡不重置)。
extends Node

const PATH: String = "user://meta.json"

var aether: int = 0
var unlocked: Array = []   # 已解锁法术 id (String)

func _ready() -> void:
	load_meta()

func add_aether(n: int) -> void:
	if n <= 0:
		return
	aether += n
	save_meta()

func is_unlocked(id: String) -> bool:
	return id in unlocked

## 已解锁 或 碎片不足 → false(不扣费);否则扣费+记录+存档 → true
func unlock(id: String, cost: int) -> bool:
	if is_unlocked(id) or aether < cost:
		return false
	aether -= cost
	unlocked.append(id)
	save_meta()
	return true

func clear() -> void:
	aether = 0
	unlocked = []
	if FileAccess.file_exists(PATH):
		DirAccess.remove_absolute(ProjectSettings.globalize_path(PATH))

func save_meta() -> void:
	var f := FileAccess.open(PATH, FileAccess.WRITE)
	if f:
		f.store_string(JSON.stringify({"aether": aether, "unlocked": unlocked}))
		f.close()

func load_meta() -> void:
	aether = 0
	unlocked = []
	if not FileAccess.file_exists(PATH):
		return
	var parsed = JSON.parse_string(FileAccess.get_file_as_string(PATH))
	if not (parsed is Dictionary):
		return
	aether = int(parsed.get("aether", 0))
	var u = parsed.get("unlocked", [])
	if u is Array:
		for id in u:
			unlocked.append(String(id))
  • Step 2: 注册 autoload

project.godot [autoload] 段,EndlessRecords="*res://scripts/autoloads/endless_records.gd" 一行之后加:

MetaProgress="*res://scripts/autoloads/meta_progress.gd"
  • Step 3: 验证(MCPplay_scene(main) 后:
MetaProgress.clear()
MetaProgress.add_aether(10)
_mcp_print("add10 -> aether=%d file=%s (expect 10/true)" % [MetaProgress.aether, str(FileAccess.file_exists("user://meta.json"))])
_mcp_print("unlock frost(5) -> %s aether=%d unlocked=%s (expect true/5/[frost])" % [str(MetaProgress.unlock("action_frost_bolt", 5)), MetaProgress.aether, str(MetaProgress.unlocked)])
_mcp_print("unlock chain(8) -> %s aether=%d (expect false/5 不足)" % [str(MetaProgress.unlock("action_chain_bolt", 8)), MetaProgress.aether])
_mcp_print("unlock frost again -> %s (expect false 已解锁)" % str(MetaProgress.unlock("action_frost_bolt", 5)))
MetaProgress.aether = 0; MetaProgress.unlocked = []
MetaProgress.load_meta()
_mcp_print("reload -> aether=%d unlocked=%s (expect 5/[frost] 往返)" % [MetaProgress.aether, str(MetaProgress.unlocked)])
MetaProgress.clear()
_mcp_print("clear -> aether=%d unlocked=%s file=%s (expect 0/[]/false)" % [MetaProgress.aether, str(MetaProgress.unlocked), str(FileAccess.file_exists("user://meta.json"))])

Expected: 全部符合括号。get_editor_errors=0。

  • Step 4: Commit
git add scripts/autoloads/meta_progress.gd project.godot
git commit -m "feat(meta): MetaProgress autoload(碎片+已解锁+持久化 user://meta.json"

Task 2: 碎片奖励曲线(balance.json 阈值表 + SettingsManager+ 清除数据钩子

Files:

  • Modify: data/balance.json

  • Modify: scripts/autoloads/settings_manager.gd

  • Step 1: balance.json 加阈值表

data/balance.json 整体替换为:

{
  "difficulty_mults": {
    "enemy_hp":   [0.7, 1.0, 1.0],
    "player_dmg": [0.7, 1.0, 1.0],
    "wave_count": [1.0, 1.0, 1.2],
    "boss_hp":    [1.0, 1.0, 1.3]
  },
  "boss_hp": {
    "4": 450.0,
    "5": 1800.0
  },
  "aether_thresholds": [[1, 0], [5, 3], [10, 8], [15, 14], [20, 20]]
}
  • Step 2: SettingsManager 加载 + 查表

settings_manager.gd 第 35 行 var _BOSS_HP: Dictionary = {} 之后加:

var _AETHER_THRESHOLDS: Array = []   # [[到达波次, 碎片], ...] 升序

_load_balance()if data.has("boss_hp") ...: _BOSS_HP = data["boss_hp"] 之后加:

	if data.has("aether_thresholds") and data["aether_thresholds"] is Array:
		_AETHER_THRESHOLDS = data["aether_thresholds"]

get_boss_base_hp() 函数之后加方法:

## 到达波次 → 以太碎片(阶梯查表:取"波次阈值 ≤ wave"的最高阶梯值;升序表)
func aether_for_wave(wave: int) -> int:
	var table: Array = _AETHER_THRESHOLDS
	if table.is_empty():
		table = [[1, 0], [5, 3], [10, 8], [15, 14], [20, 20]]
	var result: int = 0
	for entry in table:
		if entry is Array and entry.size() >= 2 and int(entry[0]) <= wave:
			result = int(entry[1])
	return result
  • Step 3: 清除数据钩子

clear_all_local_data()_apply_audio() 调用之前(第 114 行前)加:

	MetaProgress.clear()
  • Step 4: 验证(MCP
for w in [1, 5, 10, 12, 15, 20, 25]:
	_mcp_print("wave %d -> aether %d" % [w, SettingsManager.aether_for_wave(w)])

Expected: 1→0, 5→3, 10→8, 12→8, 15→14, 20→20, 25→20。get_editor_errors=0。

  • Step 5: Commit
git add data/balance.json scripts/autoloads/settings_manager.gd
git commit -m "feat(meta): 碎片奖励阈值表(balance.json)+aether_for_wave+清除数据钩子"

Task 3: spells.json 加 unlock_cost6 张锁定法术)

Files:

  • Modify: data/spells.json

  • Step 1: 给 6 张法术 meta 加 unlock_cost

在下列法术的 meta 对象里加 "unlock_cost": N(放 shop_cost 之前):

id unlock_cost
action_energy_orb 4
action_frost_bolt 5
action_poison_pool 6
logic_loop 6
action_chain_bolt 8
action_summon_turret 10

示例(action_chain_bolt 一行):

  "action_chain_bolt":    { "type": 0, "display_name": "Chain Bolt",   "description": "连锁闪电(雷元素),与水共鸣成等离子风暴。", "element_tags": ["tag:lightning"], "meta": { "base_damage": 3.0, "speed": 420.0, "lifetime": 3.0, "radius": 6.0, "damage_type": 0, "mana_cost": 60, "unlock_cost": 8, "shop_cost": 20 } },

注意:mana_cost 已在(来自 Mana MVP),别删;unlock_cost 加在其后、shop_cost 前。其余 13 张不加(= 开局解锁)。

  • Step 2: 验证(MCP
for id in ["action_energy_orb","action_frost_bolt","action_poison_pool","logic_loop","action_chain_bolt","action_summon_turret","action_spark_bolt"]:
	var s = SpellRegistry.get_spell(id)
	if s: _mcp_print("%s unlock_cost=%s" % [id, str(s.meta.get("unlock_cost", 0))])

Expected: energy_orb=4, frost=5, poison_pool=6, loop=6, chain=8, turret=10, spark_bolt=0(未加)。get_editor_errors=0JSON 合法)。

  • Step 3: Commit
git add data/spells.json
git commit -m "feat(meta): spells.json 6 张进阶法术加 unlock_cost(解锁树)"

Task 4: 商店池按解锁门控

Files:

  • Modify: scripts/autoloads/shop_manager.gd:33-46_refresh_slots,抽出可测的 _available_pool

  • Step 1: 抽出 _available_pool 并加门控

_refresh_slots() 开头的 pool 构造(第 34-37 行)替换为调用新方法,并在其上方新增方法。即将:

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)

改为:

## 商店可抽池:shop_cost>0 且(未门控 unlock_cost==0 或 已解锁)
func _available_pool() -> Array:
	return SpellRegistry.get_all_ids().filter(func(id):
		var s: SpellNode = SpellRegistry.get_spell(id)
		if s == null or int(s.meta.get("shop_cost", 0)) <= 0:
			return false
		var uc: int = int(s.meta.get("unlock_cost", 0))
		return uc == 0 or MetaProgress.is_unlocked(id))

func _refresh_slots() -> void:
	var pool: Array = _available_pool()

(其余 _refresh_slots 逻辑不变。)

  • Step 2: 验证(MCP
MetaProgress.clear()   # unlocked 空
var pool1 = ShopManager._available_pool()
_mcp_print("locked chain in pool=%s (expect false)" % str(pool1.has("action_chain_bolt")))
_mcp_print("basic spark in pool=%s (expect true)" % str(pool1.has("action_spark_bolt")))
MetaProgress.unlock("action_chain_bolt", 0)   # 强制解锁(cost0 便于测试)
var pool2 = ShopManager._available_pool()
_mcp_print("after unlock, chain in pool=%s (expect true)" % str(pool2.has("action_chain_bolt")))
MetaProgress.clear()

Expected: false / true / true。get_editor_errors=0。

  • Step 3: Commit
git add scripts/autoloads/shop_manager.gd
git commit -m "feat(meta): 商店池按 unlock_cost/已解锁门控(_available_pool"

Task 5: 死亡结算发碎片(combat_manager

Files:

  • Modify: scripts/domain/combat/combat_manager.gd:230-246_on_player_died

  • Step 1: 加碎片结算

_on_player_died()EndlessRecords.add_record(wave_reached, elapsed, kills)(第 237 行)之后加:

	MetaProgress.add_aether(SettingsManager.aether_for_wave(wave_reached))
  • Step 2: 验证(MCP,需战斗场景)

play_scene(main)execute_game_script: SceneManager.start_new_game()。下一次调用:

var cs = get_tree().current_scene
MetaProgress.clear()
WaveManager.current_wave = 10
cs._cm._run_start_time = Time.get_ticks_msec() / 1000.0
cs._cm._on_player_died({})
_mcp_print("died at W10 -> aether=%d (expect 8)" % MetaProgress.aether)
MetaProgress.clear()

Expected: aether=8get_editor_errors=0。stop_scene

  • Step 3: Commit
git add scripts/domain/combat/combat_manager.gd
git commit -m "feat(meta): 死亡按到达波次结算以太碎片"

Task 6: i18n 键(META_* / MENU_UNLOCK4 语言)

Files:

  • Modify: translations/zh_CN.po, zh_TW.po, en.po, ja.po

  • Step 1: 各 .po 末尾追加键

zh_CN.po 追加:

msgid "MENU_UNLOCK"
msgstr "🔓 解锁"

msgid "META_TITLE"
msgstr "元进展 · 解锁"

msgid "META_AETHER"
msgstr "以太碎片: %d"

msgid "META_UNLOCK_BTN"
msgstr "解锁 (%d)"

msgid "META_UNLOCKED"
msgstr "已解锁"

msgid "META_CLOSE"
msgstr "关闭"

zh_TW.po 追加(同键,繁体):🔓 解鎖 / 元進展 · 解鎖 / 以太碎片: %d / 解鎖 (%d) / 已解鎖 / 關閉en.po 追加:🔓 Unlocks / Meta · Unlocks / Aether: %d / Unlock (%d) / Unlocked / Closeja.po 追加:🔓 アンロック / メタ進行 · アンロック / エーテル: %d / アンロック (%d) / 解放済み / 閉じる

  • Step 2: 验证(MCP
for k in ["MENU_UNLOCK","META_TITLE","META_AETHER","META_UNLOCK_BTN","META_UNLOCKED","META_CLOSE"]:
	_mcp_print("%s -> %s" % [k, TranslationServer.translate(k)])

Expected: 每个键返回中文译文(非键本身,zh_CN 下)。get_editor_errors=0。

  • Step 3: Commit
git add translations/zh_CN.po translations/zh_TW.po translations/en.po translations/ja.po
git commit -m "feat(meta): META_*/MENU_UNLOCK 四语 i18n 键"

Task 7: 主菜单「🔓 解锁」按钮 + 解锁面板

Files:

  • Modify: scenes/ui/main_menu.gd

  • Step 1: 成员 + _ready + 菜单项 + ESC + 语言重建

a)在顶部成员区(var _ftue_layer: CanvasLayer = null 之后)加:

var _unlock_layer: CanvasLayer = null
var _unlock_root: Control = null

b)在 _ready()_build_ftue_panel() 之后加 _build_unlock_panel()c)在 _build_menu()items 数组里,["MENU_LEADERBOARD", _on_leaderboard], 之后插入:

		["MENU_UNLOCK", _on_unlock],

d)在 _input()elif _leaderboard_layer and _leaderboard_layer.visible: 分支之后加:

		elif _unlock_layer and _unlock_layer.visible:
			_unlock_layer.visible = false

e)在 _on_lang()_leaderboard_layer = null 之后加 _unlock_layer = null,并在末尾 _build_leaderboard_panel() 之后加 _build_unlock_panel()

  • Step 2: 回调 + 面板构建/刷新

在文件末尾追加:

# ── 解锁面板 ─────────────────────────────────────────────────
func _on_unlock() -> void:
	_unlock_layer.visible = true
	_rebuild_unlock()

func _build_unlock_panel() -> void:
	_unlock_layer = CanvasLayer.new()
	_unlock_layer.layer = 10
	_unlock_layer.visible = false
	add_child(_unlock_layer)
	var bg := ColorRect.new()
	bg.color = Color(0.0, 0.0, 0.0, 0.72)
	bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	_unlock_layer.add_child(bg)
	_unlock_root = Control.new()
	_unlock_layer.add_child(_unlock_root)

func _rebuild_unlock() -> void:
	for c in _unlock_root.get_children():
		c.queue_free()
	var panel := ColorRect.new()
	panel.color = Color(0.06, 0.06, 0.14, 0.98)
	panel.size = Vector2(560, 470)
	panel.position = Vector2(300, 90)
	_unlock_root.add_child(panel)
	var title := Label.new()
	title.text = tr("META_TITLE")
	title.position = Vector2(324, 106)
	title.add_theme_font_size_override("font_size", 28)
	title.modulate = Color.GOLD
	_unlock_root.add_child(title)
	var aether_lbl := Label.new()
	aether_lbl.text = tr("META_AETHER") % MetaProgress.aether
	aether_lbl.position = Vector2(324, 148)
	aether_lbl.add_theme_font_size_override("font_size", 18)
	aether_lbl.modulate = Color(0.6, 0.85, 1.0)
	_unlock_root.add_child(aether_lbl)
	var y: float = 188.0
	for id in SpellRegistry.get_all_ids():
		var s: SpellNode = SpellRegistry.get_spell(id)
		if s == null:
			continue
		var cost: int = int(s.meta.get("unlock_cost", 0))
		if cost <= 0:
			continue
		var name_lbl := Label.new()
		name_lbl.text = s.display_name
		name_lbl.position = Vector2(324, y + 4)
		name_lbl.add_theme_font_size_override("font_size", 16)
		_unlock_root.add_child(name_lbl)
		var btn := Button.new()
		btn.position = Vector2(560, y)
		btn.size = Vector2(230, 32)
		if MetaProgress.is_unlocked(id):
			btn.text = tr("META_UNLOCKED")
			btn.disabled = true
		else:
			btn.text = tr("META_UNLOCK_BTN") % cost
			btn.disabled = MetaProgress.aether < cost
			var sid := id
			var scost := cost
			btn.connect("pressed", func():
				if MetaProgress.unlock(sid, scost):
					AudioManager.play("buy")
					_rebuild_unlock())
		_unlock_root.add_child(btn)
		y += 40.0
	var close := Button.new()
	close.text = tr("META_CLOSE")
	close.position = Vector2(700, 508)
	close.size = Vector2(120, 36)
	close.connect("pressed", func(): _unlock_layer.visible = false)
	_unlock_root.add_child(close)
  • Step 3: 验证(MCP,编辑器内 fresh-load 往返 + 游戏内截图)

游戏内(play_scene(main),当前场景即 MainMenu):

var mm = get_tree().current_scene
MetaProgress.clear(); MetaProgress.add_aether(20)
mm._on_unlock()
_mcp_print("unlock panel visible=%s aether label ok=%s" % [str(mm._unlock_layer.visible), str(mm._unlock_root.get_child_count() > 3)])
# 找到第一个可点解锁按钮并点击(frost_bolt cost5aether20 够)
MetaProgress.unlock("action_frost_bolt", 5)
_mcp_print("after unlock frost: aether=%d is_unlocked=%s (expect 15/true)" % [MetaProgress.aether, str(MetaProgress.is_unlocked("action_frost_bolt"))])
MetaProgress.clear()

然后 get_game_screenshot 确认「元进展·解锁」面板渲染出碎片数 + 6 行锁定法术 + 解锁按钮。Expected: panel visible=true。get_editor_errors=0。stop_scene

  • Step 4: Commit
git add scenes/ui/main_menu.gd
git commit -m "feat(meta): 主菜单解锁面板(花碎片解锁法术)"

Task 8: 平衡页签支持 aether_thresholds(编辑器可调 + 保存不丢字段)

Files:

  • Modify: addons/game_designer/balance_tab.gd

  • Step 1: 加 JSON 文本框(读)+ 保存时写回

a)在成员区(var _boss: SpinBox 之后)加:

var _aether: TextEdit

b)在 _ready()add_child(HSeparator.new())(Boss 血量之后、保存按钮之前)与保存按钮之间加:

	var al := Label.new(); al.text = "碎片奖励阈值 aether_thresholdsJSON [[波次,碎片],...]"; add_child(al)
	_aether = TextEdit.new(); _aether.custom_minimum_size = Vector2(0, 50)
	_aether.text = JSON.stringify(_data.get("aether_thresholds", [[1, 0], [5, 3], [10, 8], [15, 14], [20, 20]]))
	add_child(_aether)

c)在 _save() 里,构造 out 时加入该字段:把

	var out := {
		"difficulty_mults": mults,
		"boss_hp": {"4": _miniboss.value, "5": _boss.value},
	}

改为:

	var thresholds = JSON.parse_string(_aether.text) if _aether.text.strip_edges() != "" else []
	if not (thresholds is Array):
		thresholds = []
	var out := {
		"difficulty_mults": mults,
		"boss_hp": {"4": _miniboss.value, "5": _boss.value},
		"aether_thresholds": thresholds,
	}
  • Step 2: 验证(编辑器 MCPfresh-load 往返)
var Tab = ResourceLoader.load("res://addons/game_designer/balance_tab.gd", "GDScript", ResourceLoader.CACHE_MODE_IGNORE)
var t = Tab.new(); EditorInterface.get_base_control().add_child(t)
_mcp_print("aether box has data=%s" % str(t._aether.text.contains("[")))
# 模拟保存构造的 out(不写盘):直接读 _aether 解析
var parsed = JSON.parse_string(t._aether.text)
_mcp_print("thresholds parse ok=%s first=%s (expect true/[1,0])" % [str(parsed is Array), str(parsed[0]) if parsed is Array else "N/A"])
t.free()

Expected: has data=trueparse ok=true first=[1, 0]get_editor_errors=0(清 stale 后)。

  • Step 3: Commit
git add addons/game_designer/balance_tab.gd
git commit -m "feat(meta): 平衡页签可编辑 aether_thresholds(保存不丢字段)"

Task 9: 更新 game_design §7 现状 callout

Files:

  • Modify: docs/design/game_design.md(§7 callout

  • Step 1: 替换 §7 callout

docs/design/game_design.md §7 的「本章整体未实现」callout 替换为:

> **⚠️ 实现现状 (2026-07-21 → MVP 部分实现)**:**碎片 + 法术解锁树已落地**——`MetaProgress` autoload`user://meta.json` 碎片+已解锁)、死亡按波次发碎片(`balance.json.aether_thresholds` 阈值表,平衡页签可调)、主菜单「🔓解锁」面板花碎片解锁进阶法术、商店池按 `spells.json` 的 `meta.unlock_cost` 门控。**暂缺**:核心解锁(依赖货架B)、挑战模式解锁、图鉴 Codex、成就/炼金日志、通关首胜奖励。详见 `docs_dev/specs/2026-07-21-meta-progression-mvp-design.md`。
  • Step 2: 验证git grep -n "本章整体未实现" docs/design/game_design.md 应无结果。

  • Step 3: Commit

git add docs/design/game_design.md
git commit -m "docs(meta): game_design §7 现状更新为碎片+解锁树已实现(MVP)"

Task 10: 全量回归验收

Files: 无(纯验证)

  • Step 1: 重载 + 0 错误reload_projectclear_outputget_editor_errors=0。

  • Step 2: 端到端闭环play_scene(main)):

MetaProgress.clear()
# 模拟一局到 W10 死亡发碎片
var cm_sm = SettingsManager.aether_for_wave(10)
MetaProgress.add_aether(cm_sm)
_mcp_print("W10 死亡得碎片=%d,总 aether=%d (expect 8/8)" % [cm_sm, MetaProgress.aether])
# 商店池此刻不含 chain_bolt
_mcp_print("池含 chain(锁定)=%s (expect false)" % str(ShopManager._available_pool().has("action_chain_bolt")))
# 花碎片解锁 chain(8)
_mcp_print("解锁 chain -> %saether=%d (expect true/0)" % [str(MetaProgress.unlock("action_chain_bolt", 8)), MetaProgress.aether])
# 现在池含 chain
_mcp_print("池含 chain(已解锁)=%s (expect true)" % str(ShopManager._available_pool().has("action_chain_bolt")))
MetaProgress.clear()

Expected: 8/8 → false → true/0 → true。

  • Step 3: DoD 核对spec §8)逐条确认。stop_scene

  • Step 4: Commit

git commit --allow-empty -m "chore(meta): 元进展 MVP 验收通过(Godot 4.6 运行时实测闭环全绿)"

自检结论

  • Spec 覆盖:§2.1→T1;§2.4/碎片曲线→T2/T5;§2.2 unlock_cost→T3;§2.3 门控→T4;解锁面板→T7;i18n→T6;编辑器可调(balance_tab)→T8;清除数据→T2docs→T9;测试→各任务+T10。无遗漏。
  • 无占位:每步含精确文件/行号/完整代码/期望输出。
  • 类型一致MetaProgress.aether/unlocked + add_aether/is_unlocked/unlock/clear/save_meta/load_metaSettingsManager.aether_for_waveShopManager._available_poolmeta.unlock_costbalance.json.aether_thresholds 全程一致。