feat: save game computed properties and cleaner validation; and card board saves the game, too.

This commit is contained in:
tiger tiger tiger 2026-01-16 13:59:09 +01:00
parent e9700b760d
commit 7b327c47f6
9 changed files with 136 additions and 188 deletions

View File

@ -17,6 +17,9 @@ func start_room():
card_board.board_completed.connect(func():
#TODO: hook in ending
save_room())
card_board.closed.connect(save_room)
%PlayerController.process_mode = Node.PROCESS_MODE_INHERIT
ini_room.emit()
Scenes.player_enable.emit(true)
@ -42,7 +45,7 @@ func _on_scene_finished(_id: int, _repeat:bool):
func save_room():
# Update board state before saving
save_game.board_state = card_board.get_save_dict()
card_board.save_to_resource(save_game)
save_game.mementos_complete = Scenes.completed_sequences
save_game.sequences_enabled = Scenes.enabled_sequences
super.save_room()

View File

@ -41,6 +41,8 @@ func get_ready():
card_board.board_completed.connect(func():
save_game.is_childhood_board_complete = true
save_room())
card_board.closed.connect(save_room)
card_picker.cards_picked.connect(card_board.populate_board)
@ -79,7 +81,7 @@ func _on_scene_finished(_id: int, _repeat:bool):
func save_room():
# Update board state before saving
save_game.board_state = card_board.get_save_dict()
card_board.save_to_resource(save_game)
save_game.mementos_complete = Scenes.completed_sequences
super.save_room()

View File

@ -192,8 +192,8 @@ func get_cards_by_scene_id(id: int) -> Array[Card]:
return output
# used to put cards on the dev board
func get_cards_by_name_array(names: Array[StringName]) -> Dictionary:
var output:Dictionary = {
func get_cards_by_name_array(names: Array[StringName]) -> Dictionary[String, Array]:
var output:Dictionary[String, Array] = {
"cards": [],
"sticky_notes": []
}

View File

@ -3,48 +3,59 @@ class_name SaveGame extends Resource
## Save game data container
## This is primarily a data class - file I/O helpers are thin wrappers around ResourceSaver/Loader
# === Computed Properties ===
var current_room_path: String:
get:
return Main.room_paths[current_room] if Main else ""
var is_empty: bool:
get:
return not FileAccess.file_exists(filepath) or (current_room == State.rooms.NULL)
# === Data Fields ===
@export var filepath: String = ""
@export var unique_save_name: String = ""
@export var current_room: State.rooms = State.rooms.NULL
@export_flags("Intro", "Childhood", "Voice Training", "Jui Jutsu") var mementos_complete: int = 0
@export_flags_2d_physics var sequences_enabled: int = 63
@export var board_state: Dictionary = {"cards": {}, "stickies": {}, "randoms": []}
@export var childhood_mementos: Dictionary = {"cards": {}, "stickies": {}, "randoms": []}
# Board state - properly typed fields
@export var board_cards: Dictionary[StringName, Vector2] = {}
@export var board_stickies: Dictionary[StringName, Variant] = {}
@export var board_randoms: Array[StringName] = []
@export var is_childhood_board_complete: bool = false
@export var player_position: Vector3 = Vector3.ZERO
@export var player_yaw: float = 0.0
@export var player_pitch: float = 0.0
@export var last_saved: int = 0
@export var is_valid: bool = false
@export var is_demo: bool = false
# === Computed Properties ===
var is_valid: bool:
get: return _validate()
var current_room_path: String:
get: return Main.room_paths[current_room] if Main else ""
var is_empty: bool:
get: return not FileAccess.file_exists(file_name) or (current_room == State.rooms.NULL)
var completed_sequences: int:
get:
# Hamming weight (population count) algorithm for counting set bits
var i: int = mementos_complete - ((mementos_complete >> 1) & 0x55555555)
i = (i & 0x33333333) + ((i >> 2) & 0x33333333)
i = (i + (i >> 4)) & 0x0F0F0F0F
i *= 0x01010101
return i >> 24
var total_connections: int:
get:
var connections := 0
for sticky_position in board_stickies.values():
connections += int(sticky_position is String)
return connections
# === State Variables / External Data ===
## Where to save the savegame to / where it was loaded from
var file_name: String = ""
## Screenshot or placeholder image
var thumbnail: Texture = preload("res://import/interface-elements/empty_save_slot.png")
# === Editor Conveniences ===
@export var save_manually: bool = false:
set(val):
if val:
save_to_file(thumbnail)
func _validate_property(property: Dictionary):
if property.name == "filepath":
property.usage |= PROPERTY_USAGE_UPDATE_ALL_IF_MODIFIED
if property.name in ["thumbnail", "is_valid", "is_empty"]:
property.usage |= PROPERTY_USAGE_READ_ONLY
# === Static Helpers ===
## Generates a unique save filepath
@ -56,32 +67,16 @@ static func generate_save_path() -> String:
## Creates a new save game with generated filepath
static func create_new() -> SaveGame:
var save := SaveGame.new()
save.filepath = generate_save_path()
save.unique_save_name = save.filepath.get_file().get_basename()
save.is_valid = true
save.file_name = generate_save_path()
save.unique_save_name = save.file_name.get_file().get_basename()
save.is_demo = OS.has_feature("Demo")
save.last_saved = int(Time.get_unix_time_from_system())
# Ensure save directory exists
if not DirAccess.dir_exists_absolute(save.filepath.get_base_dir()):
DirAccess.make_dir_absolute(save.filepath.get_base_dir())
print_debug("SaveGame: Created new save: %s" % save.filepath)
return save
## Creates a DEBUG save (not persisted to disk)
static func create_debug() -> SaveGame:
var save := SaveGame.new()
save.filepath = "DEBUG"
save.unique_save_name = "DEBUG"
save.is_valid = true
save.is_demo = OS.has_feature("Demo")
if OS.has_feature("debug") or OS.has_feature("demo"):
push_warning("Created DEBUG savegame. Progress will not be stored!")
else:
push_error("Created DEBUG savegame outside of demo/debug environment. This will lead to data loss!")
if not DirAccess.dir_exists_absolute(save.file_name.get_base_dir()):
DirAccess.make_dir_absolute(save.file_name.get_base_dir())
print_debug("SaveGame: Created new save: %s" % save.file_name)
return save
## Loads an existing save from disk
@ -90,37 +85,29 @@ static func load_from_file(save_filepath: String) -> SaveGame:
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)
push_error("SaveGame: Failed to load resource from: %s" % save_filepath)
return null
# Update filepath metadata
loaded.filepath = save_filepath
loaded.file_name = save_filepath
loaded.unique_save_name = save_filepath.get_file().get_basename()
# Backwards compatibility
if "randoms" not in loaded.board_state:
loaded.board_state["randoms"] = []
# Load thumbnail separately (not stored in .tres)
# Load thumbnail (stored separately as PNG)
_load_thumbnail(loaded)
# Validate
# Validate and return
loaded.is_valid = loaded._validate()
if not loaded.is_valid:
push_error("Validation of loaded save failed: %s" % save_filepath)
push_error("SaveGame: Validation failed: %s" % save_filepath)
return null
return loaded
## Helper to load thumbnail from separate PNG file
static func _load_thumbnail(save: SaveGame) -> void:
var thumbnail_path := "%s/thumbnails/%s.png" % [save.filepath.get_base_dir(), save.unique_save_name]
var thumbnail_path := "%s/thumbnails/%s.png" % [save.file_name.get_base_dir(), save.unique_save_name]
if FileAccess.file_exists(thumbnail_path):
var img := Image.load_from_file(thumbnail_path)
if img:
@ -143,7 +130,7 @@ func capture_player_state() -> void:
## Saves to disk with thumbnail
func save_to_file(screen_shot: Texture) -> void:
if filepath == "DEBUG":
if file_name == "DEBUG":
push_warning("SaveGame: DEBUG save skipped (intentional).")
return
@ -151,7 +138,7 @@ func save_to_file(screen_shot: Texture) -> void:
push_warning("SaveGame: Not saving empty savegame.")
return
print_debug("SaveGame: Saving to file: %s" % filepath)
print_debug("SaveGame: Saving to file: %s" % file_name)
# Capture current state
capture_player_state()
@ -161,11 +148,11 @@ func save_to_file(screen_shot: Texture) -> void:
_save_thumbnail(screen_shot)
# Save resource
var result := ResourceSaver.save(self, filepath)
var result := ResourceSaver.save(self, file_name)
if result != OK:
push_error("Failed to save resource to: %s (Error: %d)" % [filepath, result])
push_error("Failed to save resource to: %s (Error: %d)" % [file_name, result])
else:
print_debug("Successfully saved to: %s" % filepath)
print_debug("Successfully saved to: %s" % file_name)
## Processes and saves thumbnail as PNG
func _save_thumbnail(screen_shot: Texture) -> void:
@ -176,45 +163,31 @@ func _save_thumbnail(screen_shot: Texture) -> void:
img.crop(384, 216)
# Ensure thumbnails directory exists
var save_dir := DirAccess.open(filepath.get_base_dir())
var save_dir := DirAccess.open(file_name.get_base_dir())
if not save_dir.dir_exists("thumbnails"):
save_dir.make_dir("thumbnails")
var thumbnail_path := "%s/thumbnails/%s.png" % [filepath.get_base_dir(), unique_save_name]
var thumbnail_path := "%s/thumbnails/%s.png" % [file_name.get_base_dir(), unique_save_name]
img.save_png(thumbnail_path)
## Validates save data integrity
# === Legacy Validation (may want to be removed) ===
func _validate() -> bool:
if current_room < 0 or current_room >= State.rooms.keys().size():
return false
return validate_board_state()
return _validate_board_state()
# === Helper Methods ===
func calculate_completed_sequences() -> int:
var i: int = mementos_complete - ((mementos_complete >> 1) & 0x55555555)
i = (i & 0x33333333) + ((i >> 2) & 0x33333333)
i = (i + (i >> 4)) & 0x0F0F0F0F
i *= 0x01010101
return i >> 24
func calculate_total_connections() -> int:
var connections := 0
for sticky_position in board_state.stickies.values():
connections += int(sticky_position is String)
return connections
func validate_board_state() -> bool:
if not board_state.has("cards") or not board_state.has("stickies"):
return false
for card in board_state.cards.values():
func _validate_board_state() -> bool:
# Validate cards
for card in board_cards.values():
if not card is Vector2:
push_error("Save %s: Corrupted cards" % unique_save_name)
return false
for sticky in board_state.stickies.values():
if not (sticky is int or sticky is Vector2 or sticky is float or board_state.cards.has(sticky)):
# Validate stickies
for sticky in board_stickies.values():
if not (sticky is int or sticky is Vector2 or sticky is float or board_cards.has(sticky)):
push_error("Save %s: Corrupted sticky notes" % unique_save_name)
return false

View File

@ -604,122 +604,92 @@ func on_sticky_panel_cleared(at_id: int):
else:
current_sticky_note_id += 1
func get_save_dict() -> Dictionary:
var cards: Dictionary = {}
var stickies: Dictionary = {}
var randoms: Array[StringName]
## Saves board state directly to SaveGame resource
func save_to_resource(savegame: SaveGame) -> void:
savegame.board_cards.clear()
savegame.board_stickies.clear()
savegame.board_randoms.clear()
for child in dropzone.get_children():
if child is Card:
# Save position of Card.
cards[child.name] = child.transform.origin
# Save position of Card
savegame.board_cards[child.name] = child.transform.origin
if child.picked_random:
randoms.append(child.name)
savegame.board_randoms.append(child.name)
var note : StickyNote = child.get_attached_sticky_note()
var note: StickyNote = child.get_attached_sticky_note()
if note:
# Saves Card Name as position of it's children.
stickies[child.get_attached_sticky_note().name] = child.name
# Save Card Name as position of its children
savegame.board_stickies[child.get_attached_sticky_note().name] = child.name
if child.get_attached_sticky_note().picked_random:
randoms.append(child.get_attached_sticky_note().name)
savegame.board_randoms.append(child.get_attached_sticky_note().name)
elif child is StickyNote:
# Save position of StickyNote.
cards[child.name] = child.transform.origin
# Save position of StickyNote
savegame.board_cards[child.name] = child.transform.origin
for child in sticky_note_container.get_children():
if child is StickyNotePanel:
# Saves all collected Stickies that are not on board.
stickies[child.attached_sticky_note.name] = -1
# Save all collected Stickies that are not on board
savegame.board_stickies[child.attached_sticky_note.name] = -1
## Legacy method for backwards compatibility
func get_save_dict() -> Dictionary:
return {
"cards": cards,
"stickies": stickies,
"randoms": randoms
"cards": {},
"stickies": {},
"randoms": []
}
func initialise_from_save(savegame: SaveGame) -> void:
last_save_dict = savegame.board_state.duplicate()
if savegame.board_state == {}: return
rebuild_from_savedict(savegame.board_state)
# Early return if no board data
if savegame.board_cards.is_empty() and savegame.board_stickies.is_empty():
return
func rebuild_from_savedict(board_state:Dictionary) -> void:
var cards: Dictionary
if board_state["cards"] != {} :
cards = board_state["cards"]
var stickies: Dictionary
if board_state["stickies"] != {} :
stickies = board_state["stickies"]
var randoms: Array[StringName]
if board_state["randoms"] != [] :
randoms = board_state["randoms"]
rebuild_from_save(savegame)
if cards == null and stickies == null: return
func rebuild_from_save(savegame: SaveGame) -> void:
# Early return if nothing to load
if savegame.board_cards.is_empty() and savegame.board_stickies.is_empty():
return
# Collect all card names
var all_cards: Array[StringName]
for card_name: StringName in cards.keys():
for card_name: StringName in savegame.board_cards.keys():
all_cards.append(card_name)
for card_name: StringName in stickies.keys():
for card_name: StringName in savegame.board_stickies.keys():
all_cards.append(card_name)
var card_pile = HardCards.get_cards_by_name_array(all_cards)
for card:Card in card_pile["cards"]:
var card_pile : Dictionary[String, Array] = HardCards.get_cards_by_name_array(all_cards)
# Track cards by name for sticky note attachment
var cards_by_name: Dictionary = {}
for card: Card in card_pile["cards"]:
add_card(card, false)
card.transform.origin = cards[card.name]# Replacing position reference with card reference! Needed in next loop.
cards[card.name] = card
card.transform.origin = savegame.board_cards[card.name]
cards_by_name[card.name] = card
text_recovery[card.name] = card.text
card.picked_random = randoms.has( card.card_id )
for sticky:StickyNote in card_pile["sticky_notes"]:
card.picked_random = savegame.board_randoms.has(card.card_id)
for sticky: StickyNote in card_pile["sticky_notes"]:
text_recovery[sticky.name] = sticky.text
if stickies[sticky.name] is int:
if stickies[sticky.name] == -1:
var sticky_data = savegame.board_stickies[sticky.name]
if sticky_data is int:
if sticky_data == -1:
add_sticky_note(sticky, false)
elif stickies[sticky.name] is String:
cards[stickies[sticky.name]].attach_sticky_note(sticky)
elif sticky_data is String:
# Attached to a card
cards_by_name[sticky_data].attach_sticky_note(sticky)
else:
# Loose on board at position
insert_area(dropzone, sticky)
sticky.transform.origin = stickies[sticky.name]
sticky.picked_random = randoms.has( sticky.sticky_id )
sticky.transform.origin = sticky_data
func validate_board():
if current_context == NAVIGATE:
sticky.picked_random = savegame.board_randoms.has(sticky.sticky_id)
var needs_rebuild := false
for node in dropzone.get_children():
if node is Card:
match validate_card(node):
Error.OUT_OF_BOUNDS:
node.position = last_save_dict[node.name]
Error.ILLEGAL_STATE:
needs_rebuild = true
if node is StickyNote:
match validate_sticky(node):
Error.OUT_OF_BOUNDS:
node.position = last_save_dict[node.name]
Error.ILLEGAL_STATE:
needs_rebuild = true
for panel:StickyNotePanel in sticky_note_container.get_children():
if panel.attached_sticky_note != null:
match validate_sticky(panel.attached_sticky_note):
Error.OUT_OF_BOUNDS:
panel.attached_sticky_note.position = panel.ancor_position
Error.ILLEGAL_STATE:
needs_rebuild = true
# FIXME: currently, illegal temporary state exists a lot and needs to be rectified before this can be trusted.
if needs_rebuild and false:
for child in dropzone.get_children(): child.free()
for child in sticky_note_container.get_children(): child.free()
rebuild_from_savedict(last_save_dict)
current_dropzone_id = 0
current_sticky_note_id = 0
focus_stickies = false
current_context = NAVIGATE
else:
last_save_dict = get_save_dict()
func validate_sticky(note: StickyNote) -> CardBoard.Error:
if not get_viewport_rect().has_point(note.get_global_transform().origin):
@ -746,7 +716,7 @@ func validate_card(card: Card) -> CardBoard.Error:
func try_select_nearest_card(from: Vector2, towards: Vector2, include_stickies: bool = false) -> bool:
var selection_transform = Transform2D(0, from).looking_at(from+towards)
var selection_transform := Transform2D(0, from).looking_at(from+towards)
var scores: Dictionary[int, Area2D] = {-1: null}
for child:Area2D in dropzone.get_children():
@ -798,8 +768,8 @@ func try_select_nearest_empty_card(from: Vector2) -> bool:
return false
func get_distance_score(from: Vector2, to: Transform2D) -> int:
var diff = from * to
var dir = diff.normalized()
var diff := from * to
var dir := diff.normalized()
if dir.x > 0.5 and diff.length() > 0:
return int((abs(dir.y) + 0.5) * diff.length())
else:

View File

@ -23,7 +23,7 @@ func _ready():
%SkipButton.pressed.connect(card_burned.emit)
func burn_cards(_id: int, _repeat: bool = false) -> void:
var random_card_names: Array = State.save_game.board_state["randoms"]
var random_card_names: Array = State.save_game.board_randoms.duplicate()
for card_name in random_card_names:
if card_name.begins_with("p"):

View File

@ -20,8 +20,8 @@ func set_theme(new_theme:Theme):
current_main_theme = new_theme
theme_changed.emit(new_theme)
@export_file var user_settings_path:String = "user://user_settings.json"
@export_file var user_saves_path:String = "user://savegames"
const user_settings_path:String = "user://user_settings.json"
const user_saves_path:String = "user://savegames"
@export_group("Acessability")
@export var reduce_motion: bool = false:

View File

@ -105,7 +105,7 @@ func rebuild():
state.text = TranslationServer.translate(("""
Mementos collected: %d/4
Connections found: %d/8
""").strip_edges()) % [save.calculate_completed_sequences(), save.calculate_total_connections()]
""").strip_edges()) % [save.completed_sequences, save.total_connections]
#Secrets found: 1/4
info.add_child(room)

View File

@ -80,7 +80,7 @@ func _on_game_picked(id: int) -> void:
func _on_delete_requested(id: int) -> void:
var save_to_delete := saves[id]
var save_path := save_to_delete.filepath
var save_path := save_to_delete.file_name
var thumbnail_path := "%s/thumbnails/%s.png" % [save_path.get_base_dir(), save_to_delete.unique_save_name]
# Delete the save file