feat(client): character select, triangle activation consumer, news ticker
Sprint 24 Signal — three client tickets delivering the player-facing storyteller feedback loop: - #588: Character archetype select screen between New Game and session start. Two-card UI (Smuggler/Detective), keyboard+mouse, ESC cancels. GameState.character_archetype persisted and sent in StartupMessage. PROTOCOL_VERSION bumped to 19. - #590: Triangle crisis event consumer. Decodes triangle_crisis_events from snapshot, fires sfx_monologue_chime_urgent once per triangle per session via AudioManager.CHIME_ACTIVATION. - #592: News ticker HUD element. Scrolling marquee on UILayer, visible only when current_ticker is present in snapshot (Last Shift zone). Zero-arg update_from_state reads from GameState.current_snapshot. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,11 @@ extends Node
|
||||
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
|
||||
const CHIME_RECOGNITION := "sfx_monologue_chime"
|
||||
|
||||
# --- D-067: Triangle activation chime (#590, D-072/D-089) ---
|
||||
# Fires once per session when the triangle's tell_state shifts to RoutineDeviation.
|
||||
# Sharper variant (D-067: "contradiction/anomaly") — sfx_monologue_chime_urgent.ogg.
|
||||
const CHIME_ACTIVATION := "sfx_monologue_chime_urgent"
|
||||
|
||||
# --- Bus names (D-068) ---
|
||||
const BUS_MUSIC := "Music"
|
||||
const BUS_AMBIENT := "Ambient"
|
||||
|
||||
@@ -92,6 +92,11 @@ var debug_response: Variant = null
|
||||
# Format: user://saves/<game-id>/<filename>.sav or "" if no pending load.
|
||||
var pending_load_path: String = ""
|
||||
|
||||
# #588: Character archetype chosen at character select screen.
|
||||
# "detective" or "smuggler". Set before game scene loads; sent in StartupMessage.
|
||||
# Default: "detective" — fallback for legacy saves without character.txt.
|
||||
var character_archetype: String = "detective"
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
|
||||
@@ -47,11 +47,12 @@ func new_game() -> String:
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
## Restores world_seed from the save directory for D-010 deterministic replay.
|
||||
## Restores world_seed and character_archetype from the save directory.
|
||||
func resume_game(game_id: String) -> void:
|
||||
GameState.current_game_id = game_id
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
GameState.world_seed = _read_seed_file(save_path)
|
||||
GameState.character_archetype = _read_archetype_file(save_path)
|
||||
|
||||
|
||||
## List all game directories under user://saves/ sorted by last-modified (most recent first).
|
||||
@@ -146,6 +147,24 @@ func _read_seed_file(save_path: String) -> int:
|
||||
return file.get_64() & 0x7FFFFFFFFFFFFFFF
|
||||
|
||||
|
||||
## Write character_archetype to save directory. Called after new_game() creates the dir.
|
||||
func save_character_archetype(game_id: String, archetype: String) -> void:
|
||||
var save_path := SAVES_DIR + game_id + "/"
|
||||
var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("SessionManager: failed to write character.txt: %s" % error_string(FileAccess.get_open_error()))
|
||||
return
|
||||
file.store_string(archetype)
|
||||
|
||||
|
||||
## Read character_archetype from save directory. Returns "detective" if missing (legacy saves).
|
||||
func _read_archetype_file(save_path: String) -> String:
|
||||
var file := FileAccess.open(save_path + "character.txt", FileAccess.READ)
|
||||
if file == null:
|
||||
return "detective"
|
||||
return file.get_as_text().strip_edges()
|
||||
|
||||
|
||||
func _find_newest_save(dir_path: String) -> String:
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
|
||||
@@ -233,7 +233,7 @@ func _process(delta: float) -> void:
|
||||
|
||||
# Send startup message with world_seed (#175, D-010/D-029).
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed)
|
||||
var startup_bytes := Protocol.encode_startup_message(GameState.world_seed, GameState.character_archetype)
|
||||
if startup_bytes.size() > 0:
|
||||
var send_err := _bridge.send_message(startup_bytes)
|
||||
if send_err != OK:
|
||||
|
||||
@@ -23,6 +23,7 @@ extends Node2D
|
||||
@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)
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
@@ -31,6 +32,7 @@ var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when s
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
||||
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
||||
var _known_triangle_ids: Dictionary = {} # #590: triangle_ids that have already fired the activation chime
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
||||
@@ -102,12 +104,15 @@ func _ready() -> void:
|
||||
if fog_entities:
|
||||
_router.register_always(fog_entities.update_from_state)
|
||||
_router.register_always(_play_recognition_chimes)
|
||||
_router.register_always(_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 time_display:
|
||||
_router.register_always(time_display.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:
|
||||
@@ -294,6 +299,21 @@ func _play_recognition_chimes() -> void:
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
|
||||
|
||||
# #590 D-072/D-089: Triangle activation consumer — fires sfx_monologue_chime_urgent once
|
||||
# per triangle_id. The tell_state on the activated NPC and subsequent proximity monologue
|
||||
# lines are the visible consequence (D-039 wow moment #2 "The Character's Eye").
|
||||
# No overlay is shown — the chime is the only client-side reaction (D-039 intent).
|
||||
func _handle_triangle_crisis_events() -> void:
|
||||
var events: Array = GameState.current_snapshot.get("triangle_crisis_events", [])
|
||||
for ev in events:
|
||||
if not ev is Dictionary or not ev.has("triangle_id"):
|
||||
continue
|
||||
var tid: int = ev.triangle_id
|
||||
if not _known_triangle_ids.has(tid):
|
||||
_known_triangle_ids[tid] = true
|
||||
AudioManager.play(AudioManager.CHIME_ACTIVATION, AudioManager.BUS_UI_SOUNDS)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||
# (extracted in apply_snapshot(), server-authoritative per D-020).
|
||||
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
|
||||
|
||||
@@ -11,7 +11,8 @@ class_name Protocol
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 18
|
||||
## v19: adds character_archetype field to StartupMessage (#588, #587).
|
||||
const PROTOCOL_VERSION: int = 19
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -255,6 +256,28 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"success": bool(raw_debug.get("success", false)),
|
||||
}
|
||||
|
||||
# v19: triangle_crisis_events (#590, D-072/D-089) — one-shot activation events.
|
||||
# Each entry: {triangle_id: int}. Client deduplicates by triangle_id across ticks.
|
||||
var triangle_crisis_events: Array = []
|
||||
var raw_tce: Variant = raw.get("triangle_crisis_events")
|
||||
if raw_tce is Array:
|
||||
for raw_ev in raw_tce:
|
||||
if raw_ev is Dictionary and raw_ev.has("triangle_id"):
|
||||
triangle_crisis_events.append({
|
||||
"triangle_id": int(raw_ev["triangle_id"]),
|
||||
})
|
||||
|
||||
# v19: current_ticker (#592) — scrolling news headline when in The Last Shift zone.
|
||||
# {id: String, text: String, category: String} or null when player outside bar zone.
|
||||
var current_ticker: Variant = null
|
||||
var raw_ticker: Variant = raw.get("current_ticker")
|
||||
if raw_ticker is Dictionary and raw_ticker.has("text"):
|
||||
current_ticker = {
|
||||
"id": str(raw_ticker.get("id", "")),
|
||||
"text": str(raw_ticker["text"]),
|
||||
"category": str(raw_ticker.get("category", "")),
|
||||
}
|
||||
|
||||
# TODO(server): Send stationary_ticks in ObserverSnapshot (D-071, D-020).
|
||||
# Server already tracks this in ListeningFocus component (server/src/simulation/listening.rs).
|
||||
# When server populates this field, client-side accumulation fallback in game_state.gd
|
||||
@@ -334,6 +357,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"debug_response": debug_response,
|
||||
"stationary_ticks": stationary_ticks,
|
||||
"zone_id": zone_id,
|
||||
"triangle_crisis_events": triangle_crisis_events,
|
||||
"current_ticker": current_ticker,
|
||||
}
|
||||
|
||||
|
||||
@@ -439,11 +464,17 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175).
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## Server reads this to initialize SimRng with the world seed (D-010, D-029).
|
||||
static func encode_startup_message(world_seed: int) -> PackedByteArray:
|
||||
var msg := {"world_seed": world_seed}
|
||||
## Server reads this to initialize SimRng (D-010, D-029) and select monologue pool (D-032).
|
||||
## character_archetype: "detective" → "Detective", "smuggler" → "Smuggler" (server enum variant).
|
||||
static func encode_startup_message(world_seed: int, character_archetype: String = "detective") -> PackedByteArray:
|
||||
# Map client lowercase archetype string to server PascalCase enum variant.
|
||||
var archetype_variant: String = character_archetype.capitalize()
|
||||
var msg := {
|
||||
"world_seed": world_seed,
|
||||
"character_archetype": archetype_variant,
|
||||
}
|
||||
var result = Messagepack.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: startup message encode failed: %s" % result.status)
|
||||
|
||||
Reference in New Issue
Block a user