Files
settled-reach/client/scripts/autoloads/hud_groups.gd
T
jpmschweitzerandClaude Fable 5 cffe760d36 feat(client): T-1132 standalone Atlas companion shell + make atlas (D-254 SS3)
New atlas_standalone scene/script: attach-or-spawn boot (one REAL
connect_to_sim attempt at SR_PORT-or-9876 — a separate throwaway TCP
probe was proven by live run to kill a pre-accept-loop server via
broken handshake pipe; never abandon a connected socket), else spawn
--port 0 via new ServerProcess.start_with_pipe + LISTENING:{port}
stdout parse, retry against the resolved port. Reader role wired end
to end: protocol.encode_startup_message optional role param (empty
omits the wire key — byte-identical for all existing callers),
sim_bridge.connection_role suppresses the post-handshake
RequestAllSettings auto-send, hud_groups skips AutoPause/AutoResume
sends for readers (all three would otherwise burn Reader violation
strikes per the T-1130 matrix — endorsed by Oscar).

Generic implant host per D-254 SS3: the shell instantiates ALL
registered implant apps; implant_app_manifest gains
available_in_companion (opt-out, default true) and
implant_registry.instantiate_all a standalone filter param (default
preserves hud.gd behavior byte-identically). Boot order is
instantiate_all THEN open_app (reverse renders a permanently black
window — app_changed fires with no listener; matches hud.gd's order).
Owned-server lifecycle: _exit_tree stops a spawned child, attached
servers survive companion close. Known engine limitation documented:
raw SIGTERM bypasses all Godot notifications and orphans a spawned
server; WM close paths verified clean.

Live-verified: spawn-mode (301 systems rendered from systems.db over
the wire), attach-mode, two simultaneous readers, clean shutdown with
zero orphan processes. 14 new gdUnit tests (port/LISTENING parsing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:09:09 +02:00

189 lines
6.7 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.
## D-254 §2: skipped for Reader connections (atlas_standalone.gd's companion
## app) — this travels through the same Vec<PlayerInput> pipeline as
## MoveNorth/Interact, which the Reader permission matrix marks "no". A
## Reader has no character and no pausable gameplay session of its own (the
## standalone scene calls HudGroups.open_app() as its normal boot step, not a
## player occluding their own gameplay), so there is nothing meaningful to
## pause — sending it anyway would just be dropped as a role violation on
## every single companion boot (confirmed via a live run against a real
## server: "Reader connection ... sent 1 disallowed PlayerInput(s) — dropped
## (strike 1/3)" fired from this exact call site the first time the companion
## opened the Atlas fullscreen).
func _on_gameplay_occluded_auto_pause(occluded: bool) -> void:
if SimBridge.connection_role == "Reader":
return
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