Files
settled-reach/client/scripts/main.gd
T
jpmschweitzerandClaude Opus 4.6 ea7bcfabfb fix(ui): PR #131 review round 3 — nits sweep
Seven nit-level fixes from PR review:

- Remove dead signal insert_deactivated from ImplantApp; the hook method
  on_insert_deactivated() is the actual contract.
- Rewrite _on_atlas_economics_link comment in main.gd to reflect the
  actual flow (AtlasApp closes as a consequence of HudGroups single-
  active-app, not before emitting anything).
- Document the "pop never empties below default" invariant on
  ImplantNavStack.pop() with a pointer to reset_to_default.
- Add _mutating re-entrancy guard on ImplantNavStack mutation methods.
  push_error + early return if called during a screen_changed emission.
- main.gd registry loop now uses typed ImplantAppManifest property
  access (manifest.app_path, manifest.default_key) instead of
  dictionary-style .get() calls. Empty app_path triggers push_warning.
- Document the economics [/] hotkey exception in main.gd and reference
  the planned handle_global_key lifecycle hook. Arch doc Follow-up
  section gains a bullet for the new hook.
- Comment the independent-version-read rationale above client_ver and
  proto_ver in loading_screen.gd.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 14:47:05 +02:00

397 lines
15 KiB
GDScript

extends Node2D
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
var _camera_anchored: bool = false
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay
var _teleport_in_progress: bool = false # #501/#117: forces camera snap on next frame
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames
var _router: SnapshotEventRouter # #559: callable-based snapshot dispatch
var _consumers: SnapshotConsumers # #775: non-dialogue snapshot consumers
var _dialogue: DialogueCoordinator # #775: dialogue consumers + signal handlers
@onready var world_renderer = $World
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
@onready var camera = $Camera2D
@onready var hud = $InsertOverlay/HUD
@onready var monologue_display = $UILayer/MonologueDisplay
@onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback
@onready var interaction_list = $InsertOverlay/InteractionList # D-057: z-layer 6
@onready var world_radial = $InsertOverlay/WorldRadial # D-058: z-layer 6
@onready var dialogue_box = $InsertOverlay/DialogueBox # D-061: z-layer 6
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var minimap = $InsertOverlay/Minimap # #151: diegetic minimap overlay (D-013, D-049)
@onready var examine_display = $InsertOverlay/ExamineDisplay # #174: examine result overlay
@onready var journal_panel = $InsertOverlay/JournalPanel # #264: knowledge journal (D-041)
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@onready var loading_screen = $ModalLayer/LoadingScreen # #257: blocking overlay during load
@onready var debug_console = $ModalLayer/DebugConsole # #581: tilde debug console
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
@onready var economics_app = $InsertOverlay/HUD/EconomicsApp # #824: economics monitor (D-170)
@onready var atlas_app = $InsertOverlay/HUD/AtlasApp # #844: atlas implant — reach → system → planet → regional (D-191)
func _ready() -> void:
print("The Settled Reach — client initialized")
# #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing.
camera.position_smoothing_enabled = false
# Connect to simulation (test mode sets CONNECTED immediately)
SimBridge.connect_to_sim()
# #257: Deferred load dispatch
if not GameState.pending_load_path.is_empty():
if loading_screen:
loading_screen.show_loading()
if SimBridge.state == SimBridge.ConnectionState.CONNECTED:
_dispatch_pending_load()
else:
SimBridge.connection_state_changed.connect(_on_sim_connected_for_load)
# Camera anchor: snap to player position before the first frame renders.
var first_snapshot: Variant = SimBridge.poll_snapshot()
if first_snapshot != null:
GameState.apply_snapshot(first_snapshot)
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# 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,
]:
if node and node is CanvasItem:
HudGroups.register(node, "gameplay")
# #775: Initialize extracted components
_consumers = (
SnapshotConsumers
. new()
. init(
{
"monologue_display": monologue_display,
"dialogue_box": dialogue_box,
"examine_display": examine_display,
"loading_screen": loading_screen,
"debug_console": debug_console,
"cursor_renderer": cursor_renderer,
"interaction_list": interaction_list,
"interaction_prompt": interaction_prompt,
"minimap": minimap,
"economics_app": economics_app,
"atlas_app": atlas_app,
},
_screen_flash
)
)
_dialogue = (
DialogueCoordinator
. new()
. init(
{
"dialogue_box": dialogue_box,
"monologue_display": monologue_display,
"journal_panel": journal_panel,
},
_pending_record_inputs
)
)
_dialogue.connect_signals()
# #496: Print gauntlet session summary on disconnect
if gauntlet_hud:
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
# #559: Register snapshot dispatch handlers
_router = SnapshotEventRouter.new()
# Always-run: child nodes that update from GameState on every snapshot tick.
if world_renderer:
_router.register_always(world_renderer.update_from_state)
_router.register_always(_consumers.propagate_insert_state)
_router.register_always(_consumers.update_interaction_list)
if inventory_grid:
_router.register_always(inventory_grid.update_from_state)
if stance_indicator:
_router.register_always(stance_indicator.update_from_state)
if fog_entities:
_router.register_always(fog_entities.update_from_state)
_router.register_always(_consumers.play_recognition_chimes)
_router.register_always(_consumers.handle_triangle_crisis_events)
if gauntlet_hud:
_router.register_always(gauntlet_hud.update_from_state)
if checklist_overlay:
_router.register_always(checklist_overlay.update_from_state)
if hud:
_router.register_always(hud.update_from_state)
if news_ticker:
_router.register_always(news_ticker.update_from_state)
if journal_panel:
_router.register_always(journal_panel.update_from_state)
if debug_overlay:
_router.register_always(debug_overlay.update_from_state)
_router.register_always(_consumers.play_close_sound_events)
_router.register_always(_consumers.update_zone)
_router.register_always(_consumers.update_listening_focus)
_router.register_always(_consumers.consume_examine_result)
# Keyed: consume methods guarded by specific snapshot fields.
_router.register("current_monologue", _consumers.consume_monologue)
_router.register("current_dialogue", _dialogue.consume_dialogue)
_router.register("conversation_events", _dialogue.consume_conversation_events)
_router.register("conversation_ended", _dialogue.consume_conversation_ended)
_router.register("dialogue_response", _dialogue.consume_dialogue_response)
_router.register("save_result", _consumers.consume_save_result)
_router.register("debug_response", _consumers.consume_debug_response)
_router.register("economy_snapshot", _consumers.consume_economy_snapshot)
# #581: Wire settings_dialog debug console toggle
if settings_dialog and debug_console:
settings_dialog.debug_console_toggled.connect(debug_console.set_enabled)
# #581 D-088: Wire debug console pause/unpause
if debug_console:
debug_console.pause_requested.connect(_dialogue.on_dialogue_pause_requested)
debug_console.unpause_requested.connect(_dialogue.on_dialogue_unpause_requested)
# #835 D-191: Atlas → Economics Monitor cross-link. The atlas city data panel
# emits economics_link_requested(system_id); we pre-filter the monitor and
# open it as an insert panel on top.
if atlas_app and economics_app:
atlas_app.economics_link_requested.connect(_on_atlas_economics_link)
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey) or not event.is_pressed() or event.is_echo():
return
var key_event := event as InputEventKey
# Registry-driven toggle: each manifest declares its own default_key.
for manifest: ImplantAppManifest in ImplantRegistry.get_manifests():
if manifest.app_path.is_empty():
push_warning("main.gd: manifest with empty app_path — skipping")
continue
if manifest.default_key == key_event.keycode:
HudGroups.toggle_app(
manifest.app_path,
ImplantRegistry.get_resolved_mode(manifest.app_path)
)
return
# [ / ] — in-app navigation for the economics monitor. Not manifest-declared because
# these control intra-app navigation (prev/next system), not app launch. A planned
# handle_global_key lifecycle hook will absorb this (see arch doc Follow-up).
if key_event.keycode == KEY_BRACKETLEFT:
if economics_app and HudGroups.is_app_active("implant/economics"):
economics_app.navigate(-1)
elif key_event.keycode == KEY_BRACKETRIGHT:
if economics_app and HudGroups.is_app_active("implant/economics"):
economics_app.navigate(1)
func _on_atlas_economics_link(system_id: String) -> void:
# #835 D-191: Pre-filter the economics monitor to the city's system and open
# it as an insert panel. AtlasApp closes automatically when Economics opens
# (HudGroups single-active-app rule → app_changed signal).
if economics_app == null:
return
economics_app.select_system(system_id)
HudGroups.open_app("implant/economics", HudGroups.Mode.INSERT)
func _process(delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input
var snapshot: Variant = SimBridge.poll_snapshot()
if snapshot != null:
var old_pos := GameState.player_position
GameState.apply_snapshot(snapshot)
# #501: Detect teleport (large position jump > 5 tiles) and trigger fade
if _camera_anchored and _detect_teleport(old_pos, GameState.player_position):
_teleport_transition()
# Late anchor: live mode — first snapshot arrives during _process.
if not _camera_anchored:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# #559: Dispatch snapshot to registered handlers (router pattern).
_router.dispatch(snapshot)
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing.
if _camera_anchored:
var target := GameState.player_position * Constants.TILE_SIZE
if _teleport_in_progress:
camera.global_position = target
_teleport_in_progress = false
else:
var weight := 1.0 - exp(-Constants.CAMERA_SMOOTHING_SPEED * delta)
camera.global_position = camera.global_position.lerp(target, weight)
# Send queued input to simulation
var inputs = InputMapper.flush_queue()
for input in inputs:
# #495: F12 WRONG button — client-only
if input.action == InputMapper.Action.BUG_REPORT:
if bug_report_dialog and not bug_report_dialog.is_active():
bug_report_dialog.start_capture()
continue
# #264: J — client-only, toggle knowledge journal panel
if input.action == InputMapper.Action.OPEN_JOURNAL:
_toggle_journal()
continue
# #257: LOAD_GAME — send first, then show loading screen
if input.action == InputMapper.Action.LOAD_GAME:
var err := SimBridge.send_input(input)
_pending_record_inputs.append(input)
if err == OK and loading_screen:
loading_screen.show_loading()
elif err != OK:
push_error("main.gd: LOAD_GAME send_input failed: %s" % error_string(err))
continue
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
if input.action == InputMapper.Action.OPEN_MENU:
if settings_dialog:
if settings_dialog.is_open():
settings_dialog.close()
else:
settings_dialog.open()
continue
if input.action == InputMapper.Action.INTERACT:
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
var target_id: int = -1
var verb: String = ""
if interaction_list and interaction_list.has_method("get_interaction_target"):
target_id = interaction_list.get_interaction_target()
verb = interaction_list.get_selected_verb()
if target_id < 0 and interaction_prompt:
target_id = interaction_prompt.get_interaction_target()
verb = interaction_prompt.get_selected_verb()
if target_id >= 0:
input["action_data"] = {
"target_entity_id": target_id,
"verb": verb,
}
else:
input["action_data"] = {
"target_entity_id": null,
"verb": null,
}
SimBridge.send_input(input)
_pending_record_inputs.append(input)
# #507: Record tick data to ring buffer
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
bug_report_dialog.record_tick(
GameState.current_tick,
JSON.stringify(GameState.current_snapshot),
_pending_record_inputs
)
_pending_record_inputs.clear()
# #496: Finalize gauntlet stats on disconnect
func _on_connection_state_changed(
_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState
) -> void:
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
gauntlet_hud.finalize()
# #257: Deferred LOAD_GAME dispatch — fires once when SimBridge reaches CONNECTED.
func _on_sim_connected_for_load(
_old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState
) -> void:
if new_state != SimBridge.ConnectionState.CONNECTED:
return
if SimBridge.connection_state_changed.is_connected(_on_sim_connected_for_load):
SimBridge.connection_state_changed.disconnect(_on_sim_connected_for_load)
_dispatch_pending_load()
func _dispatch_pending_load() -> void:
var load_path := GameState.pending_load_path
if load_path.is_empty():
return
GameState.pending_load_path = ""
var err := (
SimBridge
. send_input(
{
"action": InputMapper.Action.LOAD_GAME,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"path": load_path},
}
)
)
if err != OK:
push_error("main.gd: failed to send LOAD_GAME after connection — %s" % error_string(err))
if loading_screen:
loading_screen.hide_loading(false)
# #501: Detect large position jump indicating a teleport.
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
func _teleport_transition() -> void:
_camera_anchored = true
_teleport_in_progress = true
# Clear client-side buffers
GameState.current_monologue = null
GameState.current_dialogue = null
GameState.dialogue_active = false
_consumers.clear_recognition_state()
if dialogue_box and dialogue_box.is_dialogue_active():
dialogue_box.hide_dialogue()
# Fade from black
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
_flash_rect = ColorRect.new()
_flash_rect.color = Color(0, 0, 0, 1.0)
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
$UILayer.add_child(_flash_rect)
var tween := create_tween()
tween.tween_property(_flash_rect, "color:a", 0.0, 0.3)
tween.tween_callback(_flash_rect.queue_free)
# #264: Toggle journal panel.
func _toggle_journal() -> void:
if not journal_panel:
return
if dialogue_box and dialogue_box.is_dialogue_active():
return
if journal_panel.has_method("toggle"):
journal_panel.toggle()
# #502: Full-screen color flash.
func _screen_flash(color: Color, duration: float) -> void:
if _flash_rect and is_instance_valid(_flash_rect):
_flash_rect.queue_free()
_flash_rect = ColorRect.new()
_flash_rect.color = Color(color.r, color.g, color.b, 0.4)
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
$UILayer.add_child(_flash_rect)
var tween := create_tween()
tween.tween_property(_flash_rect, "color:a", 0.0, duration)
tween.tween_callback(_flash_rect.queue_free)