feat(client): save/load client UI — F5/F6 quicksave/quickload (#554)

Wire SaveGame/LoadGame player actions through the full client stack:
protocol v15 decode, InputMapper F5/F6 bindings, SimBridge wire mapping
with one-shot carry-forward, GameState save_result field, and HUD
notification via monologue display. Quit-to-menu triggers quicksave
before scene change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 13:15:05 +01:00
co-authored by Claude Opus 4.6
parent e51650ec74
commit e1ea07e746
10 changed files with 135 additions and 5 deletions
+11
View File
@@ -70,6 +70,11 @@ var insert_active: bool = true
# Null in v0.1 (server does not yet send this field; protocol change required).
var rng_seed: Variant = null
# v15 fields (#554, D-085): save/load result from server.
# {success: bool, kind: "save"|"load", error: Variant} or null.
# One-shot: consumed by main.gd after display, then set back to null.
var save_result: Variant = null
# v7 fields (#431, D-059/D-060)
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
@@ -279,6 +284,12 @@ func apply_snapshot(snapshot: Dictionary) -> void:
else:
current_examine_result = null
# v15: save_result (#554, D-085) — one-shot save/load confirmation from server.
if snapshot.has("save_result") and snapshot.save_result is Dictionary:
save_result = snapshot.save_result
else:
save_result = null
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
# Only update when field is present (null means no change, server sends when KG changes).
if snapshot.has("player_knowledge") and snapshot.player_knowledge is Dictionary:
+15 -2
View File
@@ -22,6 +22,8 @@ enum Action {
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
SET_FACING, # D-054: facing octant update (no movement)
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
}
var input_queue: Array[Dictionary] = []
@@ -112,12 +114,23 @@ func _unhandled_input(event: InputEvent) -> void:
elif event.is_action_pressed("teleport_hub"):
if GameState.gauntlet_mode:
action = Action.TELEPORT_HUB
elif event.is_action_pressed("quicksave"):
action = Action.SAVE_GAME
elif event.is_action_pressed("quickload"):
action = Action.LOAD_GAME
if action != -1:
input_queue.append({
var entry := {
"action": action,
"timestamp_msec": Time.get_ticks_msec(),
})
}
# #554: Attach save path for SaveGame/LoadGame actions
if action == Action.SAVE_GAME or action == Action.LOAD_GAME:
var game_id := GameState.current_game_id
if game_id.is_empty():
return # No active session — ignore save/load
entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"}
input_queue.append(entry)
get_viewport().set_input_as_handled()
+8 -2
View File
@@ -84,8 +84,14 @@ func quit_to_menu() -> void:
func _do_quit_to_menu() -> void:
_cleanup_quit_dialog()
# #554: F5 quicksave will be triggered here before scene change when server
# supports SaveCommand. For now: navigate to menu without saving.
# #554: Trigger quicksave before navigating to menu.
if not GameState.current_game_id.is_empty():
var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav"
SimBridge.send_input({
"action": InputMapper.Action.SAVE_GAME,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"path": path},
})
GameState.current_game_id = ""
get_tree().change_scene_to_file(MENU_SCENE)
+7
View File
@@ -289,6 +289,9 @@ func receive_bytes(bytes: PackedByteArray) -> void:
if old_conv_ended.size() > 0:
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
# #554: Carry forward save/load result (one-shot, consumed by main.gd)
if snapshot.get("save_result") == null and _last_snapshot.get("save_result") != null:
snapshot["save_result"] = _last_snapshot["save_result"]
_last_snapshot = snapshot
# Drain the outbound buffer. Returns raw input entries for batch encoding.
@@ -326,6 +329,10 @@ static func action_enum_to_wire(action: int) -> String:
return "SetFacing" # D-054: facing octant update (no movement)
InputMapper.Action.TELEPORT_HUB:
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
InputMapper.Action.SAVE_GAME:
return "SaveGame" # #554: F5 quicksave (D-085)
InputMapper.Action.LOAD_GAME:
return "LoadGame" # #554: F6 quickload (D-085)
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
+24
View File
@@ -173,6 +173,9 @@ func _process(delta: float) -> void:
_consume_conversation_ended()
_consume_dialogue_response()
# #554: Show save/load result notification
_consume_save_result()
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
# Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame.
@@ -381,6 +384,27 @@ func _consume_dialogue_response() -> void:
GameState.dialogue_response = null
# #554: Show save/load result notification from server response.
func _consume_save_result() -> void:
if GameState.save_result == null:
return
var result: Dictionary = GameState.save_result
GameState.save_result = null # consume once
var msg: String
if result.get("success", false):
if result.get("kind", "") == "save":
msg = UIStrings.get_text("notifications.save_complete")
else:
msg = UIStrings.get_text("notifications.load_complete")
else:
if result.get("kind", "") == "save":
msg = UIStrings.get_text("notifications.save_failed")
else:
msg = UIStrings.get_text("notifications.load_failed")
if monologue_display:
monologue_display.show_notification(msg)
# D-061: Handle dialogue option selection → send to server
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
SimBridge.send_input({
+13 -1
View File
@@ -11,7 +11,7 @@ class_name Protocol
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
## Reject snapshots where version != this value.
const PROTOCOL_VERSION: int = 14
const PROTOCOL_VERSION: int = 15
# -- Decode: bytes from server → GDScript types --------------------------------
@@ -233,6 +233,17 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"confidence": str(raw_examine.get("confidence", "KnowsOf")),
}
# v15: save_result (#554, D-085) — one-shot save/load operation result.
# {success: bool, kind: "save"|"load", error: String|null}
var save_result: Variant = null
var raw_save: Variant = raw.get("save_result")
if raw_save is Dictionary:
save_result = {
"success": bool(raw_save.get("success", false)),
"kind": str(raw_save.get("kind", "")),
"error": raw_save.get("error"),
}
# v14: player_knowledge (#264, D-041) — partial KG dump for journal panel.
# {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}],
# facts: [{fact_id, confidence, source, state, acquired_tick}]}
@@ -290,6 +301,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
"poi_list": poi_list,
"examine_result": examine_result,
"player_knowledge": player_knowledge,
"save_result": save_result,
}
+1
View File
@@ -189,6 +189,7 @@ func snapshot() -> Dictionary:
"gauntlet_mode": gauntlet_mode,
"conversation_events": conv_events,
"conversation_ended": conv_ended,
"save_result": null,
}