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:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user