Fix mouse look swallowing by the HUD
Move guard look handling to the _input stage, recapture the cursor on window-focus return and when clicking back into gameplay, and make HUD controls mouse-transparent. Add regression tests for yaw/pitch and HUD mouse filters. Verified under Xvfb with real relative mouse motion. Headless 237/237, Python 94/94, movement smoke 60 fps.
This commit is contained in:
1 parent
f4a53e2d4f
commit
4d43108353
10 files changed
+97
-14
No files matched your search
@@ -4,6 +4,17 @@ All notable user-facing changes to Gig Line. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
|
||||||
Semantic Versioning.
|
Semantic Versioning.
|
||||||
|
|
||||||
|
## [0.7.3] — 2026-11-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Mouse look now reads relative motion at the `_input` stage, so a full-screen
|
||||||
|
HUD or focused control cannot swallow it while the cursor is captured.
|
||||||
|
- Mouse capture is restored after window/compositor focus returns, and clicking
|
||||||
|
back into a gameplay window recaptures it instead of opening the radial menu.
|
||||||
|
- The HUD is mouse-transparent; its presentation controls no longer block
|
||||||
|
gameplay mouse input.
|
||||||
|
|
||||||
## [0.7.2] — 2026-11-01
|
## [0.7.2] — 2026-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
|
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
|
||||||
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
||||||
|
|
||||||
|
- **Mouse look (v0.7.3):** guard look is handled in `GuardController._input()` (before GUI), not `_unhandled_input`; focus/click-back recaptures the cursor and the HUD is made mouse-transparent recursively. Regression tests cover yaw/pitch and HUD filters.
|
||||||
- **Tutorial foundation (0.8.0 work):** `docs/tutorial.md` is the eight-beat design; `src/game/tutorial.gd` is a pure `RefCounted` state machine with skip/restart and tests. It is not yet wired into `GameManager`; when wiring it, add an explicit QA bypass because captures/smokes would otherwise see prompts.
|
- **Tutorial foundation (0.8.0 work):** `docs/tutorial.md` is the eight-beat design; `src/game/tutorial.gd` is a pure `RefCounted` state machine with skip/restart and tests. It is not yet wired into `GameManager`; when wiring it, add an explicit QA bypass because captures/smokes would otherwise see prompts.
|
||||||
- **Documentation ownership (2026-11-01):** `docs/architecture.md` §12 defines the source-of-truth map and consolidation rules. Obsolete/historical plan documents are removed rather than kept; current work belongs in `TODO.md`. README/development-status/Blender AO/Forward+ stale claims were corrected. The v1.0.0 gap register and 0.8/0.9/0.9.5/1.0 roadmap live in `TODO.md`.
|
- **Documentation ownership (2026-11-01):** `docs/architecture.md` §12 defines the source-of-truth map and consolidation rules. Obsolete/historical plan documents are removed rather than kept; current work belongs in `TODO.md`. README/development-status/Blender AO/Forward+ stale claims were corrected. The v1.0.0 gap register and 0.8/0.9/0.9.5/1.0 roadmap live in `TODO.md`.
|
||||||
- **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006.
|
- **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006.
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [AD
|
|||||||
|
|
||||||
## Versioning and Delivery
|
## Versioning and Delivery
|
||||||
|
|
||||||
`config/version` in `project.godot` is the single version source (currently `0.7.2`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
|
`config/version` in `project.godot` is the single version source (currently `0.7.3`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
|
||||||
|
|
||||||
## Design Direction
|
## Design Direction
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ precision walk, the Joke Shop alley, the TV Studio arrival, and the prop lineup.
|
|||||||
> Downloads are public (no sign-in required) since the host's instance-wide
|
> Downloads are public (no sign-in required) since the host's instance-wide
|
||||||
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
||||||
>
|
>
|
||||||
> **Latest: v0.7.2** — a cost-aware OpenRouter screenshot judge, an optional
|
> **Latest: v0.7.3** — mouse-look input reliability fixes, plus the v0.7.2
|
||||||
> Forward+ Desktop HQ build with volumetric fog/SDFGI/flashlight, distinct Joke
|
> cost-aware OpenRouter judge, optional Forward+ Desktop HQ build, distinct
|
||||||
> Shop and TV Studio geometry, and fitted NPC gear.
|
> venues and fitted NPC gear.
|
||||||
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||||
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||||
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ This document follows the structure of the arc42 architecture documentation temp
|
|||||||
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
|
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
|
||||||
- **PATCH** — bug fixes and small polish that do not change behavior.
|
- **PATCH** — bug fixes and small polish that do not change behavior.
|
||||||
|
|
||||||
The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.2`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
|
The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.3`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
|
||||||
|
|
||||||
## 3. Context and Scope
|
## 3. Context and Scope
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Implemented so far:
|
|||||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||||
- Headless unit tests: **235 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
|
- Headless unit tests: **237 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
|
||||||
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||||
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
||||||
@@ -40,7 +40,7 @@ Implemented so far:
|
|||||||
|
|
||||||
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing.
|
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing.
|
||||||
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
|
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
|
||||||
- Input/reliability fixes: selecting a venue now releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status.
|
- Input/reliability fixes: selecting a venue releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status. Mouse look now reads at `_input`, restores capture on window focus/click-back, and the HUD is mouse-transparent.
|
||||||
- Tutorial foundation (0.8.0 work): the eight-beat first-shift design and a deterministic, skippable/restartable `Tutorial` state machine exist; the in-game prompt/visitor wiring is the next step.
|
- Tutorial foundation (0.8.0 work): the eight-beat first-shift design and a deterministic, skippable/restartable `Tutorial` state machine exist; the in-game prompt/visitor wiring is the next step.
|
||||||
- Vision judge (v0.7.2): discovers the cheapest current OpenRouter image+JSON model, reports the model/fallbacks used, and clears the full screen-design judge (13/13 match, 0 diff).
|
- Vision judge (v0.7.2): discovers the cheapest current OpenRouter image+JSON model, reports the model/fallbacks used, and clears the full screen-design judge (13/13 match, 0 diff).
|
||||||
- Desktop HQ (v0.7.1): optional Forward+ Windows/Linux zip with volumetric fog, SDFGI and a volumetric flashlight; the default Compatibility/Web builds and headless tests gate the effect off. A/B captures are generated locally under `docs/screens/realism/` (gitignored).
|
- Desktop HQ (v0.7.1): optional Forward+ Windows/Linux zip with volumetric fog, SDFGI and a volumetric flashlight; the default Compatibility/Web builds and headless tests gate the effect off. A/B captures are generated locally under `docs/screens/realism/` (gitignored).
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ config_version=5
|
|||||||
|
|
||||||
config/name="Gig Line"
|
config/name="Gig Line"
|
||||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||||
config/version="0.7.2"
|
config/version="0.7.3"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
run/main_scene="res://scenes/main.tscn"
|
||||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||||
|
|
||||||
|
|||||||
@@ -115,14 +115,36 @@ static func _ensure_gamepad_bindings() -> void:
|
|||||||
press.pressed = false
|
press.pressed = false
|
||||||
InputMap.action_add_event(action, press)
|
InputMap.action_add_event(action, press)
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
## Mouse look is read at the `_input` stage, before GUI/Control handling. A
|
||||||
|
## full-screen HUD or a focused control must never be able to swallow relative
|
||||||
|
## mouse motion while the game is in captured mode.
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
if input_locked:
|
if input_locked:
|
||||||
return
|
return
|
||||||
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
if event is InputEventMouseMotion:
|
||||||
rotate_y(-event.relative.x * look_sensitivity)
|
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
||||||
var pitch_sign := 1.0 if invert_y else -1.0
|
return
|
||||||
head.rotate_x(pitch_sign * event.relative.y * look_sensitivity)
|
_apply_mouse_look(event.relative)
|
||||||
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
get_viewport().set_input_as_handled()
|
||||||
|
elif event is InputEventMouseButton and event.pressed and Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
||||||
|
# Recover capture if the window lost it (alt-tab, compositor focus,
|
||||||
|
# clicking back into the game) without opening the radial menu.
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
## Pure look update, kept separate so the rotation math is testable.
|
||||||
|
func _apply_mouse_look(relative: Vector2) -> void:
|
||||||
|
rotate_y(-relative.x * look_sensitivity)
|
||||||
|
var pitch_sign := 1.0 if invert_y else -1.0
|
||||||
|
head.rotate_x(pitch_sign * relative.y * look_sensitivity)
|
||||||
|
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
||||||
|
|
||||||
|
func _notification(what: int) -> void:
|
||||||
|
# A window/compositor focus change can silently release capture. Restore it
|
||||||
|
# when gameplay owns the input layer; menus set input_locked instead.
|
||||||
|
if what == NOTIFICATION_APPLICATION_FOCUS_IN or what == NOTIFICATION_WM_WINDOW_FOCUS_IN:
|
||||||
|
if is_inside_tree() and not input_locked:
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
if focus_suppress_s > 0.0:
|
if focus_suppress_s > 0.0:
|
||||||
|
|||||||
@@ -356,6 +356,17 @@ func _init() -> void:
|
|||||||
debug_label.add_theme_font_size_override("font_size", 11)
|
debug_label.add_theme_font_size_override("font_size", 11)
|
||||||
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
add_child(debug_label)
|
add_child(debug_label)
|
||||||
|
_ignore_mouse(self)
|
||||||
|
|
||||||
|
## HUD controls are presentation-only. Leaving the full-screen containers on the
|
||||||
|
## default MOUSE_FILTER_STOP can swallow mouse motion/buttons before the guard's
|
||||||
|
## captured-look input sees them. Make every non-gameplay HUD control inert to
|
||||||
|
## the mouse; modal menus are separate CanvasLayers and keep their own filters.
|
||||||
|
func _ignore_mouse(node: Node) -> void:
|
||||||
|
for child in node.get_children():
|
||||||
|
if child is Control:
|
||||||
|
(child as Control).mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_ignore_mouse(child)
|
||||||
|
|
||||||
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
|
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
|
||||||
top_label.text = scenario.display_name if scenario else "Gig Line"
|
top_label.text = scenario.display_name if scenario else "Gig Line"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Regression tests for the first-person mouse-look path and non-blocking HUD.
|
||||||
|
|
||||||
|
const GameManager := preload("res://src/game/game_manager.gd")
|
||||||
|
const GuardController := preload("res://src/game/guard_controller.gd")
|
||||||
|
const Hud := preload("res://src/game/ui/hud.gd")
|
||||||
|
|
||||||
|
func test_mouse_look_rotates_yaw_and_pitch() -> bool:
|
||||||
|
var root := Node3D.new()
|
||||||
|
var gm: Node = GameManager.new()
|
||||||
|
var guard: GuardController = gm.call("_build_guard")
|
||||||
|
guard._ready()
|
||||||
|
var yaw0 := guard.rotation.y
|
||||||
|
var pitch0 := guard.head.rotation.x
|
||||||
|
guard._apply_mouse_look(Vector2(100, 50))
|
||||||
|
var a := TestUtils.check(guard.rotation.y < yaw0 - 0.1, "horizontal motion yaws the guard")
|
||||||
|
var b := TestUtils.check(guard.head.rotation.x < pitch0 - 0.1, "vertical motion pitches the head")
|
||||||
|
guard._apply_mouse_look(Vector2(0, 100000))
|
||||||
|
var c := TestUtils.check(absf(rad_to_deg(guard.head.rotation.x)) <= 80.01,
|
||||||
|
"pitch stays clamped")
|
||||||
|
guard.free()
|
||||||
|
gm.free()
|
||||||
|
root.free()
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_hud_does_not_capture_mouse_input() -> bool:
|
||||||
|
var hud: Hud = Hud.new()
|
||||||
|
var root := hud.get_child(0) as Control
|
||||||
|
var a := TestUtils.check(root != null and root.mouse_filter == Control.MOUSE_FILTER_IGNORE,
|
||||||
|
"full-screen HUD root ignores mouse input")
|
||||||
|
var labels := hud.find_children("*", "Label", true, false)
|
||||||
|
var blocking := 0
|
||||||
|
for node in labels:
|
||||||
|
if (node as Control).mouse_filter != Control.MOUSE_FILTER_IGNORE:
|
||||||
|
blocking += 1
|
||||||
|
var b := TestUtils.check(blocking == 0, "HUD labels do not swallow mouse motion")
|
||||||
|
hud.free()
|
||||||
|
return a and b
|
||||||
Reference in new issue
Block a user