feat: savegames save as .tres

feat: savegames restore player position
fix: savegames now more robust
This commit is contained in:
tiger tiger tiger 2026-01-16 13:03:39 +01:00
parent bc91204aa2
commit eefc38fb42
10 changed files with 156 additions and 117 deletions

View File

@ -49,6 +49,9 @@ func pull_save_state(save: SaveGame) -> void:
save.current_room = State.rooms.ADULTHOOD save.current_room = State.rooms.ADULTHOOD
save_game = save save_game = save
# Call parent to restore player position
super.pull_save_state(save)
func prepare_transition(): func prepare_transition():
for child in %Stations.get_children(): for child in %Stations.get_children():
if child is Node3D: if child is Node3D:

View File

@ -29,14 +29,24 @@ func _ready():
card_picker.cards_picked.connect(card_board.populate_board) card_picker.cards_picked.connect(card_board.populate_board)
func pull_save_state(save: SaveGame) -> void: func pull_save_state(save: SaveGame) -> void:
save.board_state = card_board.get_save_dict()
save.current_room = State.rooms.ADULTHOOD save.current_room = State.rooms.ADULTHOOD
save.mementos_complete = Scenes.completed_sequences #FIXME: fix the bloddy card board loading algorythm
#card_board.initialise_from_save(save)
# Call parent to restore player position
super.pull_save_state(save)
func _on_scene_finished(_id: int, _repeat:bool): func _on_scene_finished(_id: int, _repeat:bool):
await get_tree().create_timer(3).timeout await get_tree().create_timer(3).timeout
save_room() save_room()
func save_room():
# Update board state before saving
save_game.board_state = card_board.get_save_dict()
save_game.mementos_complete = Scenes.completed_sequences
save_game.sequences_enabled = Scenes.enabled_sequences
super.save_room()
func prepare_transition(): func prepare_transition():
pass pass

View File

@ -62,17 +62,26 @@ func _ready():
func pull_save_state(save: SaveGame) -> void: func pull_save_state(save: SaveGame) -> void:
save_game = save save_game = save
save_game.current_room = id save_game.current_room = id
save_game.board_state = card_board.get_save_dict()
# Load board state from save first, before overwriting it
card_board.initialise_from_save(save_game) card_board.initialise_from_save(save_game)
Scenes.started_sequences = save_game.mementos_complete Scenes.started_sequences = save_game.mementos_complete
Scenes.completed_sequences = save_game.mementos_complete Scenes.completed_sequences = save_game.mementos_complete
# Call parent to restore player position
super.pull_save_state(save)
func _on_scene_finished(_id: int, _repeat:bool): func _on_scene_finished(_id: int, _repeat:bool):
await get_tree().create_timer(3).timeout await get_tree().create_timer(3).timeout
save_room() save_room()
func save_room():
# Update board state before saving
save_game.board_state = card_board.get_save_dict()
save_game.mementos_complete = Scenes.completed_sequences
super.save_room()
func prepare_transition(): func prepare_transition():
save_room() save_room()

View File

@ -1871,7 +1871,7 @@ light_size = 20.0
omni_range = 16.8518 omni_range = 16.8518
[node name="MaskInteractable" parent="logic" instance=ExtResource("22_ks23q")] [node name="MaskInteractable" parent="logic" instance=ExtResource("22_ks23q")]
transform = Transform3D(-0.8827416, 0, 0.4698562, 0, 1, 0, -0.4698562, 0, -0.8827416, -0.025371574, 0.55708295, 2.5263817) transform = Transform3D(-0.8827416, 0, 0.4698562, 0, 1, 0, -0.4698562, 0, -0.8827416, 0.028929986, 0.58693635, 2.552513)
interaction = ExtResource("12_viwxf") interaction = ExtResource("12_viwxf")
[node name="MindBoardInteractable" parent="logic" instance=ExtResource("22_ks23q")] [node name="MindBoardInteractable" parent="logic" instance=ExtResource("22_ks23q")]
@ -1890,7 +1890,7 @@ interaction = ExtResource("13_v3447")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.36574292, 0.099999994, 0.032779038) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.36574292, 0.099999994, 0.032779038)
[node name="ClothesInteractable" parent="logic" instance=ExtResource("22_ks23q")] [node name="ClothesInteractable" parent="logic" instance=ExtResource("22_ks23q")]
transform = Transform3D(0.7935111, 0, -0.60855323, 0, 1, 0, 0.60855323, 0, 0.7935111, 1.6713148, 1.089737, -0.92289597) transform = Transform3D(0.7935111, 0, -0.60855323, 0, 1, 0, 0.60855323, 0, 0.7935111, 1.6334484, 1.1331886, -0.8914416)
interaction = ExtResource("12_x3dlb") interaction = ExtResource("12_x3dlb")
[node name="Sprite3D" parent="logic/ClothesInteractable/View" index="0"] [node name="Sprite3D" parent="logic/ClothesInteractable/View" index="0"]

View File

@ -41,7 +41,23 @@ func start_room():
pass pass
func pull_save_state(_save: SaveGame) -> void: func pull_save_state(_save: SaveGame) -> void:
pass # Try to restore player position from save
restore_player_from_save(_save)
## Attempts to find player controller and restore position/rotation from save
func restore_player_from_save(save: SaveGame) -> void:
var player: PlayerController = null
# Try to find player controller in common locations
if has_node("%PlayerController"):
player = get_node("%PlayerController")
elif has_node("logic/PlayerController"):
player = get_node("logic/PlayerController")
if player and player is PlayerController:
player.restore_from_save(save)
else:
print_debug("RoomTemplate: Could not find PlayerController to restore position")
func save_room(): func save_room():
save_game.save_to_file(get_tree().root.get_texture()) save_game.save_to_file(get_tree().root.get_texture())

View File

@ -1,5 +1,6 @@
class_name SaveGame extends Resource class_name SaveGame extends Resource
var _is_initialised: bool = false var _is_initialised: bool = false
var current_room_path: String: var current_room_path: String:
@ -38,6 +39,18 @@ var current_room_path: String:
childhood_mementos = value childhood_mementos = value
if _is_initialised: changed.emit() if _is_initialised: changed.emit()
@export var is_childhood_board_complete: bool = false @export var is_childhood_board_complete: bool = false
@export var player_position: Vector3 = Vector3.ZERO:
set(value):
player_position = value
if _is_initialised: changed.emit()
@export var player_yaw: float = 0.0:
set(value):
player_yaw = value
if _is_initialised: changed.emit()
@export var player_pitch: float = 0.0:
set(value):
player_pitch = value
if _is_initialised: changed.emit()
@export var thumbnail: Texture = preload("res://import/interface-elements/empty_save_slot.png"): @export var thumbnail: Texture = preload("res://import/interface-elements/empty_save_slot.png"):
set(value): set(value):
thumbnail = value thumbnail = value
@ -67,107 +80,81 @@ func _validate_property(property: Dictionary):
if property.name == "is_empty": if property.name == "is_empty":
property.usage |= PROPERTY_USAGE_READ_ONLY property.usage |= PROPERTY_USAGE_READ_ONLY
func _init(initial_filepath = "") -> void: ## Creates a NEW save game (for starting a new game)
if initial_filepath == "": func _init(filepath_or_debug: String = "") -> void:
filepath = "%s/%s.json" % [State.user_saves_path, unique_save_name] if filepath_or_debug == "":
elif initial_filepath == "DEBUG": filepath = "%s/%s.tres" % [State.user_saves_path, unique_save_name]
filepath = initial_filepath elif filepath_or_debug == "DEBUG":
else: filepath = "DEBUG"
filepath = initial_filepath
unique_save_name = initial_filepath.get_file()
read_save_file()
_is_initialised = true
func read_save_file() -> void:
if not DirAccess.dir_exists_absolute(filepath.get_base_dir()):
DirAccess.make_dir_absolute(filepath.get_base_dir())
if filepath == "DEBUG":
if OS.has_feature("debug") or OS.has_feature("demo"): if OS.has_feature("debug") or OS.has_feature("demo"):
push_warning("Created DEBUG savegame. Progress will not be stored!") push_warning("Created DEBUG savegame. Progress will not be stored!")
else: else:
print_debug(get_stack()) print_debug(get_stack())
push_error("Created DEBUG savegame outside of demo or debug environment. This is unintentional and will lead to data loss. Please contact support and attatch the stack above.") push_error("Created DEBUG savegame outside of demo or debug environment. This is unintentional and will lead to data loss. Please contact support and attatch the stack above.")
#TODO maybe cause a crash here?
return
if FileAccess.file_exists(filepath):
print_debug("Savegame: Reading from: %s" % filepath)
var file := FileAccess.open(filepath, FileAccess.READ)
var raw_json := FileAccess.get_file_as_string(filepath)
file.close()
var parsed: Dictionary = JSON.parse_string(raw_json)
var tmp_img: Image
if FileAccess.file_exists("%s/thumbnails/%s.png" % [filepath.get_base_dir(), unique_save_name]):
tmp_img = Image.load_from_file("%s/thumbnails/%s.png" % [filepath.get_base_dir(), unique_save_name])
var are_types_valid := (
parsed["unique_save_name"] is String and
parsed["current_room"] is float and
parsed["mementos_complete"] is float and
parsed["board_state"] is Dictionary and
parsed["is_childhood_board_complete"] is bool and
parsed["last_saved"] is float# and FIXME
#parsed["demo"] is bool and last_saved != 0
)
if are_types_valid:
for key in parsed.keys():
set(key, parsed[key])
for dict:Dictionary in [board_state["cards"], board_state["stickies"]]:
for key in dict.keys():
if dict[key] is String:
if dict[key].begins_with("("):
dict[key] = parse_vec_from_string(dict[key])
var cards: Dictionary[StringName, Variant]
var stickies: Dictionary[StringName, Variant]
var randoms: Array[StringName]
for cardname:String in board_state["cards"]:
cards[StringName(cardname)] = board_state["cards"][cardname]
for sticky_name:String in board_state["stickies"]:
stickies[StringName(sticky_name)] = board_state["stickies"][sticky_name]
for random_name:StringName in board_state["randoms"]:
randoms.append( random_name )
board_state = {
"cards": cards,
"stickies": stickies,
"randoms": randoms
}
is_valid = are_types_valid \
and current_room >= 0 \
and current_room < State.rooms.keys().size() \
and validate_board_state()
if not is_valid:
push_error("Parsing of Save failed.")
if tmp_img != null:
thumbnail = ImageTexture.create_from_image(tmp_img)
is_empty = false
else: else:
print_debug("Savegame: Creating (in memory) for path: %s" % filepath) filepath = filepath_or_debug
is_valid = true unique_save_name = filepath_or_debug.get_file().get_basename()
func _get_save_dict() -> Dictionary: # Ensure save directory exists
return { if filepath != "DEBUG" and not DirAccess.dir_exists_absolute(filepath.get_base_dir()):
"unique_save_name": unique_save_name, DirAccess.make_dir_absolute(filepath.get_base_dir())
"current_room": current_room,
"mementos_complete": mementos_complete, print_debug("Savegame: Creating new save for path: %s" % filepath)
"sequences_enabled": sequences_enabled, is_valid = true
"childhood_mementos": childhood_mementos, _is_initialised = true
"board_state": board_state,
"is_childhood_board_complete": is_childhood_board_complete, ## Static factory method to load an EXISTING save from disk
"last_saved": last_saved, static func load_from_file(save_filepath: String) -> SaveGame:
"is_demo": is_demo if not FileAccess.file_exists(save_filepath):
} push_error("SaveGame: File does not exist: %s" % save_filepath)
return null
print_debug("Savegame: Loading from: %s" % save_filepath)
var loaded: SaveGame = ResourceLoader.load(save_filepath, "", ResourceLoader.CACHE_MODE_IGNORE)
if not loaded:
push_error("Failed to load SaveGame resource from: %s" % save_filepath)
return null
# Update filepath to actual location (in case file was moved)
loaded.filepath = save_filepath
loaded.unique_save_name = save_filepath.get_file().get_basename()
# Ensure randoms array exists (backwards compatibility)
if "randoms" not in loaded.board_state:
loaded.board_state["randoms"] = []
# Load thumbnail separately (not stored in .tres)
var thumbnail_path := "%s/thumbnails/%s.png" % [save_filepath.get_base_dir(), loaded.unique_save_name]
if FileAccess.file_exists(thumbnail_path):
var tmp_img: Image = Image.load_from_file(thumbnail_path)
if tmp_img != null:
loaded.thumbnail = ImageTexture.create_from_image(tmp_img)
# Validate the loaded data
loaded.is_valid = loaded.current_room >= 0 \
and loaded.current_room < State.rooms.keys().size() \
and loaded.validate_board_state()
if not loaded.is_valid:
push_error("Validation of loaded save failed: %s" % save_filepath)
return null
loaded._is_initialised = true
return loaded
## Captures current player position and camera rotation
func capture_player_state() -> void:
if State.player:
player_position = State.player.global_position
# Access yaw and pitch nodes
var yaw: Node3D = State.player.get_node("Yaw")
var pitch: Node3D = yaw.get_node("Pitch")
player_yaw = yaw.rotation.y
player_pitch = pitch.rotation.x
print_debug("SaveGame: Captured player state - pos: %s, yaw: %.2f, pitch: %.2f" % [player_position, player_yaw, player_pitch])
func save_to_file(screen_shot: Texture) -> void: func save_to_file(screen_shot: Texture) -> void:
print_debug("Savegame: Saving to file: %s" % filepath) print_debug("Savegame: Saving to file: %s" % filepath)
@ -180,7 +167,12 @@ func save_to_file(screen_shot: Texture) -> void:
push_warning("Savegame: Not saving empty savegame.") push_warning("Savegame: Not saving empty savegame.")
return return
# Capture player state before saving
capture_player_state()
last_saved = int(Time.get_unix_time_from_system()) last_saved = int(Time.get_unix_time_from_system())
# Save thumbnail separately as PNG
var thumbnail_image: Image = screen_shot.get_image() var thumbnail_image: Image = screen_shot.get_image()
thumbnail_image.convert(Image.Format.FORMAT_RGB8) thumbnail_image.convert(Image.Format.FORMAT_RGB8)
thumbnail_image.linear_to_srgb() thumbnail_image.linear_to_srgb()
@ -193,10 +185,13 @@ func save_to_file(screen_shot: Texture) -> void:
var thumbnail_path: String = "%s/thumbnails/%s.png" % [filepath.get_base_dir(), unique_save_name] var thumbnail_path: String = "%s/thumbnails/%s.png" % [filepath.get_base_dir(), unique_save_name]
thumbnail_image.save_png(thumbnail_path) thumbnail_image.save_png(thumbnail_path)
print_debug(filepath.get_base_dir())
var file := FileAccess.open(filepath, FileAccess.WRITE) # Save the resource using Godot's native serialization
file.store_string(JSON.stringify(_get_save_dict())) var save_result := ResourceSaver.save(self, filepath)
file.close() if save_result != OK:
push_error("Failed to save resource to: %s (Error code: %d)" % [filepath, save_result])
else:
print_debug("Successfully saved to: %s" % filepath)
func calculate_completed_sequences() -> int: func calculate_completed_sequences() -> int:
@ -226,7 +221,3 @@ func validate_board_state() -> bool:
return false return false
return true return true
return false return false
func parse_vec_from_string(string: String) -> Vector2:
var string_array := string.replace("(", "").replace(")", "").split(", ")
return Vector2(float(string_array[0]), float(string_array[1]))

View File

@ -47,6 +47,7 @@ func _player_active(value: bool) -> void:
func expand() -> void: func expand() -> void:
shown = true shown = true
_process_billboard()
if tween and tween.is_valid(): if tween and tween.is_valid():
tween.kill() tween.kill()
@ -72,7 +73,6 @@ func collapse() -> void:
tween.parallel().tween_property(frame, "scale", Vector3.ONE * 2.0, 1.0).set_trans(Tween.TRANS_QUAD) tween.parallel().tween_property(frame, "scale", Vector3.ONE * 2.0, 1.0).set_trans(Tween.TRANS_QUAD)
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
_process_billboard()
_process_hover() _process_hover()
func _process_billboard() -> void: func _process_billboard() -> void:

View File

@ -128,6 +128,16 @@ func _ready():
func _on_player_enable(enable: bool) -> void: func _on_player_enable(enable: bool) -> void:
enabled = enable enabled = enable
## Restores player position and camera rotation from save game
func restore_from_save(save: SaveGame) -> void:
if save.player_position != Vector3.ZERO:
global_position = save.player_position
yaw.rotation.y = save.player_yaw
pitch.rotation.x = save.player_pitch
print_debug("PlayerController: Restored position %s, yaw %.2f, pitch %.2f" % [save.player_position, save.player_yaw, save.player_pitch])
else:
print_debug("PlayerController: No saved position data, using default spawn")
func _process(_delta) -> void: func _process(_delta) -> void:
if not enabled: if not enabled:
return return

View File

@ -43,10 +43,10 @@ func _load_games():
var filepaths: PackedStringArray = save_game_dir.get_files() var filepaths: PackedStringArray = save_game_dir.get_files()
for path in filepaths: for path in filepaths:
if path is String and path.ends_with(".json"): if path is String and path.ends_with(".tres"):
var save := SaveGame.new("%s/%s" % [State.user_saves_path, path.get_basename()]) var save := SaveGame.load_from_file("%s/%s" % [State.user_saves_path, path])
# HACK: Skip empty saves (we decide later what to do with them) # Skip invalid/empty saves
if not save.is_empty: if save != null and not save.is_empty:
saves.append(save) saves.append(save)
_sort_saves() _sort_saves()