Add one-slot demo snapshot save and load.

This commit is contained in:
2026-08-13 13:31:33 +08:00
parent e9f5f81f46
commit 89b9173cec
7 changed files with 513 additions and 11 deletions
+3
View File
@@ -30,8 +30,11 @@ The demo is the physical substrate for a magical-law factory game, not the final
- `WASD`: move camera
- Mouse wheel: zoom
- `N`: restart the demo site
- `Esc`: pause, autosave, and open the snapshot menu
- `F1`: toggle developer statistics
One-slot snapshots live in the Godot user data folder as `mana_foundry_slot1.json`. The title screen **继续构筑** button restores construction, belts, machines, mana, items, and objective progress.
## Running
Open `project.godot` with Godot 4.7.1 stable, or run the workspace-local engine after Task 1 has completed.
+147 -5
View File
@@ -1,6 +1,7 @@
extends Node3D
const WorldSimulation = preload("res://src/simulation/world_simulation.gd")
const DemoSnapshot = preload("res://src/persistence/demo_snapshot.gd")
const CELL_SIZE := 2.0
const MAP_HALF_WIDTH := 10
@@ -113,6 +114,8 @@ var bridge_button: Button
var victory_shown := false
var selected_building_id := -1
var charger_machine_id := -1
var continue_button: Button
var save_status_label: Label
var stats_visible := true
var stats_refresh := 0.0
var last_tick_microseconds := 0
@@ -1068,6 +1071,7 @@ func _create_ui() -> void:
start_overlay = _create_start_overlay(canvas)
pause_overlay = _create_pause_overlay(canvas)
victory_overlay = _create_victory_overlay(canvas)
_refresh_continue_button()
func _add_tool_button(parent: Control, text: String, kind: StringName) -> void:
@@ -1093,7 +1097,7 @@ func _create_start_overlay(canvas: CanvasLayer) -> Control:
center.set_anchors_preset(Control.PRESET_FULL_RECT)
shade.add_child(center)
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(600.0, 370.0)
panel.custom_minimum_size = Vector2(600.0, 430.0)
panel.add_theme_stylebox_override("panel", _panel_style(Color("#0d222df2"), COLOR_CYAN, 2))
center.add_child(panel)
var box := VBoxContainer.new()
@@ -1120,8 +1124,14 @@ func _create_start_overlay(canvas: CanvasLayer) -> Control:
start_button.text = "开始构筑"
start_button.custom_minimum_size = Vector2(220.0, 48.0)
start_button.add_theme_font_size_override("font_size", 18)
start_button.pressed.connect(_start_game)
start_button.pressed.connect(_start_new_game)
box.add_child(start_button)
continue_button = Button.new()
continue_button.text = "继续构筑"
continue_button.custom_minimum_size = Vector2(220.0, 44.0)
continue_button.add_theme_font_size_override("font_size", 16)
continue_button.pressed.connect(_continue_game)
box.add_child(continue_button)
var note := _label("第一目标:接通魔力,再按 B 分别为两台机器指定取货与放货点", 12, COLOR_MUTED)
note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(note)
@@ -1140,12 +1150,12 @@ func _create_pause_overlay(canvas: CanvasLayer) -> Control:
center.set_anchors_preset(Control.PRESET_FULL_RECT)
shade.add_child(center)
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(360.0, 190.0)
panel.custom_minimum_size = Vector2(360.0, 320.0)
panel.add_theme_stylebox_override("panel", _panel_style(COLOR_PANEL, COLOR_VIOLET, 2))
center.add_child(panel)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 16)
box.add_theme_constant_override("separation", 10)
panel.add_child(box)
var title := _label("工坊已暂停", 26, COLOR_TEXT)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
@@ -1155,6 +1165,24 @@ func _create_pause_overlay(canvas: CanvasLayer) -> Control:
resume.custom_minimum_size = Vector2(180.0, 42.0)
resume.pressed.connect(_set_paused.bind(false))
box.add_child(resume)
var save_button := Button.new()
save_button.text = "保存工坊"
save_button.custom_minimum_size = Vector2(180.0, 36.0)
save_button.pressed.connect(_save_demo_snapshot)
box.add_child(save_button)
var title_button := Button.new()
title_button.text = "回到标题"
title_button.custom_minimum_size = Vector2(180.0, 36.0)
title_button.pressed.connect(_return_to_title)
box.add_child(title_button)
var quit_button := Button.new()
quit_button.text = "退出"
quit_button.custom_minimum_size = Vector2(180.0, 36.0)
quit_button.pressed.connect(_quit_game)
box.add_child(quit_button)
save_status_label = _label("", 12, COLOR_MUTED)
save_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(save_status_label)
return shade
@@ -1197,12 +1225,126 @@ func _create_victory_overlay(canvas: CanvasLayer) -> Control:
func _start_game() -> void:
start_overlay.visible = false
simulation_enabled = true
game_paused = false
_set_paused(false)
func _start_new_game() -> void:
_reset_demo()
_start_game()
func _continue_game() -> void:
if not _load_demo_snapshot():
_reset_demo()
_start_game()
func _set_paused(value: bool) -> void:
game_paused = value
if pause_overlay != null:
pause_overlay.visible = value
if value:
_save_demo_snapshot()
func _return_to_title() -> void:
_save_demo_snapshot()
simulation_enabled = false
_set_paused(false)
if start_overlay != null:
start_overlay.visible = true
_refresh_continue_button()
func _quit_game() -> void:
_save_demo_snapshot()
get_tree().quit()
func _refresh_continue_button() -> void:
if continue_button == null:
return
continue_button.disabled = not DemoSnapshot.exists()
continue_button.modulate = Color.WHITE if not continue_button.disabled else COLOR_MUTED
func collect_demo_snapshot() -> Dictionary:
return {
"schema": DemoSnapshot.SCHEMA,
"saved_at": Time.get_datetime_string_from_system(true, true),
"victory_shown": victory_shown,
"selected_kind": String(selected_kind),
"selected_rotation": selected_rotation,
"selected_building_id": selected_building_id,
"anchor_machine_id": anchor_machine_id,
"charger_machine_id": charger_machine_id,
"camera_focus": {
"x": camera_focus.x,
"y": camera_focus.y,
"z": camera_focus.z,
},
"camera_distance": camera_distance,
"simulation": simulation.to_snapshot(),
}
func apply_demo_snapshot(payload: Dictionary) -> bool:
if payload.is_empty() or int(payload.get("schema", 0)) != DemoSnapshot.SCHEMA:
return false
var simulation_payload: Dictionary = payload.get("simulation", {})
if not simulation.apply_snapshot(simulation_payload):
return false
victory_shown = bool(payload.get("victory_shown", false))
selected_kind = StringName(String(payload.get("selected_kind", String(WorldSimulation.KIND_BELT))))
selected_rotation = int(payload.get("selected_rotation", 0))
selected_building_id = int(payload.get("selected_building_id", -1))
anchor_machine_id = int(payload.get("anchor_machine_id", -1))
charger_machine_id = int(payload.get("charger_machine_id", -1))
var focus: Dictionary = payload.get("camera_focus", {})
camera_focus = Vector3(
float(focus.get("x", 0.0)),
float(focus.get("y", 0.0)),
float(focus.get("z", 0.0))
)
camera_distance = float(payload.get("camera_distance", 28.0))
_relink_locked_machines()
_sync_building_visuals()
_capture_item_positions_after_tick()
previous_item_positions = current_item_positions.duplicate()
simulation_accumulator = 0.0
if victory_overlay != null:
victory_overlay.visible = false
_update_camera_transform()
_update_tool_ui()
_update_objective()
_update_inspect_panel()
return true
func _save_demo_snapshot() -> bool:
var ok: bool = DemoSnapshot.save_payload(collect_demo_snapshot())
if save_status_label != null:
save_status_label.text = "已写入单槽存档。" if ok else "保存失败。"
_refresh_continue_button()
return ok
func _load_demo_snapshot() -> bool:
return apply_demo_snapshot(DemoSnapshot.load_payload())
func _relink_locked_machines() -> void:
if simulation.buildings.has(anchor_machine_id) and simulation.buildings.has(charger_machine_id):
return
for id_variant in simulation.get_building_ids():
var id := int(id_variant)
var building: Dictionary = simulation.buildings[id]
if not bool(building.get("locked", false)):
continue
if building["kind"] == WorldSimulation.KIND_MACHINE:
anchor_machine_id = id
elif building["kind"] == WorldSimulation.KIND_CHARGER:
charger_machine_id = id
func _select_tool(kind: StringName) -> void:
+41
View File
@@ -0,0 +1,41 @@
class_name DemoSnapshot
extends RefCounted
const SCHEMA := 1
const DEFAULT_PATH := "user://mana_foundry_slot1.json"
static var path_override := ""
static func slot_path() -> String:
if not path_override.is_empty():
return path_override
return DEFAULT_PATH
static func exists() -> bool:
return FileAccess.file_exists(slot_path())
static func save_payload(payload: Dictionary) -> bool:
var path := slot_path()
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify(payload, "\t"))
return true
static func load_payload() -> Dictionary:
if not exists():
return {}
var file := FileAccess.open(slot_path(), FileAccess.READ)
if file == null:
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
if typeof(parsed) != TYPE_DICTIONARY:
return {}
var payload: Dictionary = parsed
if int(payload.get("schema", 0)) != SCHEMA:
return {}
return payload
+190
View File
@@ -462,6 +462,196 @@ func state_fingerprint() -> String:
return str(hash("|".join(parts)))
func to_snapshot() -> Dictionary:
var building_rows: Array = []
for id in get_building_ids():
building_rows.append(_encode_value(buildings[id]))
var item_rows: Array = []
for id in get_item_ids():
item_rows.append(_encode_value(items[id]))
var wake_ids: Array = []
for id_variant in _wake_queue.keys():
wake_ids.append(int(id_variant))
wake_ids.sort()
var event_rows: Array = []
for event in _event_heap:
event_rows.append(_encode_value(event))
var network_rows: Array = []
for network in mana_networks:
network_rows.append(_encode_value(network))
var charged_ticks: Array = []
for tick_variant in _charged_intake_ticks:
charged_ticks.append(int(tick_variant))
return {
"schema": 1,
"tick_index": tick_index,
"next_building_id": next_building_id,
"next_item_id": next_item_id,
"buildings": building_rows,
"items": item_rows,
"sink_inventory": {
String(ITEM_RAW): int(sink_inventory.get(ITEM_RAW, 0)),
String(ITEM_REFINED): int(sink_inventory.get(ITEM_REFINED, 0)),
String(ITEM_CHARGED): int(sink_inventory.get(ITEM_CHARGED, 0)),
},
"event_heap": event_rows,
"wake_queue": wake_ids,
"charged_intake_ticks": charged_ticks,
"mana_networks": network_rows,
}
func apply_snapshot(snapshot: Dictionary) -> bool:
if snapshot.is_empty() or int(snapshot.get("schema", 0)) != 1:
return false
if not snapshot.has("buildings") or not snapshot.has("items"):
return false
reset()
tick_index = int(snapshot.get("tick_index", 0))
next_building_id = int(snapshot.get("next_building_id", 1))
next_item_id = int(snapshot.get("next_item_id", 1))
var sink: Dictionary = snapshot.get("sink_inventory", {})
sink_inventory = {
ITEM_RAW: int(sink.get(String(ITEM_RAW), sink.get("raw_crystal", 0))),
ITEM_REFINED: int(sink.get(String(ITEM_REFINED), sink.get("refined_crystal", 0))),
ITEM_CHARGED: int(sink.get(String(ITEM_CHARGED), sink.get("charged_crystal", 0))),
}
for row_variant in snapshot.get("buildings", []):
if typeof(row_variant) != TYPE_DICTIONARY:
continue
var building: Dictionary = _decode_building(row_variant)
var id := int(building["id"])
buildings[id] = building
grid[Vector2i(building["cell"])] = id
for row_variant in snapshot.get("items", []):
if typeof(row_variant) != TYPE_DICTIONARY:
continue
var item: Dictionary = _decode_item(row_variant)
var id := int(item["id"])
items[id] = item
item_by_cell[Vector2i(item["cell"])] = id
for event_variant in snapshot.get("event_heap", []):
if typeof(event_variant) != TYPE_DICTIONARY:
continue
var event: Dictionary = event_variant
_push_event({
"tick": int(event.get("tick", 0)),
"type": StringName(String(event.get("type", ""))),
"id": int(event.get("id", 0)),
})
for id_variant in snapshot.get("wake_queue", []):
_wake_queue[int(id_variant)] = true
for tick_variant in snapshot.get("charged_intake_ticks", []):
_charged_intake_ticks.append(int(tick_variant))
for network_variant in snapshot.get("mana_networks", []):
if typeof(network_variant) != TYPE_DICTIONARY:
continue
mana_networks.append(_decode_mana_network(network_variant))
_rebuild_watchers()
return true
func _rebuild_watchers() -> void:
_output_watchers.clear()
_input_watchers.clear()
for id_variant in get_building_ids():
var id := int(id_variant)
var kind: StringName = buildings[id]["kind"]
if kind == KIND_SOURCE or is_processor(kind):
_register_output_watcher(id)
if is_processor(kind):
_register_input_watcher(id)
func _encode_value(value: Variant) -> Variant:
match typeof(value):
TYPE_VECTOR2I:
var cell := Vector2i(value)
return {"x": cell.x, "y": cell.y}
TYPE_STRING_NAME:
return String(value)
TYPE_ARRAY:
var encoded_array: Array = []
for entry in value:
encoded_array.append(_encode_value(entry))
return encoded_array
TYPE_DICTIONARY:
var encoded: Dictionary = {}
for key in value.keys():
encoded[str(key)] = _encode_value(value[key])
return encoded
return value
func _decode_building(row: Dictionary) -> Dictionary:
var building := row.duplicate(true)
building["id"] = int(row.get("id", 0))
building["kind"] = StringName(String(row.get("kind", "")))
building["cell"] = _decode_cell(row.get("cell", {}))
building["rotation"] = int(row.get("rotation", 0))
building["locked"] = bool(row.get("locked", false))
building["status"] = StringName(String(row.get("status", "idle")))
if is_processor(building["kind"]):
building["recipe"] = StringName(String(row.get("recipe", String(RECIPE_REFINE))))
building["input_kind"] = StringName(String(row.get("input_kind", String(ITEM_RAW))))
building["output_kind"] = StringName(String(row.get("output_kind", String(ITEM_REFINED))))
building["input_count"] = int(row.get("input_count", 0))
building["output_buffer"] = int(row.get("output_buffer", 0))
building["completion_tick"] = int(row.get("completion_tick", -1))
building["remaining_ticks"] = int(row.get("remaining_ticks", 0))
building["completed"] = int(row.get("completed", 0))
building["mana_powered"] = bool(row.get("mana_powered", false))
building["mana_network"] = int(row.get("mana_network", -1))
building["input_cell"] = _decode_cell(row.get("input_cell", {}))
building["output_cell"] = _decode_cell(row.get("output_cell", {}))
building["bridge_configured"] = bool(row.get("bridge_configured", false))
if building["kind"] == KIND_SOURCE:
building["next_ready_tick"] = int(row.get("next_ready_tick", 0))
building["scheduled_tick"] = int(row.get("scheduled_tick", -1))
building["produced"] = int(row.get("produced", 0))
if building["kind"] == KIND_SINK:
building["accepted"] = int(row.get("accepted", 0))
if building["kind"] == KIND_MANA_SOURCE:
building["capacity"] = int(row.get("capacity", MANA_SOURCE_CAPACITY))
building["mana_network"] = int(row.get("mana_network", -1))
if building["kind"] == KIND_MANA_PIPE:
building["mana_network"] = int(row.get("mana_network", -1))
return building
func _decode_item(row: Dictionary) -> Dictionary:
return {
"id": int(row.get("id", 0)),
"kind": StringName(String(row.get("kind", String(ITEM_RAW)))),
"cell": _decode_cell(row.get("cell", {})),
"progress": float(row.get("progress", 0.0)),
}
func _decode_mana_network(row: Dictionary) -> Dictionary:
var infrastructure: Array = []
for id_variant in row.get("infrastructure", []):
infrastructure.append(int(id_variant))
var machines: Array = []
for id_variant in row.get("machines", []):
machines.append(int(id_variant))
return {
"id": int(row.get("id", 0)),
"capacity": int(row.get("capacity", 0)),
"used": int(row.get("used", 0)),
"infrastructure": infrastructure,
"machines": machines,
}
func _decode_cell(value: Variant) -> Vector2i:
if value is Vector2i:
return value
if typeof(value) == TYPE_DICTIONARY:
return Vector2i(int(value.get("x", 0)), int(value.get("y", 0)))
return Vector2i.ZERO
func _move_items() -> void:
var ids := get_item_ids()
for id_variant in ids:
+1 -1
View File
@@ -83,7 +83,7 @@ stress scene + release check
### Phase 3: Presentation and scale guardrails
- [ ] Task 9: Add menu, save snapshot, audio hooks, and visual polish
- [x] Task 9: Add menu, save snapshot, audio hooks, and visual polish
- [ ] Task 10: Add deterministic stress generation and metrics
- [ ] Task 11: Package a Windows demo build
+4 -4
View File
@@ -142,12 +142,12 @@
**Description:** Add title/pause screens, coherent materials and mana effects, audio hooks, and one-slot world snapshot persistence.
**Acceptance criteria:**
- [ ] New game, continue, pause, restart, and quit flows work.
- [ ] Snapshot restores construction, transport, production, and objectives.
- [ ] Industrial assets read as magical machinery through a unified material pass.
- [x] New game, continue, pause, restart, and quit flows work.
- [x] Snapshot restores construction, transport, production, and objectives.
- [x] Industrial assets read as magical machinery through a unified material pass.
**Verification:**
- [ ] Save-then-run and run-from-save produce matching state hashes.
- [x] Save-then-run and run-from-save produce matching state hashes.
- [ ] Manual UI flow works at 1080p and a smaller window.
**Dependencies:** Task 8
+126
View File
@@ -0,0 +1,126 @@
extends SceneTree
const WorldSimulation = preload("res://src/simulation/world_simulation.gd")
const DemoSnapshot = preload("res://src/persistence/demo_snapshot.gd")
const TEST_PATH := "user://mana_foundry_snapshot_test.json"
func _initialize() -> void:
DemoSnapshot.path_override = TEST_PATH
if FileAccess.file_exists(TEST_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH))
var failures: Array[String] = []
var live := _make_two_stage()
for _tick in range(400):
live.step()
var before := live.state_fingerprint()
var charged_before := live.get_charged_count()
var snapshot := live.to_snapshot()
var restored := WorldSimulation.new()
if not restored.apply_snapshot(snapshot):
failures.append("apply_snapshot rejected a live two-stage snapshot.")
elif restored.state_fingerprint() != before:
failures.append("Restored simulation fingerprint did not match the live factory.")
elif restored.get_charged_count() != charged_before:
failures.append("Restored charged count did not match.")
if failures.is_empty():
for _tick in range(200):
live.step()
restored.step()
if live.state_fingerprint() != restored.state_fingerprint():
failures.append("Live and restored factories diverged after more ticks.")
if not DemoSnapshot.save_payload({"schema": DemoSnapshot.SCHEMA, "simulation": snapshot}):
failures.append("DemoSnapshot could not write the test slot.")
else:
var loaded := DemoSnapshot.load_payload()
var from_disk := WorldSimulation.new()
if not from_disk.apply_snapshot(loaded.get("simulation", {})):
failures.append("Disk snapshot failed to apply.")
elif from_disk.state_fingerprint() != before:
failures.append("Disk snapshot fingerprint did not match the pre-save factory.")
var empty := WorldSimulation.new()
if empty.is_demo_complete():
failures.append("Empty factory reported demo complete.")
var first_item := _make_two_stage()
var saw_item := false
for _tick in range(2000):
first_item.step()
if first_item.get_charged_count() >= 1:
saw_item = true
break
if not saw_item:
failures.append("Two-stage factory never produced a charged crystal for the one-item check.")
elif first_item.is_demo_complete():
failures.append("Demo completed after a single charged crystal.")
else:
var mid := WorldSimulation.new()
if not mid.apply_snapshot(first_item.to_snapshot()):
failures.append("Could not restore the one-item factory.")
elif mid.is_demo_complete():
failures.append("Loaded one-item factory reported complete.")
var packed := load("res://scenes/main.tscn") as PackedScene
var demo := packed.instantiate()
root.add_child.call_deferred(demo)
await process_frame
await process_frame
demo.call("_start_new_game")
var sim: WorldSimulation = demo.get("simulation")
for x in range(-5, -1):
sim.place_building(WorldSimulation.KIND_BELT, Vector2i(x, 0), 0)
sim.place_building(WorldSimulation.KIND_BELT, Vector2i(0, 0), 0)
for x in range(2, 6):
sim.place_building(WorldSimulation.KIND_BELT, Vector2i(x, 0), 0)
for z in range(1, 5):
sim.place_building(WorldSimulation.KIND_MANA_PIPE, Vector2i(0, z), 1)
sim.configure_machine_bridge(int(demo.get("anchor_machine_id")), Vector2i(-2, 0), Vector2i(0, 0))
sim.configure_machine_bridge(int(demo.get("charger_machine_id")), Vector2i(0, 0), Vector2i(2, 0))
for _tick in range(240):
sim.step()
var scene_before := sim.state_fingerprint()
var payload: Dictionary = demo.call("collect_demo_snapshot")
if not bool(demo.call("_save_demo_snapshot")):
failures.append("Scene save helper failed.")
demo.call("_reset_demo")
if sim.state_fingerprint() == scene_before:
failures.append("Reset after save did not change simulation state.")
if not bool(demo.call("_load_demo_snapshot")):
failures.append("Scene load helper failed.")
elif sim.state_fingerprint() != scene_before:
failures.append("Scene save/load did not restore the simulation fingerprint.")
if FileAccess.file_exists(TEST_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH))
DemoSnapshot.path_override = ""
if failures.is_empty():
print("SNAPSHOT_SMOKE_OK charged=%d fingerprint=%s" % [charged_before, before])
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _make_two_stage() -> WorldSimulation:
var simulation := WorldSimulation.new()
simulation.place_building(WorldSimulation.KIND_SOURCE, Vector2i(-6, 0), 0)
for x in range(-5, -1):
simulation.place_building(WorldSimulation.KIND_BELT, Vector2i(x, 0), 0)
simulation.place_building(WorldSimulation.KIND_BELT, Vector2i(0, 0), 0)
for x in range(2, 6):
simulation.place_building(WorldSimulation.KIND_BELT, Vector2i(x, 0), 0)
simulation.place_building(WorldSimulation.KIND_SINK, Vector2i(6, 0), 0)
var refine_id := simulation.place_building(WorldSimulation.KIND_MACHINE, Vector2i(-1, 1), 0)
var charger_id := simulation.place_building(WorldSimulation.KIND_CHARGER, Vector2i(1, 1), 0)
simulation.place_building(WorldSimulation.KIND_MANA_SOURCE, Vector2i(0, 5), 1)
for z in range(1, 5):
simulation.place_building(WorldSimulation.KIND_MANA_PIPE, Vector2i(0, z), 1)
simulation.configure_machine_bridge(refine_id, Vector2i(-2, 0), Vector2i(0, 0))
simulation.configure_machine_bridge(charger_id, Vector2i(0, 0), Vector2i(2, 0))
return simulation