Addresses Tyre's 9 architecture items from the sprint-36 client review. - decisions/architecture.md (Tyre #1): D-192 now says "deprecate; removal tracked in #868" instead of "remove". The branch does not remove the version field or guard — that belongs in the coordinated server+client PR. The decision text now matches the code on this branch. - meta_stack.gd (#2): handle_escape() on a screen with closable_by_escape=false now consumes the event unconditionally. Was returning whatever on_escape() returned, which default-returned false and leaked ESC into main.gd's implant/settings chain — opening the settings dialog behind the loading screen. - debug_console.gd (#4): drop the direct KEY_ESCAPE branch in _unhandled_input. ESC now falls through to main.gd → MetaStack, which finds the console on top of the stack and closes it via the normal path. Other keys are still consumed so movement/action can't leak. - main.gd (#6, #10): extract the ESC priority chain into _handle_menu_key() so "MetaStack → implant → settings" is a named thing. Add a comment near connect_to_sim explaining that GameState.bookmark_catalog survives the Option A scene transition via the autoload. - main_menu.gd (#7): header comment documenting the double LoadingScreen lifecycle — safe today because main_menu.tscn and main.tscn never co-exist, noted for future promotion to autoload if that changes. - meta_screen.gd (#8): apply captures_input symmetrically in open()/ close() — was set in open() only, so a screen changing the flag between open+close kept the opened value forever. - meta_screen.gd (#9): on_escape() docstring clarifies the tri-state (consume-and-hold / consume-and-close / ignore) — and that closable_by_escape=false is the screen-wide way to say "consume-and-hold". - bug_report_dialog.gd (#11): capture_cancelled now emits from on_close() (covers any close path — ESC, MetaStack pop, programmatic close) rather than only on_escape(). A new _completed flag distinguishes completion from cancel so the two signals stay mutually exclusive.
422 lines
16 KiB
GDScript
422 lines
16 KiB
GDScript
extends Node2D
|
|
|
|
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
|
|
|
var economics_app = null # EconomicsApp — populated in _ready() via ImplantRegistry
|
|
var atlas_app = null # AtlasApp — populated in _ready() via ImplantRegistry
|
|
|
|
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 = $MetaLayer/BugReportDialog # #495: F12 WRONG button
|
|
@onready var settings_dialog = $MetaLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
|
@onready var loading_screen = $MetaLayer/LoadingScreen # #257: blocking overlay during load
|
|
@onready var debug_console = $MetaLayer/DebugConsole # #581: tilde debug console
|
|
@onready var news_ticker = $UILayer/NewsTicker # #592: scrolling headline bar (D-049 z-7)
|
|
|
|
|
|
func _ready() -> void:
|
|
print("The Settled Reach — client initialized")
|
|
|
|
# #844 D-191: Populate app refs from registry (hud._ready already called instantiate_all).
|
|
atlas_app = ImplantRegistry.get_app_instance("implant/map")
|
|
economics_app = ImplantRegistry.get_app_instance("implant/economics")
|
|
|
|
# #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing.
|
|
camera.position_smoothing_enabled = false
|
|
|
|
# Connect to simulation (test mode sets CONNECTED immediately).
|
|
# Guard: Option A flow leaves SimBridge CONNECTED when main.tscn loads — don't drop it.
|
|
# Option A state handoff: main_menu polls and applies the snapshot first,
|
|
# seeding GameState (including bookmark_catalog) via the autoload before the
|
|
# scene swap. main.tscn then re-applies the next snapshot on top. Both paths
|
|
# write through GameState — which is autoloaded, so catalog state survives.
|
|
if SimBridge.state == SimBridge.ConnectionState.DISCONNECTED:
|
|
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 — delegate to ordered priority chain
|
|
if input.action == InputMapper.Action.OPEN_MENU:
|
|
_handle_menu_key()
|
|
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()
|
|
|
|
|
|
# #528: ESC/OPEN_MENU priority chain — first handler to consume wins.
|
|
# Order matters: MetaStack modal > open implant app > settings dialog.
|
|
# Adding a fourth handler: append a new step here; don't re-inline in _process.
|
|
func _handle_menu_key() -> void:
|
|
if MetaStack.handle_escape():
|
|
return
|
|
if HudGroups.is_implant_active():
|
|
HudGroups.close_app()
|
|
return
|
|
if settings_dialog == null:
|
|
return
|
|
if settings_dialog.is_open():
|
|
settings_dialog.close()
|
|
else:
|
|
MetaStack.push(settings_dialog)
|
|
settings_dialog.open()
|
|
|
|
|
|
# #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)
|