Skip to content

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 to unit.gd, drone.gd, and katyusha.gd for 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, since ColorRect has no TEXTURE uniform for shader sampling.
  • Drone undo restoration bug fixed: _restore_drone_state() was clobbering checkpoint restore during undo because is_resume was true. Added _undo_restore_mode guard.
  • Drone HP bars now update during undo preview via _update_hp_bar() calls after current_hp assignment.
  • 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.