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 -9
View File
@@ -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.
+1 -1
View File
@@ -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)
+1 -25
View File
@@ -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:
-1
View File
@@ -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
View File
@@ -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
+3 -22
View File
@@ -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()
-4
View File
@@ -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