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:
2026-03-05 11:44:05 +01:00
co-authored by Claude Opus 4.6
parent 7ba2be0652
commit 61d454228d
15 changed files with 501 additions and 10 deletions
+36 -5
View File
@@ -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)