Toast notifications for game events (#57) #130

Merged
liamjd merged 8 commits from 57-toast-notifications into main 2026-09-27 15:52:18 +01:00
Owner

Adds toast notifications in the bottom-right corner, raised from anywhere through GameState.toast_requested. Clicking a toast that refers to a building, pawn or map cell centres the camera on it.

Closes #57

What's in it

  • ToastMessage: text, plus an optional title and icon, a timeout (5s by default, <= 0 keeps it until closed), and at most one target, either an Entity or a cell. focus_point(map) works out the target's world position from the record itself, so the click still works after the building or pawn has left its registry.
  • Toast: one message on screen. It counts down in real time, so pausing the game doesn't freeze it. Dismissing fades and slides the panel off to the right, shrinks its slot so the toasts above slide down, then frees the node. Clicking the body sends camera_focus_requested; the X only closes it.
  • ToastStack: the column. It listens to toast_requested, puts new toasts on top so the oldest is at the bottom, and passes camera_focus_requested up to game.gd. Its empty space lets clicks through to the map.
  • ToastNotifier: turns gameplay signals into toasts: construction complete (PLANNED or UNDER_CONSTRUCTION → BUILT), demolition complete (removed while marked_for_demolition), and task blocked.
  • CameraRig.focus_on(world): snaps the camera to a point, height included, kept inside the map, and ignores a point that isn't finite.
  • game.gd / game.tscn: the notifier is a child of Game, and the stack sits under UIHUDLayer. game.gd connects the stack's signal to camera_rig.focus_on(message.focus_point(map)).

The decisions (click anywhere on the toast, one target per toast, snap rather than pan, real-time countdown, which three events) are in the spec comment on #57.

Verification

  • GUT: 33 scripts, 744 passing, 1 pending (the existing #85 placeholder). New tests: test_toast_message.gd, test_toast.gd, test_toast_stack.gd, test_toast_notifier.gd, test_camera_focus.gd. They were written before the implementation.
  • Screenshots from a running window: tools/screenshot_toast_exit.gd captures a dismissal in four frames. It shows the column in the right order, the fade and slide, the slot shrinking with the toast above sliding down, and the node freed at the end.
  • Checked by hand in the game:
    • Clicking a toast over a building in demolish mode doesn't act on the building.
    • Clicking a construction-complete toast centres the camera on the building.
    • The X closes the toast without moving the camera.

Not in this PR

  • Highlighting the target when the camera jumps to it (out of scope in the issue).
  • Pausing the countdown on hover.
  • A cap on visible toasts: task_blocked could fill the column if many tasks block at once.
  • Why a task is blocked: this toast only says that it is; the reason is #119.
  • Styling: the panel uses Godot's default theme; #21 covers an interim theme.

🤖 Generated with Claude Code

Adds toast notifications in the bottom-right corner, raised from anywhere through `GameState.toast_requested`. Clicking a toast that refers to a building, pawn or map cell centres the camera on it. Closes #57 ## What's in it - **`ToastMessage`**: text, plus an optional title and icon, a timeout (5s by default, `<= 0` keeps it until closed), and at most one target, either an `Entity` or a cell. `focus_point(map)` works out the target's world position from the record itself, so the click still works after the building or pawn has left its registry. - **`Toast`**: one message on screen. It counts down in real time, so pausing the game doesn't freeze it. Dismissing fades and slides the panel off to the right, shrinks its slot so the toasts above slide down, then frees the node. Clicking the body sends `camera_focus_requested`; the X only closes it. - **`ToastStack`**: the column. It listens to `toast_requested`, puts new toasts on top so the oldest is at the bottom, and passes `camera_focus_requested` up to `game.gd`. Its empty space lets clicks through to the map. - **`ToastNotifier`**: turns gameplay signals into toasts: construction complete (PLANNED or UNDER_CONSTRUCTION → BUILT), demolition complete (removed while `marked_for_demolition`), and task blocked. - **`CameraRig.focus_on(world)`**: snaps the camera to a point, height included, kept inside the map, and ignores a point that isn't finite. - **`game.gd` / `game.tscn`**: the notifier is a child of `Game`, and the stack sits under `UIHUDLayer`. `game.gd` connects the stack's signal to `camera_rig.focus_on(message.focus_point(map))`. The decisions (click anywhere on the toast, one target per toast, snap rather than pan, real-time countdown, which three events) are in the [spec comment on #57](https://git.liamjd.net/liamjd/UntitledColonyBuilder/issues/57#issuecomment-871). ## Verification - **GUT:** 33 scripts, 744 passing, 1 pending (the existing #85 placeholder). New tests: `test_toast_message.gd`, `test_toast.gd`, `test_toast_stack.gd`, `test_toast_notifier.gd`, `test_camera_focus.gd`. They were written before the implementation. - **Screenshots from a running window:** `tools/screenshot_toast_exit.gd` captures a dismissal in four frames. It shows the column in the right order, the fade and slide, the slot shrinking with the toast above sliding down, and the node freed at the end. - **Checked by hand in the game:** - Clicking a toast over a building in demolish mode doesn't act on the building. - Clicking a construction-complete toast centres the camera on the building. - The X closes the toast without moving the camera. ## Not in this PR - **Highlighting the target** when the camera jumps to it (out of scope in the issue). - **Pausing the countdown on hover.** - **A cap on visible toasts:** `task_blocked` could fill the column if many tasks block at once. - **Why a task is blocked:** this toast only says that it is; the reason is #119. - **Styling:** the panel uses Godot's default theme; #21 covers an interim theme. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Five test scripts, written as the spec to code against:

- test_toast_message.gd: the message data, one target at a time, and
  focus_point() resolving from the record rather than the registry
- test_toast.gd: rendering, the real-time countdown, dismissing exactly
  once, the exit animation keeping the node alive until it frees itself,
  and click-to-focus
- test_toast_stack.gd: newest on top, oldest-first toasts(), listening to
  GameState.toast_requested, relaying focus_requested, and not swallowing
  clicks on the map
- test_toast_notifier.gd: construction complete, demolition complete and
  task blocked, as static builders plus their signal wiring
- test_camera_focus.gd: CameraRig.focus_on() centring the target on screen,
  raised terrain included, and clamping to the map

The four toast scripts fail to parse until the classes exist, and GUT
leaves them out of the run summary rather than counting them as failures.
They have been checked against empty stubs to confirm they parse once the
types are declared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Toast's signal was renamed from focus_requested to avoid reading as
Control keyboard focus. The stack relays it under the same name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The exit tween now fades and slides together, collapses the slot, and
  only then frees the node. set_parallel() had been called after the first
  two tweeners, so they ran in sequence and queue_free fired as the slide
  began: the slide was never seen and the stack snapped instead of sliding.
  Fade tweens modulate:a rather than modulate, so the panel no longer
  darkens towards black, and the slide is the panel's width, not 100 px.
  Shipped durations are 0.25s fade and 0.2s collapse.
- The toast root is back to MOUSE_FILTER_STOP, so a click on a toast ends
  there instead of carrying on to the placement and demolish controllers.
- The pointing-hand cursor only shows on toasts that have a target.
- for_status_change() lists the qualifying transitions rather than ruling
  others out, so an unforeseen transition stays quiet.
- for_blocked() falls back to "A task is blocked." for an unlabelled task.
- Removed the unused for_toast_request() and a debug print.
- ToastMessage.icon is a Texture2D, matching TextureRect.texture.
- The title row aligns to the end, so the X stays right when there is no
  title, and the stack sits 16 px in from the screen corner.

tools/screenshot_toast_exit.gd captures the exit in four frames, headed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
liamjd merged commit d25cc09306 into main 2026-09-27 15:52:18 +01:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
liamjd/UntitledColonyBuilder!130
No description provided.