refactor(ui): HudGroups z-index model + gameplay occlusion (D-170)

Replaced visibility toggling with z-index layer management. Nothing
gets hidden — fullscreen apps render on top, gameplay stays underneath.
Supports future hybrid layouts (insert-mode map alongside gameplay).

Added gameplay_occluded signal: WorldRenderer skips tile/fog/entity
updates when a fullscreen implant app covers it. Prevents wasted
render work behind opaque overlays. Other renderers can connect to
the same signal.

Modes: GAMEPLAY (z=0), INSERT (z=10), FULLSCREEN (z=20), MODAL (z=30).
Star map uses app_changed signal to toggle its own visibility based
on whether its app is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 11:21:31 +02:00
co-authored by Claude Opus 4.6
parent 09e0c522bd
commit 2e01081de7
4 changed files with 125 additions and 75 deletions
+94 -55
View File
@@ -1,83 +1,110 @@
extends Node
## HUD visibility group manager (D-170).
## HUD layer manager (D-170).
##
## Nodes register into hierarchical groups. Groups control show/hide as a unit.
## 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.
##
## Hierarchy:
## "gameplay" — stance, minimap, health, prompts, cursor, inventory
## "implant/map" — star map
## "implant/wiki" — GTTR reader
## "implant/journal" — knowledge journal
## "implant/travel" — travel planner
## "implant/station" — station profile
## "implant/cargo" — cargo manifest
## "modal" — settings, bug report, loading (independent layer)
## "debug" — debug overlay, gauntlet HUD (independent layer)
## 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
##
## Rules:
## - "gameplay" and any "implant/*" are mutually exclusive.
## Opening an implant app hides gameplay.
## - "implant/*" apps are mutually exclusive with each other.
## Opening implant/map hides implant/wiki.
## - "modal" and "debug" are independent — overlay on anything.
## Groups use hierarchical paths:
## "gameplay" — HUD status, minimap, prompts, stance
## "implant/map/starchart" — star map navigator
## "implant/wiki/gttr" — Drifter's Guide reader
## "implant/journal" — knowledge journal
##
## Usage:
## HudGroups.register(self, "gameplay")
## HudGroups.register(self, "implant/map")
## HudGroups.open_app("implant/map") # hides gameplay + other implant apps
## HudGroups.close_app() # returns to gameplay
## HudGroups.toggle_app("implant/map") # open if closed, close if open
## HudGroups.register(self, "implant/map/starchart")
## HudGroups.open_app("implant/map/starchart") # fullscreen by default
## HudGroups.open_app("implant/map/starchart", HudGroups.MODE_INSERT)
## HudGroups.close_app()
## HudGroups.toggle_app("implant/map/starchart")
var _groups: Dictionary = {} # group_name -> Array[Control]
var _active_app: String = "" # currently open implant/* app ("" = none)
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
## Emitted when an app opens, closes, or changes mode.
## Listeners can use this to adapt their layout (e.g. resize to fullscreen).
signal app_changed(app_path: String, mode: Mode)
## 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.
## Connect from any renderer: HudGroups.gameplay_occluded.connect(_on_occluded)
signal gameplay_occluded(occluded: bool)
var _groups: Dictionary = {} # group_path -> Array[CanvasItem]
var _active_app: String = "" # currently focused implant app ("" = none)
var _active_mode: Mode = Mode.GAMEPLAY
func register(node: Control, group: String) -> void:
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 visibility
if group == "gameplay":
node.visible = _active_app.is_empty()
elif group.begins_with("implant/"):
node.visible = (group == _active_app)
# Apply current z-level
_apply_z(node, group)
func unregister(node: Control, group: String) -> void:
func unregister(node: CanvasItem, group: String) -> void:
if _groups.has(group):
_groups[group].erase(node)
## Open an implant app. Hides gameplay and any other implant app.
func open_app(app: String) -> void:
if not app.begins_with("implant/"):
push_warning("HudGroups.open_app: expected implant/* group, got: " + app)
return
# Hide previous app if different
## Open an implant app in fullscreen (default) or insert mode.
func open_app(app: String, mode: Mode = Mode.FULLSCREEN) -> void:
# Close previous app if different
if not _active_app.is_empty() and _active_app != app:
_set_group_visible(_active_app, false)
_set_group_z(_active_app, Z_GAMEPLAY)
app_changed.emit(_active_app, Mode.GAMEPLAY)
_active_app = app
_set_group_visible("gameplay", false)
_set_group_visible(app, true)
_active_mode = mode
# Set z-levels based on mode
var was_occluded := _active_mode == Mode.FULLSCREEN and not _active_app.is_empty()
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 implant app. Returns to gameplay.
## 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_visible(_active_app, false)
_set_group_z(_active_app, Z_GAMEPLAY)
app_changed.emit(_active_app, Mode.GAMEPLAY)
_active_app = ""
_set_group_visible("gameplay", true)
_active_mode = Mode.GAMEPLAY
_set_group_z("gameplay", Z_GAMEPLAY)
if was_fullscreen:
gameplay_occluded.emit(false)
## Toggle an implant app. If it's open, close it. If another is open, switch.
func toggle_app(app: String) -> void:
## 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)
open_app(app, mode)
## Check if a specific app is currently open.
@@ -90,19 +117,31 @@ func is_implant_active() -> bool:
return not _active_app.is_empty()
## Get the currently active app name, or "" if none.
## Get the currently active app path, or "" if none.
func get_active_app() -> String:
return _active_app
## Show/hide an independent group (modal, debug). Does not affect gameplay/implant.
func show_group(group: String, vis: bool = true) -> void:
_set_group_visible(group, vis)
## Get the current mode of the active app.
func get_active_mode() -> Mode:
return _active_mode
func _set_group_visible(group: String, vis: bool) -> void:
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
for node: Control in _groups[group]:
for node: CanvasItem in _groups[group]:
if is_instance_valid(node):
node.visible = vis
node.z_index = z
+5 -12
View File
@@ -62,20 +62,13 @@ func _ready() -> void:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# D-170: Register HUD nodes into visibility groups
# Gameplay group — hidden when implant panels are open
# D-170: Register HUD nodes into layer groups
for node in [
hud,
minimap,
stance_indicator,
interaction_prompt,
interaction_list,
inventory_grid,
news_ticker,
examine_display,
world_radial
hud, minimap, stance_indicator, interaction_prompt,
interaction_list, inventory_grid, news_ticker,
examine_display, world_radial, cursor_renderer,
]:
if node and node is Control:
if node and node is CanvasItem:
HudGroups.register(node, "gameplay")
# #775: Initialize extracted components
@@ -21,13 +21,23 @@ var _last_tick: int = -1
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
var _occluded: bool = false # D-170: skip rendering when fullscreen implant app is covering us
func _ready() -> void:
HudGroups.gameplay_occluded.connect(_on_gameplay_occluded)
print("WorldRenderer: Initialized (D-049 z-stack)")
func _on_gameplay_occluded(occluded: bool) -> void:
_occluded = occluded
# Called each frame to update visuals from game state.
# Uses tick-based invalidation — re-renders all layers when a new snapshot arrives.
func update_from_state() -> void:
if _occluded:
return # D-170: skip rendering while fullscreen implant app is active
var tick := GameState.current_tick
if tick == _last_tick:
return
+16 -8
View File
@@ -118,8 +118,9 @@ func _ready() -> void:
_info_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_info_panel)
# D-170: Register with HUD visibility groups
# D-170: Register with HUD layer groups
HudGroups.register(self, "implant/map/starchart")
HudGroups.app_changed.connect(_on_app_changed)
_load_data()
if _data_loaded:
@@ -135,20 +136,27 @@ func _process(_delta: float) -> void:
## Called from main.gd when insert state changes.
## Only force-hides when insert is inactive. Does NOT auto-show — star map is
## modal (player opens via toggle_visible()), not always-on like the minimap.
## Called from main.gd when insert state changes.
func set_insert_active(active: bool) -> void:
_insert_active = active
if not active:
visible = false
if not active and HudGroups.is_app_active("implant/map/starchart"):
HudGroups.close_app()
## Toggle visibility via HUD group system (D-170).
## Opens implant/map (hides gameplay) or closes it (returns to gameplay).
## Toggle via HUD layer system (D-170).
func toggle_visible() -> void:
HudGroups.toggle_app("implant/map/starchart")
if visible:
## Respond to app layer changes (D-170).
func _on_app_changed(app_path: String, mode: HudGroups.Mode) -> void:
if app_path != "implant/map/starchart":
return
if mode == HudGroups.Mode.FULLSCREEN or mode == HudGroups.Mode.INSERT:
visible = true
_dirty = true
else:
visible = false
## Return the currently selected system data, or empty dict.