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:
@@ -124,6 +124,11 @@ stance_down={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
free_camera={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
bug_report={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
|
||||
@@ -41,11 +41,6 @@ var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs
|
||||
# v5 fields (#414)
|
||||
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
|
||||
|
||||
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
|
||||
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
|
||||
# Server sends this field as part of the player's capability snapshot.
|
||||
var lattice_profile: String = "lattice_baseline"
|
||||
|
||||
# v6 fields (#449, D-053, D-065)
|
||||
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
|
||||
var player_inventory: Array = [] # [{item_id, name, slot}]
|
||||
@@ -94,10 +89,8 @@ 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"
|
||||
# #898: Free camera mode — camera decoupled from player, WASD pans camera directly.
|
||||
var free_camera_mode: bool = false
|
||||
|
||||
# #705: Character visual descriptor — set by character_creation.gd on confirmation.
|
||||
# Passed to EntityRenderer for the player entity's CharacterVisual on game start.
|
||||
|
||||
@@ -68,7 +68,7 @@ func _process(_delta: float) -> void:
|
||||
# D-054: Update facing angle from mouse position every frame
|
||||
_update_facing_from_mouse()
|
||||
|
||||
if GameState.dialogue_active:
|
||||
if GameState.dialogue_active or GameState.free_camera_mode:
|
||||
return
|
||||
|
||||
# D-054: Send facing octant to server when it changes (even without movement)
|
||||
|
||||
@@ -55,12 +55,11 @@ func new_game() -> String:
|
||||
|
||||
|
||||
## Resume an existing game session by setting the active game-id.
|
||||
## Restores world_seed and character_archetype from the save directory.
|
||||
## Restores world_seed 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).
|
||||
@@ -171,29 +170,6 @@ 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:
|
||||
|
||||
@@ -261,7 +261,6 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns
|
||||
# Server blocks waiting for this before entering the tick loop.
|
||||
var startup_bytes := Protocol.encode_startup_message(
|
||||
GameState.world_seed,
|
||||
GameState.character_archetype,
|
||||
GameState.character_visual_descriptor
|
||||
)
|
||||
if startup_bytes.size() > 0:
|
||||
|
||||
+41
-1
@@ -1,6 +1,11 @@
|
||||
extends Node2D
|
||||
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
# #898: Free camera pan speed in pixels/second (unzoomed) and zoom step per scroll tick.
|
||||
const FREE_CAMERA_PAN_SPEED: float = 400.0
|
||||
const FREE_CAMERA_ZOOM_STEP: float = 0.1
|
||||
const FREE_CAMERA_ZOOM_MIN: float = 0.5
|
||||
const FREE_CAMERA_ZOOM_MAX: float = 8.0
|
||||
|
||||
var economics_app = null # EconomicsApp — populated in _ready() via ImplantRegistry
|
||||
var atlas_app = null # AtlasApp — populated in _ready() via ImplantRegistry
|
||||
@@ -184,10 +189,31 @@ func _ready() -> void:
|
||||
atlas_app.economics_link_requested.connect(_on_atlas_economics_link)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# #898: Scroll wheel zoom in free camera mode.
|
||||
if GameState.free_camera_mode and event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.pressed:
|
||||
var zoom := camera.zoom
|
||||
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
zoom += Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
|
||||
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
zoom -= Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
|
||||
camera.zoom = zoom.clamp(
|
||||
Vector2(FREE_CAMERA_ZOOM_MIN, FREE_CAMERA_ZOOM_MIN),
|
||||
Vector2(FREE_CAMERA_ZOOM_MAX, FREE_CAMERA_ZOOM_MAX)
|
||||
)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
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
|
||||
# #898: F4 toggles free camera mode.
|
||||
if key_event.physical_keycode == KEY_F4:
|
||||
GameState.free_camera_mode = not GameState.free_camera_mode
|
||||
return
|
||||
# Registry-driven toggle: each manifest declares its own default_key.
|
||||
for manifest: ImplantAppManifest in ImplantRegistry.get_manifests():
|
||||
if manifest.app_path.is_empty():
|
||||
@@ -239,9 +265,23 @@ func _process(delta: float) -> void:
|
||||
# #559: Dispatch snapshot to registered handlers (router pattern).
|
||||
_router.dispatch(snapshot)
|
||||
|
||||
# #898: Free camera WASD pan — runs in place of player tracking.
|
||||
if GameState.free_camera_mode:
|
||||
var pan := Vector2.ZERO
|
||||
if Input.is_action_pressed("move_north"):
|
||||
pan.y -= 1.0
|
||||
if Input.is_action_pressed("move_south"):
|
||||
pan.y += 1.0
|
||||
if Input.is_action_pressed("move_east"):
|
||||
pan.x += 1.0
|
||||
if Input.is_action_pressed("move_west"):
|
||||
pan.x -= 1.0
|
||||
if pan != Vector2.ZERO:
|
||||
var speed := FREE_CAMERA_PAN_SPEED / camera.zoom.x
|
||||
camera.global_position += pan.normalized() * speed * delta
|
||||
# Track camera to player (D-015: locked, fixed-north).
|
||||
# #117: Manual exponential smoothing.
|
||||
if _camera_anchored:
|
||||
elif _camera_anchored:
|
||||
var target := GameState.player_position * Constants.TILE_SIZE
|
||||
if _teleport_in_progress:
|
||||
camera.global_position = target
|
||||
|
||||
@@ -613,34 +613,15 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
# -- Encode: GDScript types → bytes to server ----------------------------------
|
||||
|
||||
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #588, #718).
|
||||
## Encode a StartupMessage to MessagePack bytes (#175, #718).
|
||||
## Sent by the client immediately after handshake validation.
|
||||
## 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).
|
||||
## Server reads this to initialize SimRng (D-010, D-029).
|
||||
## character_visual: optional CharacterVisualDescriptor — included as "character_visual_descriptor" dict.
|
||||
static func encode_startup_message(
|
||||
world_seed: int, character_archetype: String = "detective", character_visual: Variant = null
|
||||
world_seed: int, character_visual: Variant = null
|
||||
) -> PackedByteArray:
|
||||
# Map client lowercase archetype string to server PascalCase enum variant.
|
||||
# Explicit match prevents unknown strings silently reaching the server as
|
||||
# garbage enum values — fail loudly and fall back to "Detective".
|
||||
var archetype_variant: String
|
||||
match character_archetype:
|
||||
"detective":
|
||||
archetype_variant = "Detective"
|
||||
"smuggler":
|
||||
archetype_variant = "Smuggler"
|
||||
_:
|
||||
push_error(
|
||||
(
|
||||
"Protocol: unknown character_archetype '%s' — defaulting to 'Detective'"
|
||||
% character_archetype
|
||||
)
|
||||
)
|
||||
archetype_variant = "Detective"
|
||||
var msg := {
|
||||
"world_seed": world_seed,
|
||||
"character_archetype": archetype_variant,
|
||||
}
|
||||
if character_visual != null and character_visual.has_method("to_dict"):
|
||||
msg["character_visual_descriptor"] = character_visual.to_dict()
|
||||
|
||||
@@ -88,10 +88,6 @@ static func apply(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
GameState.current_monologue = null
|
||||
|
||||
# #122: lattice_profile
|
||||
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
|
||||
GameState.lattice_profile = snapshot.lattice_profile
|
||||
|
||||
# v6: player_stance (#449, D-053)
|
||||
if snapshot.has("player_stance") and snapshot.player_stance is String:
|
||||
GameState.player_stance = snapshot.player_stance
|
||||
|
||||
@@ -40,15 +40,17 @@ func test_fog_visibility_forward_tile() -> void:
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed
|
||||
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed.
|
||||
# Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within
|
||||
# the 64x64 box and does not trigger an unexpected _resize() in update_from_state().
|
||||
GameState.visible_tiles = []
|
||||
fog._resize(Rect2i(0, 0, 64, 64))
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
|
||||
fog.update_from_state()
|
||||
# Index: row 5 * width 64 + col 5
|
||||
assert_that(fog._vis_bytes[5 * 64 + 5]).override_failure_message(
|
||||
"Forward tile at (5,5) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
|
||||
# Index: row 10 * width 64 + col 10
|
||||
assert_that(fog._vis_bytes[10 * 64 + 10]).override_failure_message(
|
||||
"Forward tile at (10,10) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
|
||||
).is_equal(FogState.VIS_FORWARD)
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
@@ -81,10 +83,12 @@ func test_fog_exploration_persistence() -> void:
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
# Use position (10,10): 8-tile padding gives tile_bounds origin (2,2), stays within
|
||||
# the 64x64 box and does not trigger an unexpected _resize() in update_from_state().
|
||||
GameState.visible_tiles = []
|
||||
fog._resize(Rect2i(0, 0, 64, 64))
|
||||
var pos := Vector2i(5, 5)
|
||||
var idx: int = 5 * 64 + 5
|
||||
var pos := Vector2i(10, 10)
|
||||
var idx: int = 10 * 64 + 10
|
||||
# Frame 1: tile visible
|
||||
GameState.visible_positions = {pos: true}
|
||||
GameState.visibility_sectors = {pos: "Forward"}
|
||||
@@ -118,9 +122,10 @@ func test_fog_hidden_tile_value() -> void:
|
||||
assert_that(fog._vis_bytes[idx]).override_failure_message(
|
||||
"Never-visible tile should be VIS_HIDDEN=%d after resize" % FogState.VIS_HIDDEN
|
||||
).is_equal(FogState.VIS_HIDDEN)
|
||||
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
|
||||
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible.
|
||||
# Use position (10,10): 8-tile padding stays within the 64x64 box, no resize triggered.
|
||||
GameState.visible_positions = {Vector2i(10, 10): true}
|
||||
GameState.visibility_sectors = {Vector2i(10, 10): "Forward"}
|
||||
fog.update_from_state()
|
||||
assert_that(fog._vis_bytes[idx]).override_failure_message(
|
||||
"Non-visible tile should remain VIS_HIDDEN=%d after update" % FogState.VIS_HIDDEN
|
||||
|
||||
@@ -182,11 +182,10 @@ func test_d063_dim_alpha_is_set() -> void:
|
||||
box.queue_free()
|
||||
|
||||
|
||||
func skip_test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
|
||||
func test_d063_confrontation_signal_fires_on_confrontation_option() -> void:
|
||||
## D-063: Selecting a confrontation option fires confrontation_monologue signal.
|
||||
## BROKEN (#867): signal_fired stays false in headless; create_tween() before emit
|
||||
## may abort _start_confrontation_beat if panel node is null. Bug filed.
|
||||
## This delivers the 1-2 second internal monologue beat to MonologueDisplay.
|
||||
## Fixed (#867): guard tween_property behind is_instance_valid(panel) so emit fires
|
||||
## even in headless mode where the panel node may not be in the scene tree.
|
||||
var box := _make_dialogue_box()
|
||||
if box == null: return
|
||||
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
## Sprint 22 fog state behavioral tests — revived in Sprint 38 (#879).
|
||||
## Original: deleted in Sprint 37 (#870 parse-error cleanup).
|
||||
## Spec refs: D-059, D-066, #569, #585
|
||||
##
|
||||
## Coverage: EXP_EXPLORED persistence, grow-only bounds invariant,
|
||||
## texture-resize copy, BoundaryWall handling.
|
||||
##
|
||||
## Uses FogState autoload directly via /root/FogState — byte-level assertions
|
||||
## on _vis_bytes and _exp_bytes, consistent with test_fog_shader.gd approach.
|
||||
class_name TestFogSprint22
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _get_fog_state() -> Node:
|
||||
var node = get_node_or_null("/root/FogState")
|
||||
if node == null:
|
||||
push_warning("TestFogSprint22: FogState autoload not found — test skipped")
|
||||
return node
|
||||
|
||||
|
||||
func _reset_fog_state(fog_state: Node) -> void:
|
||||
GameState.visible_positions.clear()
|
||||
GameState.boundary_positions.clear()
|
||||
GameState.visible_tiles.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
fog_state._resize(Rect2i(0, 0, 32, 32))
|
||||
|
||||
|
||||
# -- EXP_EXPLORED persistence --------------------------------------------------
|
||||
## D-059: Previously-seen tiles render as "deep fog" (EXP_EXPLORED = 128).
|
||||
## Once a tile enters LOS, leaving LOS must NOT reset it to EXP_UNEXPLORED.
|
||||
## This is the core "fog of war memory" invariant.
|
||||
|
||||
func test_exp_explored_persists_after_leaving_los() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: tile (2,2) is in LOS → must become EXP_VISIBLE
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_2_2: int = (2 - oy) * w + (2 - ox)
|
||||
assert_int(exp[idx_2_2]).override_failure_message(
|
||||
"D-059: visible tile must have EXP_VISIBLE (255) on first sight"
|
||||
).is_equal(FogState.EXP_VISIBLE)
|
||||
|
||||
# Tick 2: tile (2,2) leaves LOS — only (3,3) is visible now
|
||||
GameState.visible_positions = {Vector2i(3, 3): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# After leaving LOS, (2,2) must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0)
|
||||
ox = fog_state.map_bounds.position.x
|
||||
oy = fog_state.map_bounds.position.y
|
||||
w = fog_state.map_bounds.size.x
|
||||
exp = fog_state._exp_bytes
|
||||
idx_2_2 = (2 - oy) * w + (2 - ox)
|
||||
assert_int(exp[idx_2_2]).override_failure_message(
|
||||
"D-059: tile leaving LOS must decay to EXP_EXPLORED (128), not EXP_UNEXPLORED (0)"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_never_seen_tile_stays_unexplored() -> void:
|
||||
## Corollary: a tile that was never in LOS stays EXP_UNEXPLORED.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tile (5,5) never enters LOS
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_5_5: int = (5 - oy) * w + (5 - ox)
|
||||
assert_int(exp[idx_5_5]).override_failure_message(
|
||||
"D-059: tile never in LOS must remain EXP_UNEXPLORED (0)"
|
||||
).is_equal(FogState.EXP_UNEXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_exp_explored_not_overwritten_by_subsequent_invisible_ticks() -> void:
|
||||
## EXP_EXPLORED must not decay further after the player moves away.
|
||||
## If the player is never in the area again, the tile stays at EXP_EXPLORED.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: see tile (4,4)
|
||||
GameState.visible_positions = {Vector2i(4, 4): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves far away, (4,4) out of LOS
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 3: player stays far away
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_4_4: int = (4 - oy) * w + (4 - ox)
|
||||
assert_int(exp[idx_4_4]).override_failure_message(
|
||||
"D-059: EXP_EXPLORED must not decay further once set — tile stays at 128"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
# -- Grow-only bounds invariant ------------------------------------------------
|
||||
## D-059: map_bounds only ever grows. Previously-explored tiles that leave the
|
||||
## visible area must not be evicted from the texture. The bounds never shrink.
|
||||
|
||||
func test_bounds_grow_when_player_moves_to_new_area() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: small area visible
|
||||
GameState.visible_positions = {Vector2i(2, 2): true, Vector2i(3, 3): true}
|
||||
fog_state.update_from_state()
|
||||
var bounds_after_t1: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Tick 2: player moves to a larger area
|
||||
GameState.visible_positions = {Vector2i(20, 20): true, Vector2i(25, 25): true}
|
||||
fog_state.update_from_state()
|
||||
var bounds_after_t2: Rect2i = fog_state.map_bounds
|
||||
|
||||
# Bounds must have grown or stayed the same — never shrunk
|
||||
assert_bool(bounds_after_t2.size.x >= bounds_after_t1.size.x).override_failure_message(
|
||||
"D-059: map_bounds width must never shrink (grow-only invariant)"
|
||||
).is_true()
|
||||
assert_bool(bounds_after_t2.size.y >= bounds_after_t1.size.y).override_failure_message(
|
||||
"D-059: map_bounds height must never shrink (grow-only invariant)"
|
||||
).is_true()
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_bounds_contain_new_visible_positions() -> void:
|
||||
## After update_from_state, all visible positions must lie within map_bounds.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
GameState.visible_positions = {Vector2i(10, 5): true, Vector2i(15, 12): true}
|
||||
fog_state.update_from_state()
|
||||
var bounds: Rect2i = fog_state.map_bounds
|
||||
|
||||
for pos in GameState.visible_positions:
|
||||
assert_bool(bounds.has_point(pos)).override_failure_message(
|
||||
"D-059: visible position %s must be within map_bounds %s" % [pos, bounds]
|
||||
).is_true()
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_bounds_encompass_previous_area_after_player_moves() -> void:
|
||||
## Old area coordinates must still be within map_bounds after player moves away.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: see area around (2,2)
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves far away
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# The original tile (2,2) must still be within map_bounds
|
||||
var bounds: Rect2i = fog_state.map_bounds
|
||||
assert_bool(bounds.has_point(Vector2i(2, 2))).override_failure_message(
|
||||
"D-059: grow-only — previously-visited area (2,2) must remain within map_bounds"
|
||||
).is_true()
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
# -- Texture-resize copy -------------------------------------------------------
|
||||
## D-059: When bounds grow (resize), exploration data from the old bounds
|
||||
## must be preserved in the new texture at the correct offsets.
|
||||
## This is the "texture-resize copy" invariant.
|
||||
|
||||
func test_exploration_data_preserved_across_resize() -> void:
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: mark (3,3) as explored
|
||||
GameState.visible_positions = {Vector2i(3, 3): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Force a resize by moving far away
|
||||
GameState.visible_positions = {Vector2i(25, 25): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# After resize, (3,3) must still be EXP_EXPLORED (not reset to EXP_UNEXPLORED)
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_3_3: int = (3 - oy) * w + (3 - ox)
|
||||
assert_int(exp[idx_3_3]).override_failure_message(
|
||||
"D-059: exploration state (EXP_EXPLORED=128) must survive texture resize"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_newly_added_area_starts_unexplored_after_resize() -> void:
|
||||
## When bounds grow to include a new area, those new tiles start as EXP_UNEXPLORED.
|
||||
## The copy preserves old data; new tiles get the default (0).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Establish a small explored area
|
||||
GameState.visible_positions = {Vector2i(2, 2): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Move far away to trigger bounds growth
|
||||
GameState.visible_positions = {Vector2i(30, 30): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# A completely new tile (30,30) on this tick should be EXP_VISIBLE (just entered LOS)
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var idx_30_30: int = (30 - oy) * w + (30 - ox)
|
||||
assert_int(exp[idx_30_30]).override_failure_message(
|
||||
"D-059: tile first entering LOS after resize must be EXP_VISIBLE (255)"
|
||||
).is_equal(FogState.EXP_VISIBLE)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
# -- BoundaryWall handling (#585) ----------------------------------------------
|
||||
## BoundaryWall margin tiles: fog lifts (VIS_FORWARD) so wall content composites,
|
||||
## but they do NOT persist as explored (not in visible_positions or _exp_bytes).
|
||||
|
||||
func test_boundary_wall_vis_bytes_are_forward() -> void:
|
||||
## #585: BoundaryWall tiles must receive VIS_FORWARD in the vis texture
|
||||
## so the wall sprite composites correctly (not occluded by fog).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var vis: PackedByteArray = fog_state._vis_bytes
|
||||
var boundary_idx: int = (5 - oy) * w + (6 - ox)
|
||||
assert_int(vis[boundary_idx]).override_failure_message(
|
||||
"#585: BoundaryWall tile must have VIS_FORWARD (255) in vis texture"
|
||||
).is_equal(FogState.VIS_FORWARD)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_boundary_wall_does_not_persist_as_explored() -> void:
|
||||
## #585: BoundaryWall tiles must NOT become EXP_EXPLORED after leaving the area.
|
||||
## They are rendering artifacts, not player memory.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: have a boundary wall tile at (6,5)
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves away; (6,5) is no longer a boundary wall
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
GameState.boundary_positions.clear()
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (6,5) must not be EXP_EXPLORED — it was never a true explored tile
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var boundary_idx: int = (5 - oy) * w + (6 - ox)
|
||||
assert_int(exp[boundary_idx]).override_failure_message(
|
||||
"#585: BoundaryWall tile must NOT persist as EXP_EXPLORED — only true LOS tiles are explored"
|
||||
).is_equal(FogState.EXP_UNEXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_normal_tile_adjacent_to_boundary_still_explored() -> void:
|
||||
## The normal LOS tile adjacent to a BoundaryWall must still be marked explored.
|
||||
## BoundaryWall exclusion must not affect neighboring tiles.
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: normal tile (5,5) in LOS, boundary wall at (6,5)
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves away
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
GameState.boundary_positions.clear()
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Normal tile (5,5) must be EXP_EXPLORED
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var exp: PackedByteArray = fog_state._exp_bytes
|
||||
var normal_idx: int = (5 - oy) * w + (5 - ox)
|
||||
assert_int(exp[normal_idx]).override_failure_message(
|
||||
"#585: normal LOS tile adjacent to BoundaryWall must still be EXP_EXPLORED (128)"
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
|
||||
func test_boundary_wall_visibility_only_when_present() -> void:
|
||||
## #585: A tile that is a BoundaryWall in tick 1 but absent in tick 2
|
||||
## must have VIS_HIDDEN in tick 2 (fog reapplied).
|
||||
var fog_state = _get_fog_state()
|
||||
if fog_state == null:
|
||||
return
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
|
||||
# Tick 1: boundary wall at (6,5)
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.boundary_positions = {Vector2i(6, 5): true}
|
||||
fog_state.update_from_state()
|
||||
|
||||
# Tick 2: player moves far away; (6,5) no longer visible or boundary
|
||||
GameState.visible_positions = {Vector2i(20, 20): true}
|
||||
GameState.boundary_positions.clear()
|
||||
fog_state.update_from_state()
|
||||
|
||||
# (6,5) must be VIS_HIDDEN — fog returned
|
||||
var ox: int = fog_state.map_bounds.position.x
|
||||
var oy: int = fog_state.map_bounds.position.y
|
||||
var w: int = fog_state.map_bounds.size.x
|
||||
var vis: PackedByteArray = fog_state._vis_bytes
|
||||
var boundary_idx: int = (5 - oy) * w + (6 - ox)
|
||||
assert_int(vis[boundary_idx]).override_failure_message(
|
||||
"#585: BoundaryWall tile must return to VIS_HIDDEN when not in current boundary set"
|
||||
).is_equal(FogState.VIS_HIDDEN)
|
||||
|
||||
_reset_fog_state(fog_state)
|
||||
@@ -4,7 +4,6 @@
|
||||
##
|
||||
## API per Tyre architecture review:
|
||||
## show_monologue(text, duration, priority=2, is_urgent=false)
|
||||
## GameState.lattice_profile selects colour palette
|
||||
class_name TestMonologueDisplay
|
||||
extends GdUnitTestSuite
|
||||
|
||||
@@ -35,14 +34,11 @@ func _label_text(d: Node) -> String:
|
||||
|
||||
func before_test() -> void:
|
||||
## Reset GameState fields touched by this suite so tests don't bleed into each other.
|
||||
## lattice_profile: tests that care about colour set it explicitly — default to baseline.
|
||||
## current_monologue: GameState integration tests need null as start state.
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
func after_test() -> void:
|
||||
GameState.current_monologue = null
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -310,34 +306,10 @@ func test_text_has_color_bbcode() -> void:
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lattice colour palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_augmented_colour_differs_from_baseline() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_augmented"
|
||||
d.show_monologue("Detective.", 5.0)
|
||||
var aug_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
d._next_fade_in_msec = 0.0
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Smuggler.", 5.0)
|
||||
var base_txt := _label_text(d)
|
||||
|
||||
assert_that(aug_txt).is_not_equal(base_txt)
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_urgent_colour_differs_from_standard() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.show_monologue("Normal.", 5.0, 2, false)
|
||||
var std_txt := _label_text(d)
|
||||
d._visible[0].expire_timer = -0.1; d._process(0.0)
|
||||
@@ -350,17 +322,6 @@ func test_urgent_colour_differs_from_standard() -> void:
|
||||
d.queue_free()
|
||||
|
||||
|
||||
func test_unknown_profile_falls_back_without_crash() -> void:
|
||||
var d = _make_display()
|
||||
if d == null: return
|
||||
GameState.lattice_profile = "lattice_hypothetical_tier_x"
|
||||
d.show_monologue("Future proof.", 5.0)
|
||||
var txt := _label_text(d)
|
||||
assert_that(txt).contains("[color=#") # fallback colour applied, no crash
|
||||
GameState.lattice_profile = "lattice_baseline"
|
||||
d.queue_free()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -123,23 +123,26 @@ func test_game_state_warns_on_missing_player() -> void:
|
||||
# -- SimBridge: test data completeness --
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_tiles() -> void:
|
||||
## Protocol uses "visible_tiles" (not "tiles") for test snapshot — updated from stale assertion.
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("tiles")).is_true()
|
||||
assert_that(snap.tiles.size()).is_greater(0)
|
||||
var tile = snap.tiles[0]
|
||||
assert_that(snap.has("visible_tiles")).is_true()
|
||||
assert_that(snap.visible_tiles.size()).is_greater(0)
|
||||
var tile = snap.visible_tiles[0]
|
||||
assert_that(tile.has("x")).is_true()
|
||||
assert_that(tile.has("y")).is_true()
|
||||
assert_that(tile.has("type")).is_true()
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_visible_positions() -> void:
|
||||
## Protocol uses "visible_tiles" for position data — visible_positions is derived client-side.
|
||||
## Updated from stale assertion: TestHarness snapshot never had a top-level "visible_positions".
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("visible_positions")).is_true()
|
||||
assert_that(snap.visible_positions.size()).is_greater(0)
|
||||
var pos = snap.visible_positions[0]
|
||||
assert_that(pos.has("x")).is_true()
|
||||
assert_that(pos.has("y")).is_true()
|
||||
assert_that(snap.has("visible_tiles")).is_true()
|
||||
assert_that(snap.visible_tiles.size()).is_greater(0)
|
||||
var vtile = snap.visible_tiles[0]
|
||||
assert_that(vtile.has("x")).is_true()
|
||||
assert_that(vtile.has("y")).is_true()
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_player_entity() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
@@ -162,10 +165,11 @@ func test_sim_bridge_test_snapshot_has_npc() -> void:
|
||||
assert_that(has_npc).is_true()
|
||||
|
||||
func test_sim_bridge_test_tiles_contain_all_types() -> void:
|
||||
## Protocol uses "visible_tiles" — updated from stale "tiles" assertion.
|
||||
SimBridge.reset_test_state()
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var types: Dictionary = {}
|
||||
for tile in snap.tiles:
|
||||
for tile in snap.visible_tiles:
|
||||
types[tile.type] = true
|
||||
assert_that(types.has("floor")).is_true()
|
||||
assert_that(types.has("wall")).is_true()
|
||||
|
||||
@@ -1,79 +1,17 @@
|
||||
## Sprint 24 — Signal acceptance tests (#588, #590, #592)
|
||||
## Sprint 24 — Signal acceptance tests (#590, #592)
|
||||
##
|
||||
## Client-side acceptance criteria:
|
||||
## - #588: character_archetype field in GameState, StartupMessage, SessionManager persistence
|
||||
## - #590: triangle_crisis_events decoded by Protocol, chimed once per triangle_id
|
||||
## - #592: news_ticker decode + update_from_state hide/show behavior
|
||||
##
|
||||
## Spec: D-032 (monologue pools per character), D-016 (client displays server data only),
|
||||
## D-042 (UI strings in yaml), D-067 (chime on recognition onset)
|
||||
## Spec: D-016 (client displays server data only), D-042 (UI strings in yaml),
|
||||
## D-067 (chime on recognition onset)
|
||||
class_name TestSignalSprint24
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const NEWS_TICKER_SCENE = preload("res://ui/news_ticker.tscn")
|
||||
|
||||
|
||||
# -- #588: Character archetype field ------------------------------------------
|
||||
|
||||
func test_game_state_has_character_archetype_field() -> void:
|
||||
assert_bool("character_archetype" in GameState).override_failure_message(
|
||||
"GameState must have a character_archetype field (#588)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_game_state_character_archetype_default_is_detective() -> void:
|
||||
# Fresh GameState defaults to "detective" (safest fallback for legacy saves).
|
||||
var archetype = GameState.get("character_archetype")
|
||||
assert_str(archetype).override_failure_message(
|
||||
"GameState.character_archetype default must be 'detective'"
|
||||
).is_equal("detective")
|
||||
|
||||
|
||||
func test_protocol_startup_message_unknown_archetype_defaults_to_detective() -> void:
|
||||
# Unknown archetype strings must not silently pass garbage to the server.
|
||||
# The match guard falls back to "Detective" and calls push_error.
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "hacker")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).is_null()
|
||||
assert_str(decoded.value["character_archetype"]).override_failure_message(
|
||||
"Unknown archetype must fall back to 'Detective'"
|
||||
).is_equal("Detective")
|
||||
|
||||
|
||||
func test_protocol_startup_message_includes_character_archetype() -> void:
|
||||
# StartupMessage wire payload must carry "character_archetype" key (#588).
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(12345, "detective")
|
||||
assert_bool(bytes.size() > 0).is_true()
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.status).is_null()
|
||||
var msg: Dictionary = decoded.value
|
||||
assert_bool(msg.has("character_archetype")).override_failure_message(
|
||||
"StartupMessage must contain 'character_archetype' key, got: %s" % str(msg.keys())
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_protocol_startup_message_detective_maps_to_pascal_case() -> void:
|
||||
# "detective" client string must map to "Detective" PascalCase server enum variant.
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "detective")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_str(decoded.value["character_archetype"]).is_equal("Detective")
|
||||
|
||||
|
||||
func test_protocol_startup_message_smuggler_maps_to_pascal_case() -> void:
|
||||
# "smuggler" client string must map to "Smuggler" PascalCase server enum variant.
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(0, "smuggler")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_str(decoded.value["character_archetype"]).is_equal("Smuggler")
|
||||
|
||||
|
||||
func test_protocol_startup_message_preserves_world_seed() -> void:
|
||||
# Adding character_archetype must not break world_seed encoding.
|
||||
var seed: int = 0xDEADBEEF
|
||||
var bytes: PackedByteArray = Protocol.encode_startup_message(seed, "detective")
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_int(decoded.value["world_seed"]).is_equal(seed)
|
||||
|
||||
|
||||
# -- #590: triangle_crisis_events decode --------------------------------------
|
||||
|
||||
func test_protocol_decode_includes_triangle_crisis_events_field() -> void:
|
||||
|
||||
@@ -294,10 +294,12 @@ func test_hud_time_row_updates_after_process() -> void:
|
||||
"tick": 1, "version": 23, "entities": [],
|
||||
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
|
||||
})
|
||||
instance._process(0.016)
|
||||
|
||||
# In test mode SimBridge is disconnected — poll_snapshot() returns null so the
|
||||
# SnapshotEventRouter inside main._process() never fires. Call the HUD directly
|
||||
# instead, which is what the router would do in a live session.
|
||||
var hud = instance.get_node_or_null("InsertOverlay/HUD")
|
||||
assert_that(hud).is_not_null()
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_time_text()).is_equal("12:00 · Afternoon · D1")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user