Dev Log: 2026-06-09¶
Branch: rc/v0.1.4-dev
Easy difficulty and the undo system¶
Easy difficulty shipped. The headline feature is full undo: the player can browse backward through every checkpoint captured during a stage, preview the board state at any point, and confirm a restore to that state. The system also includes a difficulty-gated tutorial experience (tutorials on Easy, skip controls on Normal), a collapsible undo button, and a confirmation dialog for switching away from Easy mid-stage.
Why Easy mode exists¶
Panzer Island's reactive turn system is punishing for first-time players. Drones react to every move and attack, and a single mispositioned unit can cascade into a team wipe. Normal difficulty is tuned for players who read the board before they act. Easy difficulty is tuned for players who are still learning to read the board at all.
The design constraint was that Easy must not trivialize the game. The enemy roster, stats, and objectives are identical to Normal. The only difference is the safety net: tutorials guide every early-stage action, and undo lets the player rewind mistakes. The player still has to make the right decision; they just get multiple chances to arrive at it.
Checkpoint system¶
Every time the player begins a new sector (layer transition), the stage
controller snapshots the full game state: unit positions, HP, XP, level,
limit gauge, drone positions, drone HP, drone alert state, drone patrol
index, dead unit IDs, and per-stage XP tracking. Checkpoints are stored in
GameManager.checkpoints as an array of dictionaries, most-recent last, and
persisted to disk after every save so a crash or resume picks up the full
history.
The snapshot is built by _build_checkpoint() in stage_controller.gd. The
function iterates every live unit and drone, serializes their state into
flat dictionaries (positions as [x, y] arrays for JSON compatibility), and
returns the complete snapshot. Dead units are included with alive: false so
the restore path knows to set their HP to zero without removing them from the
roster.
The undo button¶
The undo button sits in the upper-right area of the HUD, below the pause
button. It is only visible on Easy difficulty and only enabled when at least
one checkpoint exists. The button uses a collapsible design: a main button
labeled "Undo" with a minimize chevron (<) that shrinks it to a single
icon-width strip, and an expand chevron (>) that restores it. The collapse
state is cosmetic only; the chevrons remain tappable regardless of the main
button's enabled state, so the player can always expand the button back.
The button's position shifts between collapsed and expanded states to keep
the right edge aligned. The collapsed strip sits at VP_W - SKILL_TOUCH_MIN
- RIGHT_PAD, while the expanded row sits at VP_W - BTN_W -
SKILL_TOUCH_MIN - RIGHT_PAD. The layout uses an HBoxContainer pattern
mirroring the field-repair button, with the main button on the left and the
chevron on the right.
Undo browse mode¶
Tapping the undo button enters browse mode. The current live state is
captured as _pre_browse_cp (the "pre-browse checkpoint"), the input block
is raised, all units and drones are frozen, water animations stop, and a
semi-transparent greyscale overlay fades in over the board. The undo bar
appears at the bottom of the screen with prev/next navigation, a position
label ("2 / 5"), a Confirm button, and a Cancel button.
The greyscale overlay is a ColorRect on its own CanvasLayer (layer 5),
with mouse_filter = MOUSE_FILTER_IGNORE so it does not interfere with
input. The overlay fades in over undo_overlay_fade_in seconds (default
0.25s) and fades out over undo_overlay_fade_out seconds (default 0.2s).
Both durations are @export vars on stage_controller.gd so they can be
tuned without code changes. The color and target alpha are also exported.
Freezing works by calling set_frozen(true) on every unit, drone, and ghost
drone. Units stop their idle animations. Drones stop their idle animations
and skip the _process wobble pass. AnimatedSprite2D children in the map
layer (water tiles) have their stop() / play("ripple") toggled. Looping
tweens (Katyusha's IC pulse, the limit-gauge HUD pulse) are paused via
set_speed_scale(0.0) and resumed with set_speed_scale(1.0) on unfreeze.
Checkpoint preview¶
_apply_checkpoint_preview(cp, permanent) is the core restore function. With
permanent=false (browse mode), it moves every unit and drone to their
checkpoint positions, restores HP/XP/level/limit gauge, hides drones that
were alive at the checkpoint but killed since then, and spawns ghost drone
visuals for drones that were alive at the checkpoint but killed in the live
game. Ghost drones are translucent copies that exist only in
_undo_preview_drones and are freed when the browse ends.
With permanent=true (confirmed undo), it does the same positioning but
actually removes drones not in the checkpoint via queue_free(). The
permanent flag is only used by the confirmed-undo path after a layer
reload, where the full stage rebuild requires drones to be genuinely
respawned and killed rather than visually toggled.
Matching is by spawn_cell, not by array index. This means if a drone
somehow drifted between checkpoint capture and preview, it still maps
correctly. The function also calls _update_hp_bar() on every matched drone
so the health display stays in sync with the restored state.
Cross-sector undo¶
Checkpoints can reference earlier sectors. When the player browses backward
past a sector boundary, _rebuild_map_for_layer() is called to tear down
the current sector's map tiles, props, and grid, and rebuild the target
sector's terrain from the JSON data. The grid is fully reconstructed via
grid.setup() with the target sector's cell data. Props are respawned from
the layer's props array.
Water animations are re-paused after rebuild if the browse is still active. The sector label in the HUD updates to reflect the previewed sector number. When the player cancels from a cross-sector preview, the current sector's map is rebuilt and the sector label restored.
The cancel path also fixes a subtle grid-occupancy issue: after
_rebuild_map_for_layer clears and rebuilds the grid, units at unchanged
positions are not automatically re-registered. _apply_checkpoint_preview
now always calls grid.set_occupied() for every unit and drone regardless
of whether their position changed, ensuring the grid stays consistent after
a sector rebuild.
Confirmed undo¶
When the player taps Confirm on the undo bar, a ConfirmDialog (extending
Modal) asks "Undo to sector N? Current progress will be lost." with Undo
and Cancel buttons. The dialog is on CanvasLayer 21, above the undo bar.
Yes/No buttons are ordered with Yes on the left, No on the right, matching
the convention that the destructive action is the primary choice.
If accepted, GameManager.truncate_after() drops all checkpoints after the
selected index. The stage is then fully reloaded via _advance_to_layer():
drones are cleared, the layer banner plays (unless in restore mode), terrain
is rebuilt, units are respawned, and _apply_checkpoint_preview(permanent=true)
strips out drones that should be dead at the restored state. The undo button
is re-enabled via _refresh_undo_button() and an autosave is triggered so
the player can resume from the restored state.
For cross-sector undo the same flow runs but targets the checkpoint's sector index rather than the current one.
The cancel button bug¶
A significant bug was found and fixed during development. The route cancel
button (the one that cancels a planned move/attack route) could leave the
game in an unresponsive state. The root cause: if selected became null or
the phase shifted away from PREVIEW while the route controls were visible,
the cancel handler's early-return guard (if phase != Phase.PREVIEW or
selected == null: return) skipped hiding the controls. The controls use
MOUSE_FILTER_STOP to capture clicks, so they remained visible and blocked
all board input while appearing to do nothing.
The fix adds an unconditional hud.set_route_controls_visible(false) before
the early return. When selected is null the phase is also reset to SELECT.
A deferred safeguard function (_ensure_interactive_after_cancel) was added
as a backstop: if the game is still in ANIMATING or DRONE_REACT phase on the
next frame, it forces a reset to SELECT, drains any stuck input blocks, and
refreshes the HUD. The same deferred safeguard is applied to the auto-attack
cancel path, where the async loop's cleanup (_reselect_after_action) can
fail to fire if the loop's tween is killed mid-await.
Difficulty switching¶
The difficulty selector on the world map is a grid of buttons styled as a
ConfirmDialog (replacing the raw AcceptDialog from the initial
implementation). Switching from Easy to Normal when the player has not yet
cleared any stage on Normal triggers a confirmation: "Switch to Normal
difficulty? Normal has no tutorials or undo button." The check uses
GameManager.has_cleared_any_on_normal_or_higher(), which scans saved stage
results. Switching to Easy from Normal or Hard is unconditional.
The difficulty popup's locked state (when a stage is in progress) uses the
same ConfirmDialog class with a dismissible info layout rather than the
previous raw AcceptDialog, keeping the visual language consistent.
Undo button tutorial¶
Stage 1's invasive tutorial script gained a new step before the free-play
handoff: TUT_S1_UNDO, a message step anchored to undo_btn with the
text "If you make a mistake, you can undo on easy difficulty." The step
teaches the player that the button exists before they need it.
The undo button rect is resolved by TutorialRefResolver._undo_btn_rect(),
which calls hud.undo_button_rect() to get the button's global bounding
box. The coach overlay's _reposition_skip_pill() was extended to shift the
"Skip tutorial" pill to the lower-right when its default position overlaps
the spotlight target, so the undo tutorial step's spotlight does not collide
with the skip control.
The step only fires on Easy difficulty. On Normal and Hard the tutorial script skips it entirely, and the undo button row is hidden.
Solver and stage validation¶
The Python solver's json_io.py gained a validation rule banning drone
spawns on cells [14,0] and [15,0], which are reserved for the undo
button's HUD footprint. Twenty-four stage violations across Chapters 1-4
were fixed. The extract-zone pursuit-drone detection was changed from a
hardcoded _PURSUIT_DRONES frozenset to a runtime lookup against
drones.json's pursues flag, loaded via _load_drone_info() with lazy
initialization so synthetic test stages without a project_root do not
trigger spurious errors.
The ch04/s08 detonator-air extract-zone false positive was also fixed in this pass.
Minor¶
- Skip tutorial pill widened (196px min width, 56px height) to fully cover the expanded undo row.
- Field repair button repositioned to avoid overlap with the undo button collapse strip.
set_frozen()added tounit.gd,drone.gd, andkatyusha.gdfor coordinated animation freezing.- Water tile animation freeze via
_set_water_animations_paused()toggles the "ripple" animation on AnimatedSprite2D children. - Greyscale overlay replaced an earlier shader approach with a plain
ColorRect+ alpha tween, sinceColorRecthas noTEXTUREuniform for shader sampling. - Drone undo restoration bug fixed:
_restore_drone_state()was clobbering checkpoint restore during undo becauseis_resumewas true. Added_undo_restore_modeguard. - Drone HP bars now update during undo preview via
_update_hp_bar()calls aftercurrent_hpassignment. - Checkpoint pushed at sector start so the undo button is immediately enabled after the first sector loads.
- All three translation locales (EN, JA, DE) updated with new undo-related strings.