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 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