Skip to content

Checkpoint: Add tile change persistence - #2877

Merged
manuq merged 16 commits into
endlessm:mainfrom
NataliaOspinal:main
Nov 13, 2026
Merged

manuq merged 16 commits into
endlessm:mainfrom
NataliaOspinal:main

Conversation

@NataliaOspinal

@NataliaOspinal NataliaOspinal commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Whenever an enemy changes part of the tilemap as the VoidSpreadingEnemy does, if the player reaches a checkpoint and dies, the scene resets to its original state instead of keeping the changes that we'd like to keep in previous traveled zones for storytelling or possible gameplay mechanics.

To fix this, I created a new Checkpoint child class (I wasn't sure I could edit the original, but if that's the idea I could just add a checkbox for persistent enemies and the respective exports to incorporate the code to the original) that effectively tracks with both _process and _physics_process for accuracy of tile changes, checking the respective paths and its enemy states.

Note: I've included (as part of the code) a way to track the changes of a possible void-spreading guard, since that was the idea shown in the screenshots of the issue!

Resolves: #1208

@NataliaOspinal
NataliaOspinal requested a review from a team as a code owner September 10, 2026 23:11
@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Test build no longer available.


# Store everything including position, paths, and consumed tiles in a single dictionary
# bound strictly to each enemy to prevent synchronization issues.
static var saved_enemy_states: Dictionary = {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As commented on #1208 (comment) I don't think static variables are an appropriate approach.

Gotta fuse the new code with the og checkpoint in a way that doesn't affect (?) existing ones
@manuq

manuq commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

@NataliaOspinal I see you have added new changes. Let us know when it's ready for review again.

@NataliaOspinal
NataliaOspinal requested a review from wjt September 17, 2026 22:57
@NataliaOspinal

NataliaOspinal commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Hello! The code is ready for review, I gave notice on the Issue itself a few days ago, but just in case I'm giving notice here too! @manuq

@manuq manuq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good, but I wasn't able to test it! Please provide a working example. You could add one in scenes/dev/level_design/. Check for instance, the scenes/dev/level_design/doors_and_levers/.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment in the ticket "Imagine that the template enemies are void-spreading enemies which chase you, but can't cross the bridges" was hypothetical. Sorry for the confusion. Please revert any changes to guard.gd.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay got it! I created a testing scene inside scenes/dev/level_design/void_persistence and reverted the changes in the guard.gd script! Let me know if more changes are needed!

@NataliaOspinal
NataliaOspinal requested a review from a team as a code owner September 24, 2026 00:38
@manuq

manuq commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Nice, if I abandon the demo and continue it, I can see it persists:

void-persisted.webm

And it resets properly if I restart the demo quest.

Comment on lines +225 to +226
if path_walk_behavior:
data["path_progress"] = path_walk_behavior.get("progress_ratio")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PathWalkBehavior doesn't have any progress_ratio property. Am I missing something or is this a leftover?

Comment on lines +245 to +246
if path_walk_behavior and data.has("path_progress"):
path_walk_behavior.set("progress_ratio", data["path_progress"])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, no "progress_ratio" in PathWalkBehavior.

Comment on lines +80 to +83
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the nodes in a group for connecting and then immediately calling the signal, makes the signal irrelevant. This could be done by calling the method directly:

get_tree().call_group("persistence_listeners", "_on_checkpoint_activated")

Now, there is a better way of doing this, with a signal and without the grouping. For that, please check how the signal lights_changed in per_scene_state.gd is used to turn on/off lights in the game. Then add a similar signal checkpoint_activated one.

Comment on lines +27 to +28
## Dictionary to store arbitrary persistent scene facts, such as enemy states and consumed tiles.
@export var facts: Dictionary = {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting! We already have 2 other generic facts, one global GameState.global.facts, and one per-quest GameState.quest.facts.

For collected buttons, we use those because we don't want the buttons to re-appear when replaying the scene. Now I think for the Void enemy position and consumed tiles, it makes sense to add a new per-scene generic facts. Adding this here will make them reset when the scene changes. Seems to be what we want.

@manuq

manuq commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

@NataliaOspinal this is looking nice! If you can, in addition to the request above, please:

@manuq

manuq commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

@NataliaOspinal do you know if you will continue working on this?

@manuq

manuq commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

I'm going to close this for now. @NataliaOspinal feel free to reopen!

@manuq manuq closed this Oct 6, 2026
@NataliaOspinal

Copy link
Copy Markdown
Contributor Author

I think I got it now! I edited the dev level and implemented the variables in PathWalkBehavior, plus changed the get_tree().call_group("persistence_listeners", "_on_checkpoint_activated") to a signal in per_scene_state! I did change the scripts a couple of days ago but I was struggling to make the void enemies chase the player in the dev level so it can be easier to check the changes. I'll push the changes now, let me know if it needs more editing!

@NataliaOspinal

Copy link
Copy Markdown
Contributor Author

I'm going to close this for now. @NataliaOspinal feel free to reopen!

Should I create a new PR with a reference to this one? It doesn't let me re-open

@manuq manuq reopened this Oct 7, 2026
@manuq

manuq commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Should I create a new PR with a reference to this one? It doesn't let me re-open

Interesting. I just reopened it for you.

@NataliaOspinal

Copy link
Copy Markdown
Contributor Author

Hi! @manuq Just in case I'm letting you know I pushed the new changes last time, the day the issue was re-opened. Let me know if there's more editing to be done! Thank you for the helpful reviews! 😄

@manuq manuq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I get cought by the second Void enemy and then go back to the checkpoint, the game crashes and the game state is not recoverable. If I stop the game and try to continue, I get the same error again:

E 0:02:32:860   NavigationFollowWalkBehavior._physics_process: Invalid access to property or key 'global_position' on a base object of type 'Nil'.
  <GDScript Fuente>navigation_follow_walk_behavior.gd:144 @ NavigationFollowWalkBehavior._physics_process()
  <Rastreo de Pila>navigation_follow_walk_behavior.gd:144 @ _physics_process()
Grabacion.de.pantalla.desde.2026-10-13.10-15-38.mp4

@NataliaOspinal

Copy link
Copy Markdown
Contributor Author

I've tested it from the dev archipelago now, it works! I'm not sure if I should have updated my main branch with the original main branch before pushing the fixes, but I have a "bringing-back-changes" branch that exists before that merge just in case.

@NataliaOspinal
NataliaOspinal requested a review from manuq October 28, 2026 17:07
@manuq

manuq commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

@NataliaOspinal thanks! I will check this soon!

@NataliaOspinal

Copy link
Copy Markdown
Contributor Author

Thank you for helping me restore the main branch! Is it ready for merge? I tested it again a couple of times to make sure and I think it seems to work fine on my end?

Comment on lines +104 to +105
if parent == null:
continue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see that you added this to avoid the problem of unassigned elements in the inspector, which the void_persistence_test.tscn has:

image

I think it's fine, but I will add a comment about it in the code.

Comment on lines +115 to +120
if (
GameState.scene != null
and not GameState.scene.checkpoint_activated.is_connected(_on_checkpoint_activated)
):
GameState.scene.checkpoint_activated.connect(_on_checkpoint_activated)
_load_state()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in a _ready function of a @tool script that already returns early if running in the editor. So I don't think the conditional is needed. Because a TileMapCover will always be part of a scene. I tried it without the conditional and the example works fine. I'm going to drop it.

Comment on lines +180 to +188
if animate_modulate:
self.modulate.a = 1.0
self.modulate.a = 1.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reverts the change in 48565eb. So I'll bring it back.

Comment on lines +193 to +194
if not checkpoint.save_void_and_enemies:
return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that the checkpoint should have the knowledge about this node wanting to be saved. Instead, the setting for saving should be in each TileMapCover and void_spreading_enemy.gd

Comment on lines +196 to +197
if GameState.scene == null or not "facts" in GameState.scene:
return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, this extra safety isn't needed. This node will be part of a scene, and a PerSceneState always has a facts property.

Comment on lines +133 to +138
if (
GameState.scene != null
and not GameState.scene.checkpoint_activated.is_connected(_on_checkpoint_activated)
):
GameState.scene.checkpoint_activated.connect(_on_checkpoint_activated)
_load_state()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as with the tilemap cover. Instead of adding the extra safety, do an early return in the _ready when running in the editor. And connect later.


## Makes this the active checkpoint.
func activate() -> void:
if GameState.scene != null:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can see that scene here can't be null, because the next line uses it (GameState.scene.spawn_point).

if GameState.scene.facts.has(save_key):
var data: Dictionary = GameState.scene.facts[save_key]

global_position = data["position"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency this should be:

Suggested change
global_position = data["position"]
global_position = data["global_position"]

And the same when loading the save.

signal lights_changed(lights_on: bool, immediate: bool)

## Emitted when the player activates a checkpoint.
@warning_ignore("unused_signal")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid this warning the checkpoint script can call a method in PerSceneState, rather than using the signal directly:

func activate_spawnpoint() -> void:
        checkpoint_activated.emit()

@manuq
manuq merged commit 8f72093 into endlessm:main Nov 13, 2026
4 checks passed
@manuq

manuq commented Nov 13, 2026

Copy link
Copy Markdown
Collaborator

Thank you for helping me restore the main branch! Is it ready for merge? I tested it again a couple of times to make sure and I think it seems to work fine on my end?

I did some changes for merging it. Congratulations!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support checkpoints in expanding void levels

3 participants