diff --git a/scenes/game_elements/characters/player/components/player.gd b/scenes/game_elements/characters/player/components/player.gd index 1822d18faa..35bd2cb0d3 100644 --- a/scenes/game_elements/characters/player/components/player.gd +++ b/scenes/game_elements/characters/player/components/player.gd @@ -195,8 +195,7 @@ func defeat(falling: bool = false) -> void: # Stop moving the player. velocity = Vector2.ZERO - # Decrement lives and save the new count - GameState.decrement_lives() + GameState.state.player_state.decrement_lives() if falling: var tween := create_tween() @@ -205,7 +204,7 @@ func defeat(falling: bool = false) -> void: await get_tree().create_timer(2.0).timeout # Check if player has lives remaining - if GameState.current_lives > 0: + if GameState.state.player_state.lives > 0: # Still have lives - reload current scene/checkpoint SceneSwitcher.reload_with_transition(Transition.Effect.FADE, Transition.Effect.FADE) else: @@ -237,11 +236,10 @@ func _on_abilities_changed() -> void: _toggle_abilities() -## Handles game over logic: restarts from the beginning of the current challenge -## with lives reset to 3. +## Handles game over logic: restarts from the beginning of the current challenge, +## with lives reset. func _handle_game_over() -> void: - # Reset lives to 3 - GameState.reset_lives() + GameState.state.player_state.reset_lives() # Get the start of the current challenge var challenge_start_scene: String = GameState.get_challenge_start_scene() diff --git a/scenes/game_elements/props/eternal_loom/components/eternal_loom.gd b/scenes/game_elements/props/eternal_loom/components/eternal_loom.gd index 1e1df9ed50..60b51e3a12 100644 --- a/scenes/game_elements/props/eternal_loom/components/eternal_loom.gd +++ b/scenes/game_elements/props/eternal_loom/components/eternal_loom.gd @@ -27,7 +27,7 @@ func _ready() -> void: talk_behavior.title = "have_threads" if _have_threads else "no_threads" interact_area.interaction_ended.connect(self._on_interaction_ended) - if GameState.incorporating_threads: + if GameState.state.quest_state.incorporating_threads: if Transitions.is_running(): await Transitions.finished @@ -42,7 +42,6 @@ func _ready() -> void: if elder: await elder.congratulate_player() - GameState.set_incorporating_threads(false) GameState.mark_quest_completed() @@ -59,7 +58,7 @@ func _on_interaction_ended() -> void: if not ProjectSettings.get_setting(ThreadbareProjectSettings.SKIP_SOKOBANS): # Hide interact label during scene transition interact_area.disabled = true - GameState.set_incorporating_threads(true) + GameState.state.quest_state.incorporating_threads = true SceneSwitcher.change_to_file_with_transition(SOKOBANS.pick_random()) else: GameState.mark_quest_completed() @@ -73,7 +72,10 @@ func on_offering_succeeded() -> void: func is_item_offering_possible() -> bool: return ( - GameState.current_quest - and GameState.current_quest.threads_to_collect > 0 - and GameState.items_collected().size() >= GameState.current_quest.threads_to_collect + GameState.state.quest_state + and GameState.state.quest_state.quest.threads_to_collect > 0 + and ( + GameState.items_collected().size() + >= GameState.state.quest_state.quest.threads_to_collect + ) ) diff --git a/scenes/game_elements/props/spawn_point/components/spawn_point.gd b/scenes/game_elements/props/spawn_point/components/spawn_point.gd index 6cad7ab6b3..203e4d0f9a 100644 --- a/scenes/game_elements/props/spawn_point/components/spawn_point.gd +++ b/scenes/game_elements/props/spawn_point/components/spawn_point.gd @@ -21,7 +21,7 @@ func _ready() -> void: if Engine.is_editor_hint(): return - if GameState.current_spawn_point == get_tree().current_scene.get_path_to(self): + if GameState.state.scene_state.spawn_point == get_tree().current_scene.get_path_to(self): move_player_to_self_position() diff --git a/scenes/game_logic/light2d_behaviors/artificial_light_behavior.gd b/scenes/game_logic/light2d_behaviors/artificial_light_behavior.gd index c18e24b777..3e80ff4cdd 100644 --- a/scenes/game_logic/light2d_behaviors/artificial_light_behavior.gd +++ b/scenes/game_logic/light2d_behaviors/artificial_light_behavior.gd @@ -48,8 +48,8 @@ func _ready() -> void: if Engine.is_editor_hint(): return initial_energy = light.energy - light.visible = GameState.lights_on - light.enabled = GameState.lights_on + light.visible = GameState.state.scene_state.lights_on + light.enabled = GameState.state.scene_state.lights_on GameState.lights_changed.connect(_on_lights_changed) diff --git a/scenes/globals/game_state/game_state.gd b/scenes/globals/game_state/game_state.gd index 6f8472ddfe..61b6c47de9 100644 --- a/scenes/globals/game_state/game_state.gd +++ b/scenes/globals/game_state/game_state.gd @@ -1,4 +1,3 @@ -# gdlint: disable=max-public-methods # SPDX-FileCopyrightText: The Threadbare Authors # SPDX-License-Identifier: MPL-2.0 @tool @@ -16,9 +15,6 @@ signal item_consumed(item: InventoryItem) ## or consuming an item. signal collected_items_changed(updated_items: Array[InventoryItem]) -## Emitted when the player's lives change. -signal lives_changed(new_lives: int) - ## Emitted when it becomes too dark that artificial lights can turn on, or ## when darkness goes away so artificial lights should turn off. signal lights_changed(lights_on: bool, immediate: bool) @@ -29,22 +25,7 @@ signal completed_quests_changed ## Emitted when lore or StoryQuest player abilities change. signal abilities_changed -const GAME_STATE_PATH := "user://game_state.cfg" -const INVENTORY_SECTION := "inventory" -const INVENTORY_ITEMS_KEY := "items_collected" -const QUEST_SECTION := "quest" -const QUEST_PATH_KEY := "resource_path" -const QUEST_CHALLENGE_START_KEY := "challenge_start_scene" -const QUEST_PLAYER_ABILITIES_KEY := "quest_player_abilities" -const GLOBAL_SECTION := "global" -const GLOBAL_INCORPORATING_THREADS_KEY := "incorporating_threads" -const COMPLETED_QUESTS_KEY := "completed_quests" -const CURRENTSCENE_KEY := "current_scene" -const SPAWNPOINT_KEY := "current_spawn_point" -const GAME_PLAYER_ABILITIES_KEY := "game_player_abilities" -const LIVES_KEY := "current_lives" -const MAX_LIVES := 2 ** 53 -const DEBUG_LIVES := false +const SAVE_PATH := "user://game_state.tres" ## The player abilities to have from the beginning ## when not running the game from the main scene. @@ -56,74 +37,31 @@ const DEBUG_PLAYER_ABILITIES := [ ## Scenes to skip from saving. const TRANSIENT_SCENES := [ "res://scenes/menus/title/title_screen.tscn", - "res://scenes/menus/intro/intro.tscn", ] -## Global inventory, used to track the items the player obtains and that -## can be added to the loom. -@export var inventory: Array[InventoryItem] = [] -@export var current_spawn_point: NodePath - -## Player abilities for the whole game. -## [br][br] -## These are flags that enable systems or mechanics for the player progression -## during the entire game.[br] -## When involved in a quest, [member quest_player_abilities] are used instead. -## After completing a lore quest, [member quest_player_abilities] are copied to this and persisted. -@export var game_player_abilities: int = 0: - set = _set_game_player_abilities - -## Player abilities for the current quest. -## [br][br] -## These are flags that enable systems or mechanics for the quest progression[br] -## When involved in a lore quest, [member game_player_abilities] are copied to this. -## When involved in a StoryQuest, this starts in zero (without abilities). -@export var quest_player_abilities: int = 0: - set = _set_quest_player_abilities - -## Current number of lives the player has. -var current_lives: int = MAX_LIVES - -## Current state of artificial lights. -var lights_on: bool - -## Set when the loom transports the player to a trio of Sokoban puzzles, so that -## when the player returns to Fray's End the loom can trigger a brief cutscene. -var incorporating_threads: bool = false - -## Set when any introductory dialogue has been played for the current scene. -## Cleared when the scene changes. -var intro_dialogue_shown: bool = false - -## The paths to the [Quest]s that the player has completed, in the order that they were completed. -var completed_quests: Array[String] = [] - -## The quest that the player is currently playing, or [code]null[/code] if they -## are not playing a quest. Update this with [method start_quest], [method -## mark_quest_completed] and [method abandon_quest]. -var current_quest: Quest - ## The progress is persisted only if the game is run normally from the main scene. ## Otherwise, it means we are playing a specific scene: the current scene from the editor or ## with a direct URL hash to a scene in the web build. In the latter cases, this variable is false. var persist_progress: bool -var _state := ConfigFile.new() +var state: GlobalState -func _validate_property(property: Dictionary) -> void: - match property["name"]: - # Treat the player abilities as bit flags. - # The @export_flags would be ideal but it expects constant - # strings, and we want to use the PlayerAbilities enum keys - # as hint strings. - # This also requires this script to be a @tool. - "lore_player_abilities", "storyquest_player_abilities": - property.hint = PROPERTY_HINT_FLAGS - property.hint_string = ",".join(Enums.PlayerAbilities.keys()) +func _ready() -> void: + if Engine.is_editor_hint(): + return + if ResourceLoader.exists(SAVE_PATH): + state = ResourceLoader.load(SAVE_PATH, "", ResourceLoader.CACHE_MODE_IGNORE) + + if state: + state = state.duplicate_deep(Resource.DEEP_DUPLICATE_INTERNAL) + else: + state = GlobalState.new() + + # TODO? + state.completed_quests_changed.connect(completed_quests_changed.emit) -func _ready() -> void: var current_scene := get_tree().current_scene var initial_scene_uid := ( ResourceLoader.get_resource_uid(current_scene.scene_file_path) if current_scene else -1 @@ -140,41 +78,31 @@ func _ready() -> void: set_ability(ability, true) return - var err := _state.load(GAME_STATE_PATH) - if err != OK and err != ERR_FILE_NOT_FOUND: - push_error("Failed to load %s: %s" % [GAME_STATE_PATH, err]) - - if DEBUG_LIVES: - prints("[LIVES DEBUG] GameState initialized with", current_lives, "lives") - - -## Set the [member incorporating_threads] flag. -func set_incorporating_threads(new_incorporating_threads: bool) -> void: - incorporating_threads = new_incorporating_threads - _state.set_value(GLOBAL_SECTION, GLOBAL_INCORPORATING_THREADS_KEY, incorporating_threads) - _save() - ## Set [member current_quest] and clear the [member inventory]. ## Also resets lives to maximum when starting a quest. func start_quest(quest: Quest) -> void: _do_clear_inventory() - _update_inventory_state() - current_quest = quest - _state.set_value(QUEST_SECTION, QUEST_PATH_KEY, quest.resource_path) + + var qs := QuestState.new() + state.quest_state = qs + qs.quest = quest + _do_set_scene(quest.first_scene, ^"") # Set the challenge start scene to the first scene of the quest - _state.set_value(QUEST_SECTION, QUEST_CHALLENGE_START_KEY, quest.first_scene) + qs.challenge_start_scene_path = quest.first_scene - if current_quest.is_lore_quest: - quest_player_abilities = game_player_abilities + if quest.is_lore_quest: + # Duplicate the current global player state. If the quest is completed, + # it will be copied back; if abandoned, it will be discarded. + qs.player_state = state.player_state.duplicate() else: - quest_player_abilities = 0 - _state.set_value(QUEST_SECTION, QUEST_PLAYER_ABILITIES_KEY, quest_player_abilities) + # Use a fresh player state for StoryQuests + qs.player_state = PlayerState.new() # Reset lives when starting a new quest - reset_lives() + qs.player_state.reset_lives() _save() @@ -190,14 +118,15 @@ func guess_quest(scene_path_or_uid: String) -> void: while dir_path != "res://": var quest_path := dir_path.path_join("quest.tres") if ResourceLoader.exists(quest_path, "Resource"): - current_quest = ResourceLoader.load(quest_path) as Quest - prints("Guessed quest", current_quest.resource_path, "from scene", scene_path) + var quest := ResourceLoader.load(quest_path) as Quest + state.quest_state = QuestState.new() + state.quest_state.quest = quest + state.quest_state.player_state = PlayerState.new() + prints("Guessed quest", quest.resource_path, "from scene", scene_path) return dir_path = dir_path.get_base_dir() - current_quest = null - ## Set the scene path and [member current_spawn_point]. func set_scene(scene_path: String, spawn_point: NodePath = ^"") -> void: @@ -210,90 +139,69 @@ func set_scene(scene_path: String, spawn_point: NodePath = ^"") -> void: ## Set the current spawn point and save it. func set_current_spawn_point(spawn_point: NodePath = ^"") -> void: - current_spawn_point = spawn_point - _state.set_value(GLOBAL_SECTION, SPAWNPOINT_KEY, current_spawn_point) + state.scene_state.spawn_point = spawn_point _save() ## Set the challenge start scene. This is the scene the player returns to ## when they run out of lives. func set_challenge_start_scene(scene_path: String) -> void: - _state.set_value(QUEST_SECTION, QUEST_CHALLENGE_START_KEY, scene_path) - - if DEBUG_LIVES: - prints("[LIVES DEBUG] Challenge start set to:", scene_path) - - _save() + if state.quest_state: + state.quest_state.challenge_start_scene_path = scene_path + _save() ## Get the challenge start scene, or the first scene of the current quest ## if no challenge start has been set. func get_challenge_start_scene() -> String: - var challenge_start: String = _state.get_value(QUEST_SECTION, QUEST_CHALLENGE_START_KEY, "") - - if challenge_start.is_empty() and current_quest: - challenge_start = current_quest.first_scene + var qs := state.quest_state + if not qs: + return "" - if DEBUG_LIVES: - prints( - "[LIVES DEBUG] No challenge start set, using quest first scene:", challenge_start - ) + if qs.challenge_start_scene_path: + return qs.challenge_start_scene_path - return challenge_start - - -## Returns [code]true[/code] if the player is currently on a quest; i.e. if -## [member current_quest] is not [code]null[/code]. -func is_on_quest() -> bool: - return current_quest != null - - -## Clear all quest-related state from the config file. -func _clear_quest_state() -> void: - if _state.has_section(QUEST_SECTION): - _state.erase_section(QUEST_SECTION) + return qs.quest.first_scene ## If [member current_quest] is set, record this quest as having been completed, ## and unset it. Also resets lives to maximum. func mark_quest_completed() -> void: - if current_quest: - if current_quest.is_lore_quest: - # Copy quest abilities to game abilities. - game_player_abilities = quest_player_abilities - _state.set_value(GLOBAL_SECTION, GAME_PLAYER_ABILITIES_KEY, game_player_abilities) - _do_set_quest_completed_state(current_quest, true) - current_quest = null - _clear_quest_state() - _save() + var qs := state.quest_state + if not qs: + return + + if qs.quest.is_lore_quest: + # Copy quest abilities to game abilities. + state.player_state = qs.player_state + + state.set_quest_completed_state(qs.quest, true) + state.quest_state = null + _save() ## Set the scene path and [member current_spawn_point] without triggering a save. func _do_set_scene(scene_path: String, spawn_point: NodePath = ^"") -> void: - if get_scene_to_restore() != scene_path: - intro_dialogue_shown = false + if state.current_scene_path != scene_path: + state.current_scene_path = scene_path + state.scene_state = PerSceneState.new() - current_spawn_point = spawn_point - _state.set_value(GLOBAL_SECTION, CURRENTSCENE_KEY, scene_path) - _state.set_value(GLOBAL_SECTION, SPAWNPOINT_KEY, current_spawn_point) + state.scene_state.spawn_point = spawn_point ## Add the [InventoryItem] to the [member inventory]. func add_collected_item(item: InventoryItem) -> void: - inventory.append(item) + state.inventory.append(item) item_collected.emit(item) collected_items_changed.emit(items_collected()) - _update_inventory_state() _save() +## Abandon the current quest without ## If [member current_quest] is set, unset it, without recording the quest as -## having been completed. Also resets lives to maximum. +## having been completed. func abandon_quest() -> void: - set_incorporating_threads(false) - _clear_quest_state() - current_quest = null - quest_player_abilities = 0 + state.quest_state = null clear_inventory() @@ -301,46 +209,13 @@ func abandon_quest() -> void: ## is_completed] is true, or remove [param quest] if [param is_completed] is ## false. func set_quest_completed_state(quest: Quest, is_completed: bool) -> void: - _do_set_quest_completed_state(quest, is_completed) + state.set_quest_completed_state(quest, is_completed) _save() -func _do_set_quest_completed_state(quest: Quest, is_completed: bool) -> void: - var quest_name := quest.resource_path - if is_completed: - if quest_name not in completed_quests: - completed_quests.append(quest_name) - completed_quests_changed.emit() - else: - while quest_name in completed_quests: - completed_quests.erase(quest_name) - completed_quests_changed.emit() - - -func _set_game_player_abilities(new_game_player_abilities: int) -> void: - if game_player_abilities == new_game_player_abilities: - return - game_player_abilities = new_game_player_abilities - abilities_changed.emit() - - -func _set_quest_player_abilities(new_quest_player_abilities: int) -> void: - if quest_player_abilities == new_quest_player_abilities: - return - quest_player_abilities = new_quest_player_abilities - abilities_changed.emit() - - -func _use_global_abilities() -> bool: - return current_quest == null - - ## Clear player abilities. func clear_abilities() -> void: - if _use_global_abilities(): - game_player_abilities = 0 - else: - quest_player_abilities = 0 + state.player_state.abilities = 0 ## Enable or disable a player ability. @@ -348,21 +223,10 @@ func clear_abilities() -> void: ## This behaves differently outside quests than when involved in a quest. func set_ability(ability: Enums.PlayerAbilities, is_enabled: bool) -> void: if is_enabled: - if not has_ability(ability): - if _use_global_abilities(): - game_player_abilities |= ability - else: - quest_player_abilities |= ability - else: - if has_ability(ability): - if _use_global_abilities(): - game_player_abilities &= ~ability - else: - quest_player_abilities &= ~ability - if _use_global_abilities(): - _state.set_value(GLOBAL_SECTION, GAME_PLAYER_ABILITIES_KEY, game_player_abilities) + state.player_state.abilities |= ability else: - _state.set_value(QUEST_SECTION, QUEST_PLAYER_ABILITIES_KEY, quest_player_abilities) + state.player_state.abilities &= ~ability + abilities_changed.emit() _save() @@ -372,138 +236,53 @@ func set_ability(ability: Enums.PlayerAbilities, is_enabled: bool) -> void: ## StoryQuests: the lore has player progression that last the whole game, ## while StoryQuests are narrative units and have their own player progression. func has_ability(ability: Enums.PlayerAbilities) -> bool: - if _use_global_abilities(): - return game_player_abilities & ability - return quest_player_abilities & ability + return state.player_state.abilities & ability ## Remove all [InventoryItem] from the [member inventory]. func clear_inventory() -> void: _do_clear_inventory() - _update_inventory_state() _save() ## Remove all [InventoryItem] from the [member inventory] without triggering a save. func _do_clear_inventory() -> void: - for item: InventoryItem in inventory.duplicate(): - inventory.erase(item) + for item: InventoryItem in state.inventory.duplicate(): + state.inventory.erase(item) item_consumed.emit(item) collected_items_changed.emit(items_collected()) ## Return all the items collected so far in the [member inventory]. func items_collected() -> Array[InventoryItem]: - return inventory.duplicate() - - -func _update_inventory_state() -> void: - _state.set_value( - INVENTORY_SECTION, - INVENTORY_ITEMS_KEY, - inventory.map(func(i: InventoryItem) -> InventoryItem.ItemType: return i.type) - ) - - -## Decrement the player's lives by 1. Does not go below 0. -## Saves the new lives count. -func decrement_lives() -> void: - current_lives = max(0, current_lives - 1) - _state.set_value(GLOBAL_SECTION, LIVES_KEY, current_lives) - _save() - lives_changed.emit(current_lives) - if DEBUG_LIVES: - prints("[LIVES DEBUG] Lives decremented to:", current_lives) - - -## Reset the player's lives to maximum (3). -## Saves the new lives count. -func reset_lives() -> void: - current_lives = MAX_LIVES - _state.set_value(GLOBAL_SECTION, LIVES_KEY, current_lives) - _save() - lives_changed.emit(current_lives) - if DEBUG_LIVES: - prints("[LIVES DEBUG] Lives reset to:", current_lives) - - -## Add one life to the player, up to the maximum. -## This is for future "extra life" pickups. -func add_life() -> void: - if current_lives < MAX_LIVES: - current_lives += 1 - _state.set_value(GLOBAL_SECTION, LIVES_KEY, current_lives) - _save() - lives_changed.emit(current_lives) - if DEBUG_LIVES: - prints("[LIVES DEBUG] Life added. Lives now:", current_lives) + return state.inventory.duplicate() func change_lights(new_lights_on: bool, immediate: bool = false) -> void: - lights_on = new_lights_on - lights_changed.emit(lights_on, immediate) - - -## Clear the per-scene state. -func clear_per_scene_state() -> void: - lights_on = false + state.scene_state.lights_on = new_lights_on + lights_changed.emit(new_lights_on, immediate) ## Clear the persisted state. func clear() -> void: - _state.clear() - completed_quests = [] - game_player_abilities = 0 - current_lives = MAX_LIVES - if DEBUG_LIVES: - prints("[LIVES DEBUG] State cleared. Lives reset to:", current_lives) + state = GlobalState.new() _save() ## Check if there is persisted state. func can_restore() -> bool: - return get_scene_to_restore() != "" + return state.current_scene_path != "" ## If there is a scene to restore, return it. func get_scene_to_restore() -> String: - return _state.get_value(GLOBAL_SECTION, CURRENTSCENE_KEY, "") - - -## Restore the persisted state. -func restore() -> Dictionary: - inventory.clear() - for item_type: InventoryItem.ItemType in _state.get_value( - INVENTORY_SECTION, INVENTORY_ITEMS_KEY, [] - ): - var item := InventoryItem.with_type(item_type) - inventory.append(item) - - if _state.has_section_key(QUEST_SECTION, QUEST_PATH_KEY): - current_quest = load(_state.get_value(QUEST_SECTION, QUEST_PATH_KEY)) as Quest - - var scene_path: String = _state.get_value(GLOBAL_SECTION, CURRENTSCENE_KEY, "") - current_spawn_point = _state.get_value(GLOBAL_SECTION, SPAWNPOINT_KEY, ^"") - incorporating_threads = _state.get_value( - GLOBAL_SECTION, GLOBAL_INCORPORATING_THREADS_KEY, false - ) - completed_quests = _state.get_value(GLOBAL_SECTION, COMPLETED_QUESTS_KEY, [] as Array[String]) - - game_player_abilities = _state.get_value(GLOBAL_SECTION, GAME_PLAYER_ABILITIES_KEY, 0) - quest_player_abilities = _state.get_value(QUEST_SECTION, QUEST_PLAYER_ABILITIES_KEY, 0) - - # Restore lives from saved state, default to MAX_LIVES if not found - current_lives = _state.get_value(GLOBAL_SECTION, LIVES_KEY, MAX_LIVES) - if DEBUG_LIVES: - prints("[LIVES DEBUG] State restored. Lives:", current_lives) - - return {"scene_path": scene_path, "spawn_point": current_spawn_point} + return state.current_scene_path func _save() -> void: if not persist_progress: return - _state.set_value(GLOBAL_SECTION, COMPLETED_QUESTS_KEY, completed_quests) - var err := _state.save(GAME_STATE_PATH) - if err != OK: - push_error("Failed to save settings to %s: %s" % [GAME_STATE_PATH, err]) + + var e := ResourceSaver.save(state, SAVE_PATH) + if e != OK: + push_error("Failed to save state to %s: %d %s" % [SAVE_PATH, e, error_string(e)]) diff --git a/scenes/globals/game_state/global_state.gd b/scenes/globals/game_state/global_state.gd new file mode 100644 index 0000000000..2af0e6953a --- /dev/null +++ b/scenes/globals/game_state/global_state.gd @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: The Threadbare Authors +# SPDX-License-Identifier: MPL-2.0 +class_name GlobalState +extends Resource + +signal completed_quests_changed + +# TODO: do we actually want this in quest_state? +@export var inventory: Array[InventoryItem] +@export var completed_quests: Array[Quest] + +## State concerning the quest that the player is currently playing, or +## [code]null[/code] if they are not playing a quest. +@export var quest_state: QuestState + +@export var current_scene_path: String +@export var scene_state: PerSceneState + +@export var global_player_state: PlayerState = PlayerState.new() + +var player_state: PlayerState: + get(): + if quest_state: + return quest_state.player_state + + return global_player_state + set(new_value): + push_error("player_state is a read-only proxy") + + +func set_quest_completed_state(quest: Quest, is_completed: bool) -> void: + if is_completed: + if quest not in completed_quests: + completed_quests.append(quest) + completed_quests_changed.emit() + changed.emit() + else: + while quest in completed_quests: + completed_quests.erase(quest) + completed_quests_changed.emit() + changed.emit() diff --git a/scenes/globals/game_state/global_state.gd.uid b/scenes/globals/game_state/global_state.gd.uid new file mode 100644 index 0000000000..eb9383cbfc --- /dev/null +++ b/scenes/globals/game_state/global_state.gd.uid @@ -0,0 +1 @@ +uid://d3q7tohvsnbn5 diff --git a/scenes/globals/game_state/player_state.gd b/scenes/globals/game_state/player_state.gd new file mode 100644 index 0000000000..8f0e2bbcf2 --- /dev/null +++ b/scenes/globals/game_state/player_state.gd @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: The Threadbare Authors +# SPDX-License-Identifier: MPL-2.0 +@tool +class_name PlayerState +extends Resource + +const MAX_LIVES := 3 ## 2 ** 53 + +## Current number of lives the player has. +@export_range(0, 2 ** 53, 1) var lives: int: + set(value): + lives = value + changed.emit() + +## Bitfield of elements of Enums.PlayerAbilities +@export var abilities: int: + set(value): + abilities = value + changed.emit() + + +func _validate_property(property: Dictionary) -> void: + match property.name: + "player_abilities": + property.hint = PROPERTY_HINT_FLAGS + # This is not a constant expression, so we cannot use @export_custom + property.hint_string = ",".join(Enums.PlayerAbilities.keys()) + + +func decrement_lives() -> void: + lives = max(0, lives - 1) + + +func reset_lives() -> void: + lives = MAX_LIVES diff --git a/scenes/globals/game_state/player_state.gd.uid b/scenes/globals/game_state/player_state.gd.uid new file mode 100644 index 0000000000..d902b65ea4 --- /dev/null +++ b/scenes/globals/game_state/player_state.gd.uid @@ -0,0 +1 @@ +uid://cs06npj4vvi1 diff --git a/scenes/globals/game_state/quest_state.gd b/scenes/globals/game_state/quest_state.gd new file mode 100644 index 0000000000..d4102ee5c3 --- /dev/null +++ b/scenes/globals/game_state/quest_state.gd @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: The Threadbare Authors +# SPDX-License-Identifier: MPL-2.0 +class_name QuestState +extends Resource + +@export var quest: Quest + +@export var challenge_start_scene_path: String + +## Set when the loom transports the player to a trio of Sokoban puzzles, so that +## when the player returns to Fray's End the loom can trigger a brief cutscene. +@export var incorporating_threads: bool + +@export var player_state: PlayerState = PlayerState.new() diff --git a/scenes/globals/game_state/quest_state.gd.uid b/scenes/globals/game_state/quest_state.gd.uid new file mode 100644 index 0000000000..7acb596ad6 --- /dev/null +++ b/scenes/globals/game_state/quest_state.gd.uid @@ -0,0 +1 @@ +uid://cyrblqxli2a2h diff --git a/scenes/globals/game_state/scene_state.gd b/scenes/globals/game_state/scene_state.gd new file mode 100644 index 0000000000..e98df47955 --- /dev/null +++ b/scenes/globals/game_state/scene_state.gd @@ -0,0 +1,11 @@ +class_name PerSceneState +extends Resource + +@export var spawn_point: NodePath + +## Set when any introductory dialogue has been played for the current scene. +## Cleared when the scene changes. +@export var intro_dialogue_shown: bool + +## Current state of artificial lights. +var lights_on: bool diff --git a/scenes/globals/game_state/scene_state.gd.uid b/scenes/globals/game_state/scene_state.gd.uid new file mode 100644 index 0000000000..6f92c5aa33 --- /dev/null +++ b/scenes/globals/game_state/scene_state.gd.uid @@ -0,0 +1 @@ +uid://d3r76ewcgcxjc diff --git a/scenes/globals/pause/pause_overlay.gd b/scenes/globals/pause/pause_overlay.gd index c74e8e7ee3..212b876de3 100644 --- a/scenes/globals/pause/pause_overlay.gd +++ b/scenes/globals/pause/pause_overlay.gd @@ -30,10 +30,10 @@ func toggle_pause() -> void: Input.set_default_cursor_shape(Input.CURSOR_ARROW if new_state else Input.CURSOR_CROSS) if new_state: - if not GameState.current_quest: + if not GameState.state.quest_state: skip_tutorial_button.hide() abandon_quest_button.hide() - elif GameState.current_quest.skippable: + elif GameState.state.quest_state.quest.skippable: skip_tutorial_button.show() abandon_quest_button.hide() else: diff --git a/scenes/globals/scene_switcher/scene_switcher.gd b/scenes/globals/scene_switcher/scene_switcher.gd index b8fa931581..c85b603983 100644 --- a/scenes/globals/scene_switcher/scene_switcher.gd +++ b/scenes/globals/scene_switcher/scene_switcher.gd @@ -54,10 +54,10 @@ func _restore_from_hash() -> void: # otherwise, this is an absolute uid:// or res:// path if ResourceLoader.exists(path, "PackedScene"): - if GameState.can_restore() and GameState.get_scene_to_restore() == path: - # Continue if the path matches the saved scene. This would happen - # if the player reloads the page while playing. - GameState.restore() + if GameState.get_scene_to_restore() == path: + # The path matches the saved scene. This would happen + # if the player reloads the page while playing. Don't clear the state. + pass else: # Otherwise, treat it as the player is debugging a scene from the web. # In that case, do not persist progress and clear the game state. @@ -152,8 +152,6 @@ func change_to_file(scene_path: String, spawn_point: NodePath = ^"") -> void: func change_to_packed(scene: PackedScene, spawn_point: NodePath = ^"") -> void: assert(scene != null) - GameState.clear_per_scene_state() - if get_tree().change_scene_to_packed(scene) == OK: _set_hash(scene.resource_path) GameState.set_scene(scene.resource_path, spawn_point) diff --git a/scenes/menus/debug/debug_completed_quest_list.gd b/scenes/menus/debug/debug_completed_quest_list.gd index b7e58f058d..adef16cf0c 100644 --- a/scenes/menus/debug/debug_completed_quest_list.gd +++ b/scenes/menus/debug/debug_completed_quest_list.gd @@ -29,7 +29,7 @@ func rebuild() -> void: bits.append(quest.resource_path.get_base_dir().substr(QUEST_ROOT.length() + 1)) var button := CheckButton.new() button.text = " ยท ".join(bits) - button.button_pressed = GameState.completed_quests.has(quest.resource_path) + button.button_pressed = GameState.state.completed_quests.has(quest) button.toggled.connect(_on_button_toggled.bind(quest)) add_child(button) diff --git a/scenes/menus/title/components/title_screen.gd b/scenes/menus/title/components/title_screen.gd index 5986388f72..4f3685e7b4 100644 --- a/scenes/menus/title/components/title_screen.gd +++ b/scenes/menus/title/components/title_screen.gd @@ -11,9 +11,8 @@ extends Control func _ready() -> void: if ProjectSettings.get_setting(ThreadbareProjectSettings.SKIP_SPLASH): - var saved_scene: Dictionary = GameState.restore() - if saved_scene["scene_path"]: - SceneSwitcher.change_to_file(saved_scene["scene_path"], saved_scene["spawn_point"]) + if GameState.can_restore(): + _on_main_menu_continue_pressed() else: _on_start_pressed() @@ -24,12 +23,11 @@ func _input(event: InputEvent) -> void: func _on_main_menu_continue_pressed() -> void: - var saved_scene: Dictionary = GameState.restore() ( SceneSwitcher . change_to_file_with_transition( - saved_scene["scene_path"], - saved_scene["spawn_point"], + GameState.state.current_scene_path, + GameState.state.scene_state.spawn_point, Transition.Effect.FADE, Transition.Effect.FADE, ) @@ -37,8 +35,7 @@ func _on_main_menu_continue_pressed() -> void: func _on_start_pressed() -> void: - if GameState.can_restore(): - GameState.clear() + GameState.clear() GameState.start_quest(tutorial_quest) SceneSwitcher.change_to_file_with_transition( diff --git a/scenes/quests/lore_quests/quest_000/3_sequence_puzzle/components/tutorial_sequence_puzzle.gd b/scenes/quests/lore_quests/quest_000/3_sequence_puzzle/components/tutorial_sequence_puzzle.gd index 6c47f4d0df..44fcba4572 100644 --- a/scenes/quests/lore_quests/quest_000/3_sequence_puzzle/components/tutorial_sequence_puzzle.gd +++ b/scenes/quests/lore_quests/quest_000/3_sequence_puzzle/components/tutorial_sequence_puzzle.gd @@ -8,7 +8,7 @@ extends Node2D func _ready() -> void: - if not GameState.intro_dialogue_shown: + if not GameState.state.scene_state.intro_dialogue_shown: await cinematic.cinematic_finished tutorial_npc.walk_path() diff --git a/scenes/quests/lore_quests/quest_000/4_ink_combat/components/tutorial_ink_combat.gd b/scenes/quests/lore_quests/quest_000/4_ink_combat/components/tutorial_ink_combat.gd index 45c2e2473a..9dd0e6e549 100644 --- a/scenes/quests/lore_quests/quest_000/4_ink_combat/components/tutorial_ink_combat.gd +++ b/scenes/quests/lore_quests/quest_000/4_ink_combat/components/tutorial_ink_combat.gd @@ -4,3 +4,9 @@ extends Node2D @onready var repel_powerup: CharacterBody2D = %RepelPowerup @onready var fill_game_logic: FillGameLogic = %FillGameLogic + + +func _ready() -> void: + # TODO: have Cinematic emit finished in the case where it doesn't play automatically? ugh. + if GameState.state.scene_state.intro_dialogue_shown: + fill_game_logic.start() diff --git a/scenes/quests/lore_quests/quest_000/4_ink_combat/tutorial_ink_combat.tscn b/scenes/quests/lore_quests/quest_000/4_ink_combat/tutorial_ink_combat.tscn index 1a1ec766f9..9e70723b1c 100644 --- a/scenes/quests/lore_quests/quest_000/4_ink_combat/tutorial_ink_combat.tscn +++ b/scenes/quests/lore_quests/quest_000/4_ink_combat/tutorial_ink_combat.tscn @@ -83,6 +83,7 @@ clip = &"cozy" metadata/_custom_type_script = "uid://c2lx7gvkonxaa" [node name="Cinematic" type="Node2D" parent="." unique_id=2113147326] +unique_name_in_owner = true script = ExtResource("3_pagku") dialogue = ExtResource("4_4u14d") metadata/_custom_type_script = "uid://x1mxt6bmei2o" diff --git a/scenes/ui_elements/cinematic/cinematic.gd b/scenes/ui_elements/cinematic/cinematic.gd index 977405692f..5cc715a8da 100644 --- a/scenes/ui_elements/cinematic/cinematic.gd +++ b/scenes/ui_elements/cinematic/cinematic.gd @@ -35,11 +35,11 @@ func _ready() -> void: func start() -> void: - if not GameState.intro_dialogue_shown: + if not GameState.state.scene_state.intro_dialogue_shown: DialogueManager.show_dialogue_balloon(dialogue, "", [self]) await DialogueManager.dialogue_ended cinematic_finished.emit() - GameState.intro_dialogue_shown = true + GameState.state.scene_state.intro_dialogue_shown = true if next_scene: ( diff --git a/scenes/ui_elements/story_quest_progress/components/story_quest_progress.gd b/scenes/ui_elements/story_quest_progress/components/story_quest_progress.gd index 8c09d5ec68..daefedda91 100644 --- a/scenes/ui_elements/story_quest_progress/components/story_quest_progress.gd +++ b/scenes/ui_elements/story_quest_progress/components/story_quest_progress.gd @@ -8,21 +8,21 @@ const ITEM_SLOT: PackedScene = preload("uid://1mjm4atk2j6e") func _ready() -> void: - if not GameState.current_quest: + if not GameState.state.quest_state: return - if GameState.current_quest.threads_to_collect == 0: + if GameState.state.quest_state.quest.threads_to_collect == 0: visible = false return # Add one slot for each item in the current quest - for _i: int in GameState.current_quest.threads_to_collect: + for _i: int in GameState.state.quest_state.quest.threads_to_collect: items_container.add_child(ITEM_SLOT.instantiate()) # On ready, the HUD is populated with the items that were collected so # far in the quest. var items_collected := GameState.items_collected() - for i: int in min(items_collected.size(), GameState.current_quest.threads_to_collect): + for i: int in min(items_collected.size(), GameState.state.quest_state.quest.threads_to_collect): items_container.get_child(i).start_as_filled(items_collected[i]) # Then, when each new item is collected, it is added to the progress UI diff --git a/scenes/world_map/components/frays_end.gd b/scenes/world_map/components/frays_end.gd index 16d8e45b6f..228ea9f703 100644 --- a/scenes/world_map/components/frays_end.gd +++ b/scenes/world_map/components/frays_end.gd @@ -10,9 +10,6 @@ func _ready() -> void: _update_story_quest_progress_visibility() GameState.collected_items_changed.connect(_update_story_quest_progress_visibility) - # Restore lives to maximum when entering Fray's End - GameState.reset_lives() - func _update_story_quest_progress_visibility(_new_items: Array[InventoryItem] = []) -> void: hud.change_story_quest_progress_visibility(eternal_loom.is_item_offering_possible()) diff --git a/scenes/world_map/components/quest_progress_unlocker.gd b/scenes/world_map/components/quest_progress_unlocker.gd index ad5b395cfa..d4b7d9337f 100644 --- a/scenes/world_map/components/quest_progress_unlocker.gd +++ b/scenes/world_map/components/quest_progress_unlocker.gd @@ -65,7 +65,7 @@ func _on_completed_quests_changed() -> void: func is_satisfied() -> bool: for quest: Quest in required_quests: # TODO: would be better if completed_quests held the Quest resources... - if quest.resource_path not in GameState.completed_quests: + if quest not in GameState.state.completed_quests: return false return true