diff --git a/scenes/game_elements/characters/enemies/guard/components/guard.gd b/scenes/game_elements/characters/enemies/guard/components/guard.gd index 72a9c1bcc3..5224779304 100644 --- a/scenes/game_elements/characters/enemies/guard/components/guard.gd +++ b/scenes/game_elements/characters/enemies/guard/components/guard.gd @@ -155,6 +155,8 @@ func _ready() -> void: guard_movement.destination_reached.connect(self._on_destination_reached) guard_movement.still_time_finished.connect(self._on_still_time_finished) guard_movement.path_blocked.connect(self._on_path_blocked) + add_to_group("persistence_listeners") + call_deferred("_load_state") func _process(delta: float) -> void: @@ -480,3 +482,45 @@ func _on_detection_area_body_exited(body: Node2D) -> void: if state == State.DETECTING: guard_movement.stop_moving() state = State.INVESTIGATING + +## Responds to the checkpoint's activated signal and records patrol parameters. +func _on_checkpoint_activated(checkpoint: Checkpoint) -> void: + if not checkpoint.save_void_and_enemies: + return + + if GameState.scene == null or not "facts" in GameState.scene: + return + + var save_key := str(get_path()) + var data := { + "position": global_position, + "state": state, + "current_idx": current_patrol_point_idx, + "prev_idx": previous_patrol_point_idx + } + + if guard_movement: + data["movement_dest"] = guard_movement.destination + + GameState.scene.facts[save_key] = data + + +## Reconstructs the guard's patrol route context upon scene load. +func _load_state() -> void: + if GameState.scene == null or not "facts" in GameState.scene: + return + + var save_key := str(get_path()) + if GameState.scene.facts.has(save_key): + var data: Dictionary = GameState.scene.facts[save_key] + + global_position = data["position"] + if "_last_position" in self: + set("_last_position", data["position"]) + + state = data["state"] + current_patrol_point_idx = data["current_idx"] + previous_patrol_point_idx = data["prev_idx"] + + if guard_movement and data.has("movement_dest"): + guard_movement.set_destination(data["movement_dest"]) diff --git a/scenes/game_elements/characters/enemies/void_spreading_enemy/components/tile_map_cover.gd b/scenes/game_elements/characters/enemies/void_spreading_enemy/components/tile_map_cover.gd index f29e551c18..874c9574ef 100644 --- a/scenes/game_elements/characters/enemies/void_spreading_enemy/components/tile_map_cover.gd +++ b/scenes/game_elements/characters/enemies/void_spreading_enemy/components/tile_map_cover.gd @@ -110,6 +110,8 @@ func _ready() -> void: for coord: Vector2i in get_used_cells(): consume(coord, true) + add_to_group("persistence_listeners") + _load_state() ## Cover all [param cells] with [member terrain_name], hiding any nodes in those cells which are @@ -176,5 +178,27 @@ func uncover_all(duration: float, animate_modulate: bool = false) -> void: await tween.finished clear() - if animate_modulate: - self.modulate.a = 1.0 + self.modulate.a = 1.0 + +## Responds to the checkpoint's activated signal and saves consumed tiles if permitted. +func _on_checkpoint_activated(checkpoint: Checkpoint) -> void: + if not checkpoint.save_void_and_enemies: + return + + if GameState.scene == null or not "facts" in GameState.scene: + return + + var save_key := str(get_path()) + GameState.scene.facts[save_key] = get_used_cells() + + +## Reconstructs the holes in the TileMap immediately on scene load. +## Executes without animation to prevent visual glitches upon respawn. +func _load_state() -> void: + if GameState.scene == null or not "facts" in GameState.scene: + return + + var save_key := str(get_path()) + if GameState.scene.facts.has(save_key): + var saved_tiles: Array = GameState.scene.facts[save_key] + consume_cells(saved_tiles, true) diff --git a/scenes/game_elements/characters/enemies/void_spreading_enemy/components/void_spreading_enemy.gd b/scenes/game_elements/characters/enemies/void_spreading_enemy/components/void_spreading_enemy.gd index f5176c38ab..81b19f7476 100644 --- a/scenes/game_elements/characters/enemies/void_spreading_enemy/components/void_spreading_enemy.gd +++ b/scenes/game_elements/characters/enemies/void_spreading_enemy/components/void_spreading_enemy.gd @@ -131,6 +131,8 @@ func _ready() -> void: idle_patrol_path = idle_patrol_path state = state _last_position = position + add_to_group("persistence_listeners") + _load_state() if Engine.is_editor_hint(): set_process(false) @@ -203,3 +205,42 @@ func _on_player_capture_area_body_entered(body: Node2D) -> void: var player := body as Player player.defeat(true) + + +## Responds to the checkpoint's activated signal and records position and path progress. +func _on_checkpoint_activated(checkpoint: Checkpoint) -> void: + if not checkpoint.save_void_and_enemies: + return + + if GameState.scene == null or not "facts" in GameState.scene: + return + + var save_key := str(get_path()) + var data := { + "position": global_position, + "last_position": _last_position, + "state": state + } + + if path_walk_behavior: + data["path_progress"] = path_walk_behavior.get("progress_ratio") + + GameState.scene.facts[save_key] = data + + +## Reconstructs the enemy's exact position and path progress on scene load. +## Overrides the internal last_position variable to prevent massive particle bursts caused by teleportation. +func _load_state() -> void: + if GameState.scene == null or not "facts" in GameState.scene: + return + + var save_key := str(get_path()) + if GameState.scene.facts.has(save_key): + var data: Dictionary = GameState.scene.facts[save_key] + + global_position = data["position"] + _last_position = data["last_position"] + state = data["state"] + + if path_walk_behavior and data.has("path_progress"): + path_walk_behavior.set("progress_ratio", data["path_progress"]) diff --git a/scenes/game_elements/props/checkpoint/components/checkpoint.gd b/scenes/game_elements/props/checkpoint/components/checkpoint.gd index d10f67bd38..929a09fea7 100644 --- a/scenes/game_elements/props/checkpoint/components/checkpoint.gd +++ b/scenes/game_elements/props/checkpoint/components/checkpoint.gd @@ -3,6 +3,7 @@ @tool class_name Checkpoint extends Area2D +signal activated ## A place where the player respawns if the current scene is reloaded. ## ## A checkpoint is initially invisible. It becomes visible when the player enters the area, which @@ -29,6 +30,10 @@ const REQUIRED_ANIMATIONS := [&"idle", &"appear"] ## be able to interact with the checkpoint. @export var dialogue: DialogueResource = preload("uid://bug2aqd47jgyu") +## Determines if the enemies and the void layer should save their state when +## the player activates this checkpoint. +@export var save_void_and_enemies: bool = false + ## The point where the player will spawn. @onready var spawn_point: SpawnPoint = %SpawnPoint @@ -72,6 +77,10 @@ func _ready() -> void: ## Makes this the active checkpoint. func activate() -> void: + for listener: Node in get_tree().get_nodes_in_group("persistence_listeners"): + if listener.has_method("_on_checkpoint_activated") and not activated.is_connected(listener._on_checkpoint_activated): + activated.connect(listener._on_checkpoint_activated) + activated.emit(self) GameState.scene.spawn_point = owner.get_path_to(spawn_point) GameState.save() diff --git a/scenes/globals/game_state/per_scene_state.gd b/scenes/globals/game_state/per_scene_state.gd index eabb13dcdf..dbb73b508a 100644 --- a/scenes/globals/game_state/per_scene_state.gd +++ b/scenes/globals/game_state/per_scene_state.gd @@ -24,6 +24,9 @@ signal lights_changed(lights_on: bool, immediate: bool) ## Set when any introductory dialogue has been played for the current scene. @export var intro_dialogue_shown: bool +## Dictionary to store arbitrary persistent scene facts, such as enemy states and consumed tiles. +@export var facts: Dictionary = {} + ## Current state of artificial lights. Set with [member set_lights_on]. This is ## not saved to disk since the lights are controlled programmatically. var lights_on: bool