D-226 layer 1. World-advancing phases gated on pause: Movement/Storyteller/Knowledge/TickAdvance set-gated via sim_not_paused; Simulation + Economy gated per-system at their registration sites — collect_sound_events and serve_econ_state_query stay unconditioned (transient-buffer clear + paused-allowed query; set-gating Simulation leaked a stale tick-7 footstep into frozen snapshots — caught by golden_suite, fixed without touching the fixture; regression test encodes the bug shape). New PlayerAction::AutoPause/AutoResume + AutoPauseState resource implement Option A reconciliation: auto-resume only fires if auto-pause caused the pause; manual pause and Half rate survive implant open/close. PauseParams SystemParam bundle keeps process_player_input under the 16-param ceiling (BookmarkInputParams precedent). Client: HudGroups.gameplay_occluded now sends AutoPause/AutoResume via send_named_action; 5 gdUnit tests + 7 Rust tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
176 lines
5.9 KiB
GDScript
176 lines
5.9 KiB
GDScript
extends Node
|
|
## HUD layer manager (D-170).
|
|
##
|
|
## Controls z-ordering and layout mode for HUD elements. Nothing gets hidden —
|
|
## fullscreen apps render on top of gameplay, insert-mode apps share the screen.
|
|
##
|
|
## Modes:
|
|
## MODE_GAMEPLAY — default z-level, normal layout
|
|
## MODE_INSERT — small overlay alongside gameplay (e.g. minimap-sized star map)
|
|
## MODE_FULLSCREEN — takes over the screen, gameplay renders behind
|
|
##
|
|
## Groups use hierarchical paths:
|
|
## "gameplay" — HUD status, minimap, prompts, stance
|
|
## "implant/map" — unified atlas (reach map → system → planet → regional, D-191)
|
|
## "implant/wiki/gttr" — Drifter's Guide reader
|
|
## "implant/journal" — knowledge journal
|
|
## "implant/economics" — economics monitor (D-181, #824)
|
|
##
|
|
## Usage:
|
|
## HudGroups.register(self, "implant/map")
|
|
## HudGroups.open_app("implant/map") # fullscreen by default
|
|
## HudGroups.open_app("implant/map", HudGroups.MODE_INSERT)
|
|
## HudGroups.close_app()
|
|
## HudGroups.toggle_app("implant/map")
|
|
|
|
## Emitted when an app opens, closes, or changes mode.
|
|
## mode is a HudGroups.Mode enum value.
|
|
signal app_changed(app_path: String, mode: int)
|
|
|
|
## Emitted when gameplay rendering should pause or resume.
|
|
## Fullscreen implant apps occlude gameplay — expensive renderers (world,
|
|
## fog, entities) should skip draw calls while this is true.
|
|
signal gameplay_occluded(occluded: bool)
|
|
|
|
enum Mode { GAMEPLAY, INSERT, FULLSCREEN }
|
|
|
|
## Z-index values for each mode. Gameplay is the base layer.
|
|
const Z_GAMEPLAY: int = 0
|
|
const Z_INSERT: int = 10
|
|
const Z_FULLSCREEN: int = 20
|
|
const Z_MODAL: int = 30
|
|
|
|
var _groups: Dictionary = {} # group_path -> Array[CanvasItem]
|
|
var _active_app: String = "" # currently focused implant app ("" = none)
|
|
var _active_mode: Mode = Mode.GAMEPLAY
|
|
|
|
|
|
func _ready() -> void:
|
|
# T-970 (D-226 layer 1): auto-pause/auto-resume the sim when a fullscreen
|
|
# implant app occludes gameplay. AutoPause/AutoResume are distinct
|
|
# PlayerAction variants from the manual Pause/Unpause (Space bar, D-088) —
|
|
# the server tracks whether ITS OWN auto-pause caused the current pause
|
|
# (AutoPauseState) so a pre-existing manual pause or Half rate survives
|
|
# implant open/close untouched (see server/src/simulation/input.rs and
|
|
# server/src/simulation/time.rs).
|
|
gameplay_occluded.connect(_on_gameplay_occluded_auto_pause)
|
|
|
|
|
|
## Sends AutoPause/AutoResume via SimBridge's outbound queue. Uses
|
|
## send_named_action (protocol-level, not bound to an InputMapper.Action
|
|
## keybind) — the same mechanism as RequestBookmarkCatalog — since occlusion
|
|
## is a UI-state transition, not a physical input.
|
|
func _on_gameplay_occluded_auto_pause(occluded: bool) -> void:
|
|
if occluded:
|
|
SimBridge.send_named_action("AutoPause")
|
|
else:
|
|
SimBridge.send_named_action("AutoResume")
|
|
|
|
|
|
func register(node: CanvasItem, group: String) -> void:
|
|
if not _groups.has(group):
|
|
_groups[group] = []
|
|
if node not in _groups[group]:
|
|
_groups[group].append(node)
|
|
# Apply current z-level
|
|
_apply_z(node, group)
|
|
|
|
|
|
func unregister(node: CanvasItem, group: String) -> void:
|
|
if _groups.has(group):
|
|
_groups[group].erase(node)
|
|
|
|
|
|
## Open an implant app in fullscreen (default) or insert mode.
|
|
func open_app(app: String, mode: Mode = Mode.FULLSCREEN) -> void:
|
|
# Capture occlusion state BEFORE mutating — used for change detection below
|
|
var was_occluded := _active_mode == Mode.FULLSCREEN and not _active_app.is_empty()
|
|
|
|
# Close previous app if different
|
|
if not _active_app.is_empty() and _active_app != app:
|
|
_set_group_z(_active_app, Z_GAMEPLAY)
|
|
app_changed.emit(_active_app, Mode.GAMEPLAY)
|
|
|
|
_active_app = app
|
|
_active_mode = mode
|
|
|
|
# Set z-levels based on mode
|
|
if mode == Mode.FULLSCREEN:
|
|
_set_group_z("gameplay", Z_GAMEPLAY)
|
|
_set_group_z(app, Z_FULLSCREEN)
|
|
elif mode == Mode.INSERT:
|
|
_set_group_z("gameplay", Z_GAMEPLAY)
|
|
_set_group_z(app, Z_INSERT)
|
|
|
|
app_changed.emit(app, mode)
|
|
|
|
# Notify renderers about occlusion state change
|
|
var now_occluded := mode == Mode.FULLSCREEN
|
|
if now_occluded != was_occluded:
|
|
gameplay_occluded.emit(now_occluded)
|
|
|
|
|
|
## Close the current app. Returns everything to gameplay z-level.
|
|
func close_app() -> void:
|
|
var was_fullscreen := _active_mode == Mode.FULLSCREEN and not _active_app.is_empty()
|
|
if not _active_app.is_empty():
|
|
_set_group_z(_active_app, Z_GAMEPLAY)
|
|
app_changed.emit(_active_app, Mode.GAMEPLAY)
|
|
_active_app = ""
|
|
_active_mode = Mode.GAMEPLAY
|
|
_set_group_z("gameplay", Z_GAMEPLAY)
|
|
if was_fullscreen:
|
|
gameplay_occluded.emit(false)
|
|
|
|
|
|
## Toggle an app. If open, close it. If closed or different app, open it.
|
|
func toggle_app(app: String, mode: Mode = Mode.FULLSCREEN) -> void:
|
|
if _active_app == app:
|
|
close_app()
|
|
else:
|
|
open_app(app, mode)
|
|
|
|
|
|
## Check if a specific app is currently open.
|
|
func is_app_active(app: String) -> bool:
|
|
return _active_app == app
|
|
|
|
|
|
## Check if any implant app is open.
|
|
func is_implant_active() -> bool:
|
|
return not _active_app.is_empty()
|
|
|
|
|
|
## Get the currently active app path, or "" if none.
|
|
func get_active_app() -> String:
|
|
return _active_app
|
|
|
|
|
|
## Get the current mode of the active app.
|
|
func get_active_mode() -> Mode:
|
|
return _active_mode
|
|
|
|
|
|
func _apply_z(node: CanvasItem, group: String) -> void:
|
|
if group == "gameplay":
|
|
node.z_index = Z_GAMEPLAY
|
|
elif group.begins_with("implant/"):
|
|
if group == _active_app:
|
|
node.z_index = Z_FULLSCREEN if _active_mode == Mode.FULLSCREEN else Z_INSERT
|
|
else:
|
|
node.z_index = Z_GAMEPLAY
|
|
elif group == "modal":
|
|
node.z_index = Z_MODAL
|
|
|
|
|
|
func _set_group_z(group: String, z: int) -> void:
|
|
if not _groups.has(group):
|
|
return
|
|
# Prune freed nodes first: a typed loop variable (`for node: CanvasItem`)
|
|
# errors on assignment of a freed instance BEFORE any is_instance_valid
|
|
# guard can run. Nodes freed without unregister() must not crash the manager.
|
|
var alive: Array = _groups[group].filter(func(n): return is_instance_valid(n))
|
|
_groups[group] = alive
|
|
for node: CanvasItem in alive:
|
|
node.z_index = z
|