feat(client): sprint 38 — free camera viewer, archetype strip, test fixes

- Add free camera mode (F4 toggle): WASD pan, scroll zoom, decoupled
  from player position (#898)
- Strip archetype-driven code: remove character_archetype, lattice_profile,
  and lattice color palettes from client (#882)
- Fix confrontation_monologue signal not firing in headless test mode (#867)
- Revive fog state behavioral tests: EXP_EXPLORED persistence, grow-only
  bounds, texture-resize copy, BoundaryWall handling (#879)
- Triage pre-existing test failures: fix examine_display dismiss timing,
  fog test position fragility, rendering snapshot assertions,
  time_display format (#871)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 10:19:11 +02:00
co-authored by Claude Opus 4.6
parent f9fdfb7712
commit 3f5b4258ba
18 changed files with 504 additions and 235 deletions
+2 -1
View File
@@ -498,7 +498,8 @@ func _start_confrontation_beat(response_id: String, text: String) -> void:
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
if _active_tween and _active_tween.is_valid() and is_instance_valid(panel):
_active_tween.tween_property(panel, "modulate:a", CONFRONTATION_DIM_ALPHA, 0.2)
confrontation_monologue.emit(
UIStrings.get_text(CONFRONTATION_MONOLOGUE_KEY), CONFRONTATION_BEAT_DURATION
+5 -1
View File
@@ -82,12 +82,16 @@ func _start_fade_out() -> void:
## Dismiss immediately (e.g. when dialogue opens).
## Sets _active = false immediately so is_active() returns false before the fade completes.
func dismiss() -> void:
if not _active:
return
_active = false
if _dismiss_tween and _dismiss_tween.is_valid():
_dismiss_tween.kill()
_start_fade_out()
var t := create_tween()
t.tween_property(self, "modulate:a", 0.0, FADE_OUT)
t.tween_callback(func(): visible = false)
func is_active() -> bool:
+13 -40
View File
@@ -9,7 +9,6 @@ extends Control
# (>= tiebreak = FIFO: newest replaces oldest at same priority).
#
# Stagger: 0.15s minimum gap between consecutive fade-ins (spec §5.4).
# Colour: lattice_profile passed in at call time — no autoload access in renderer.
# is_urgent=true → opacity 1.0 and elevated colour variant (bloom deferred).
const MAX_VISIBLE: int = 3
@@ -20,29 +19,16 @@ const FADE_IN_SEC: float = 0.3
const FADE_OUT_SEC: float = 0.5
const MIN_DURATION: float = FADE_IN_SEC + 0.1 # clamp: line must survive its own fade-in
# Lattice colour palette — keyed by lattice_profile passed from GameState at show time.
# standard opacity = 0.85, urgent opacity = 1.0.
# Monologue colour palette — standard opacity = 0.85, urgent opacity = 1.0.
# Source: Tyre architecture review, Sprint 14.
const _LATTICE_COLORS: Dictionary = {
"lattice_augmented":
{ # detective
"standard": Color("#d0d4e0"),
"urgent": Color("#e0e8f8"),
},
"lattice_baseline":
{ # smuggler
"standard": Color("#d8d0c4"),
"urgent": Color("#f0e4d4"),
},
}
const _FALLBACK_STANDARD: Color = Color("#c8d0e0")
const _FALLBACK_URGENT: Color = Color("#e0e8f8")
const _STANDARD_COLOR: Color = Color("#c8d0e0")
const _URGENT_COLOR: Color = Color("#e0e8f8")
const _NOTIFICATION_COLOR: Color = Color("#8890a0") # #554: neutral system notification
const _NOTIFICATION_DURATION: float = 2.5
# Visible slot: {node: Control, expire_timer: float, priority: int, tween: Tween}
var _visible: Array[Dictionary] = []
# Queue entry: {text, duration, priority, is_urgent, lattice_profile}
# Queue entry: {text, duration, priority, is_urgent}
var _queue: Array[Dictionary] = []
# Msec timestamp when the next fade-in may begin (stagger enforcement)
var _next_fade_in_msec: float = 0.0
@@ -69,19 +55,15 @@ func _process(delta: float) -> void:
if next.get("is_notification", false):
_show_notification_line(next.text)
else:
_show_line(
next.text, next.duration, next.priority, next.is_urgent, next.lattice_profile
)
_show_line(next.text, next.duration, next.priority, next.is_urgent)
# Display a monologue line.
# priority: higher number = more important (default 2; urgent beats normal).
# is_urgent: visual flag — full opacity + elevated colour. Bloom deferred.
# Empty text is silently ignored — no slot created, no queue entry.
# lattice_profile is read from GameState here and passed down — renderer stays
# decoupled from the autoload (D-020 renderer contract).
# #554: Show a brief system notification (save/load result, connection status).
# Uses neutral color, short duration, bypasses lattice_profile styling.
# Uses neutral color, short duration.
func show_notification(text: String) -> void:
if text.is_empty():
return
@@ -94,7 +76,6 @@ func show_notification(text: String) -> void:
duration = _NOTIFICATION_DURATION,
priority = 1,
is_urgent = false,
lattice_profile = "",
is_notification = true
}
if _queue.size() < MAX_QUEUE:
@@ -116,12 +97,11 @@ func show_monologue(
) -> void:
if text.is_empty():
return
var profile := GameState.lattice_profile
var now := float(Time.get_ticks_msec())
if _visible.size() < MAX_VISIBLE and now >= _next_fade_in_msec:
_show_line(text, duration, priority, is_urgent, profile)
_show_line(text, duration, priority, is_urgent)
else:
_enqueue(text, duration, priority, is_urgent, profile)
_enqueue(text, duration, priority, is_urgent)
# ---------------------------------------------------------------------------
@@ -159,9 +139,9 @@ func _show_notification_line(text: String) -> void:
func _show_line(
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
text: String, duration: float, priority: int, is_urgent: bool
) -> void:
var line_node := _build_line_node(text, is_urgent, lattice_profile)
var line_node := _build_line_node(text, is_urgent)
_vbox.add_child(line_node)
var slot := {
@@ -192,7 +172,7 @@ func _retire_slot(slot: Dictionary) -> void:
func _enqueue(
text: String, duration: float, priority: int, is_urgent: bool, lattice_profile: String
text: String, duration: float, priority: int, is_urgent: bool
) -> void:
if _queue.size() < MAX_QUEUE:
_queue.append(
@@ -201,7 +181,6 @@ func _enqueue(
duration = duration,
priority = priority,
is_urgent = is_urgent,
lattice_profile = lattice_profile
}
)
_queue.sort_custom(
@@ -216,7 +195,6 @@ func _enqueue(
duration = duration,
priority = priority,
is_urgent = is_urgent,
lattice_profile = lattice_profile
}
_queue.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool: return a.priority > b.priority
@@ -232,13 +210,8 @@ func _lowest_priority_idx() -> int:
return idx
func _build_line_node(text: String, is_urgent: bool, lattice_profile: String) -> Control:
var palette: Dictionary = _LATTICE_COLORS.get(lattice_profile, {})
var color: Color = (
palette.get("urgent", _FALLBACK_URGENT)
if is_urgent
else palette.get("standard", _FALLBACK_STANDARD)
)
func _build_line_node(text: String, is_urgent: bool) -> Control:
var color: Color = _URGENT_COLOR if is_urgent else _STANDARD_COLOR
var container := MarginContainer.new()
container.add_theme_constant_override("margin_left", 4)