Game speed control: pause with two owners, and speed presets #114

Merged
liamjd merged 4 commits from 113-game-speed-control into main 2026-09-18 17:17:28 +01:00
Owner

Closes #113.

GameClock gains a player-facing control surface: pause with two independent owners, four speed presets, and signals a panel can render from. Keyboard and on-screen controls both drive the same API.

Pause has two owners and one answer

Before this, _on_main_menu_changed() assigned paused directly. A player who paused deliberately and then opened and closed the menu was silently resumed — the menu's false overwrote the player's true.

paused is now derived and not assignable: player_paused or menu_paused. The fix is not "assign more carefully"; it is that pause has two inputs and one answer, so a third owner later (a threat alert, a modal) costs one more input rather than another ordering bug.

paused_changed reports the derived value, not the input that moved. Two owners holding the simulation is still one stop, so opening the menu over an already-paused game emits nothing, and the player releasing their pause while the menu is still open emits nothing either. The first draft emitted from each setter and got both of those backwards — the second case announced false while the game was still frozen.

Consequently the nine clock.paused = ... writes in test_game_clock.gd move to clock.player_paused, and that file's API note now points at the new spec for how the two combine.

Speed

SPEED_PRESETS = [1.0, 2.0, 5.0, 10.0], with set_speed_preset(index) as the single place speed_index, speed and speed_changed move together. faster() and slower() are steps through it, so the clamp and the did-it-actually-change guard cannot drift apart.

Presets clamp rather than wrap: dropping a player from maximum speed to 1x on one extra press reads as the game stalling. 1x is the floor — pause is a separate control, not 0x. speed remains a free float off the preset list, so 0.5x slow motion and any future modifier do not have to become presets.

The number row selects by position, not by value: game_speed_1 is the first preset rather than 1x, so a balance pass that changes the values does not leave a key naming a speed that no longer exists. Those four actions were previously registered in project.godot and handled nowhere.

Speed never changes what a tick is. Ten seconds at 1x and one second at 10x produce the same tick sequence. That is the property the deterministic threat timeline rests on, and the reason ticks_per_second is frozen mid-run while speed is not.

Decision: ticks-per-frame is left uncapped

advance() is unchanged and still drains every tick it owes inside one frame. Capping would smooth high speeds but let the clock fall behind wall time under load, and the honest version is easier to reason about while there is nothing expensive on the tick. Worth revisiting once #75's work loop runs per tick — that is the path that will hitch first at 10x.

Verification

513 passing, 0 failing, 1 pending across 514 tests. The pending is the pre-existing #85 marker in test_task_tree.gd.

test/unit/test_game_clock_controls.gd adds 32 tests: pause ownership and precedence, toggle_pause() touching only the player's half, preset stepping and clamping, set_speed_preset() direct selection, signal firing on the derived value only, and what reset() does to both. test_game_clock.gd continues to own the accumulator, tick counting and re-entrancy.

Manually verified headed: keyboard and button controls, the label, and the pause glyph.

Two bugs found in the headed pass that no unit test could see

Space was ambiguous after clicking a speed button. game_pause is bound to Space, Godot's default ui_accept also includes Space, and the buttons defaulted to FOCUS_ALL. So clicking + gave it focus, and the next Space both resumed the game and re-pressed the focused button — one extra speed step, or two if the key repeated, since BaseButton's ui_accept handling does not filter echo the way is_action_pressed() does. Fixed with focus_mode = 0 on the three HUD buttons: they are pointer targets, and the keyboard equivalents already exist as real actions.

The speed label was written from half the state. Two handlers each wrote it from their own signal, so pressing speed-up while paused replaced "Paused" with "3.0x" on a frozen simulation — the same two-inputs-one-answer problem as paused itself, one layer up in the view. Now one _render_speed() reads both values, connected to both signals via unbind(1) and called once in _ready() for the startup paint, which was otherwise showing the scene's hardcoded "1.0x" over a game that starts paused behind the menu.

Not done here

  • Key bindings and scene wiring are not unit tested; input actions and .tscn wiring are verified headed, per CLAUDE.md.
  • test_game_clock_controls.gd's after_each restores GameState.main_menu = false rather than the captured value. GameState and GameClock are both real autoloads, so flipping the menu here pauses the global clock, and pawn_view.gd reads GameClock.delta_for() every frame — restoring the captured true left ten pawn-movement tests failing with "the pawn didn't move". The hazard predates this branch and is worth its own issue: the pawn tests depend on global clock state and never say so, so the symptom lands nowhere near the cause.

🤖 Generated with Claude Code

Closes #113. `GameClock` gains a player-facing control surface: pause with two independent owners, four speed presets, and signals a panel can render from. Keyboard and on-screen controls both drive the same API. ## Pause has two owners and one answer Before this, `_on_main_menu_changed()` assigned `paused` directly. A player who paused deliberately and then opened and closed the menu was silently resumed — the menu's `false` overwrote the player's `true`. `paused` is now **derived and not assignable**: `player_paused or menu_paused`. The fix is not "assign more carefully"; it is that pause has two inputs and one answer, so a third owner later (a threat alert, a modal) costs one more input rather than another ordering bug. `paused_changed` reports **the derived value**, not the input that moved. Two owners holding the simulation is still one stop, so opening the menu over an already-paused game emits nothing, and the player releasing their pause while the menu is still open emits nothing either. The first draft emitted from each setter and got both of those backwards — the second case announced `false` while the game was still frozen. Consequently the nine `clock.paused = ...` writes in `test_game_clock.gd` move to `clock.player_paused`, and that file's API note now points at the new spec for how the two combine. ## Speed `SPEED_PRESETS = [1.0, 2.0, 5.0, 10.0]`, with `set_speed_preset(index)` as the single place `speed_index`, `speed` and `speed_changed` move together. `faster()` and `slower()` are steps through it, so the clamp and the did-it-actually-change guard cannot drift apart. Presets clamp rather than wrap: dropping a player from maximum speed to 1x on one extra press reads as the game stalling. 1x is the floor — pause is a separate control, not 0x. `speed` remains a free float off the preset list, so 0.5x slow motion and any future modifier do not have to become presets. The number row selects **by position**, not by value: `game_speed_1` is the first preset rather than 1x, so a balance pass that changes the values does not leave a key naming a speed that no longer exists. Those four actions were previously registered in `project.godot` and handled nowhere. **Speed never changes what a tick is.** Ten seconds at 1x and one second at 10x produce the same tick sequence. That is the property the deterministic threat timeline rests on, and the reason `ticks_per_second` is frozen mid-run while `speed` is not. ## Decision: ticks-per-frame is left uncapped `advance()` is unchanged and still drains every tick it owes inside one frame. Capping would smooth high speeds but let the clock fall behind wall time under load, and the honest version is easier to reason about while there is nothing expensive on the tick. Worth revisiting once #75's work loop runs per tick — that is the path that will hitch first at 10x. ## Verification `513 passing, 0 failing, 1 pending` across 514 tests. The pending is the pre-existing #85 marker in `test_task_tree.gd`. `test/unit/test_game_clock_controls.gd` adds 32 tests: pause ownership and precedence, `toggle_pause()` touching only the player's half, preset stepping and clamping, `set_speed_preset()` direct selection, signal firing on the derived value only, and what `reset()` does to both. `test_game_clock.gd` continues to own the accumulator, tick counting and re-entrancy. Manually verified headed: keyboard and button controls, the label, and the pause glyph. ### Two bugs found in the headed pass that no unit test could see **Space was ambiguous after clicking a speed button.** `game_pause` is bound to Space, Godot's default `ui_accept` also includes Space, and the buttons defaulted to `FOCUS_ALL`. So clicking `+` gave it focus, and the next Space both resumed the game *and* re-pressed the focused button — one extra speed step, or two if the key repeated, since `BaseButton`'s `ui_accept` handling does not filter echo the way `is_action_pressed()` does. Fixed with `focus_mode = 0` on the three HUD buttons: they are pointer targets, and the keyboard equivalents already exist as real actions. **The speed label was written from half the state.** Two handlers each wrote it from their own signal, so pressing speed-up while paused replaced "Paused" with "3.0x" on a frozen simulation — the same two-inputs-one-answer problem as `paused` itself, one layer up in the view. Now one `_render_speed()` reads both values, connected to both signals via `unbind(1)` and called once in `_ready()` for the startup paint, which was otherwise showing the scene's hardcoded "1.0x" over a game that starts paused behind the menu. ## Not done here - Key bindings and scene wiring are not unit tested; input actions and `.tscn` wiring are verified headed, per CLAUDE.md. - `test_game_clock_controls.gd`'s `after_each` restores `GameState.main_menu = false` rather than the captured value. `GameState` and `GameClock` are both real autoloads, so flipping the menu here pauses the **global** clock, and `pawn_view.gd` reads `GameClock.delta_for()` every frame — restoring the captured `true` left ten pawn-movement tests failing with "the pawn didn't move". The hazard predates this branch and is worth its own issue: the pawn tests depend on global clock state and never say so, so the symptom lands nowhere near the cause. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Tests only; the implementation is #113's to write. 33 red, all of them on
missing API rather than a wrong expectation.

test_game_clock_controls.gd covers the surface a player touches: pause
ownership, the speed presets, the signals a panel renders from, and what
reset() does to both. It deliberately does not pin whether ticks-per-frame gets
capped at high speed - #113 leaves that open.

The load-bearing change is that `paused` stops being assignable. It becomes a
derived answer over two independent inputs, `player_paused` and `menu_paused`,
because the menu currently assigns `paused` directly and so silently resumes a
player who had paused deliberately. That is not an ordering bug to be fixed by
assigning more carefully - pause has two owners and one answer, and a third
owner later should cost one more input rather than another bug.

Consequently the nine `clock.paused = ...` writes in test_game_clock.gd move to
`clock.player_paused`, and that file's API note now points here for how the two
combine.

Also pinned: speed never changes the tick sequence. Ten seconds at 1x and one
second at 10x produce the same ticks, which is the property the deterministic
threat timeline rests on and the reason ticks_per_second is frozen mid-run
while speed is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
game_speed_1 through game_speed_4 were registered in project.godot and handled
nowhere - bound keys that did nothing, which looks wired until someone greps.

They now select a preset by position through a new GameClock.set_speed_preset().
Positional rather than by value: game_speed_1 is the first preset, not 1x, so a
balance pass that changes SPEED_PRESETS does not silently leave a key naming a
speed that no longer exists.

set_speed_preset() also becomes the one place speed_index, speed and
speed_changed move together; faster() and slower() are now steps through it
rather than their own copies of the clamp and the did-it-change guard.

game.gd drives the bindings from SPEED_PRESETS rather than four branches, with
InputMap.has_action() guarding a preset that has been added without its action -
is_action_pressed() on an unregistered action is an error, not a false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
liamjd merged commit e074998527 into main 2026-09-18 17:17:28 +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!114
No description provided.