fix(client): anchor camera to player before first frame renders

Camera2D's internal smoothed_camera_pos starts at (0,0) and lerps
toward global_position, causing a visible white-screen drift on startup.
Disable position_smoothing in _ready(), snap camera to player position
from the first snapshot, then re-enable smoothing with reset_smoothing()
after the first anchored frame. Add tick guards to monologue/dialogue
consumption to prevent re-triggering when client FPS > sim tick rate.

Fixes bug #2 (camera doesn't center on player at startup).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 23:25:58 +01:00
co-authored by Claude Opus 4.6
parent 2b54697278
commit 2dd1e9545d
+58 -13
View File
@@ -17,24 +17,54 @@ extends Node2D
# If a new snapshot arrives with null dialogue while fade-in is still running, # If a new snapshot arrives with null dialogue while fade-in is still running,
# we don't re-trigger show_dialogue because _last_dialogue_id still matches. # we don't re-trigger show_dialogue because _last_dialogue_id still matches.
var _last_dialogue_id: int = 0 var _last_dialogue_id: int = 0
var _camera_anchored: bool = false
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
var _last_dialogue_tick: int = -1
func _ready() -> void: func _ready() -> void:
print("The Settled Reach — client initialized") print("The Settled Reach — client initialized")
# Connect to simulation (will use test mode initially) # Disable camera smoothing during init. Camera2D's position_smoothing
# lerps an internal smoothed_camera_pos toward global_position each frame.
# That smoothed position initializes at (0,0) — the Camera2D's default in
# the .tscn. Even after we set global_position to the player coords,
# smoothing causes the viewport to still show (0,0) on the first rendered
# frame because the lerp hasn't converged. With smoothing OFF, the viewport
# uses global_position directly. Re-enabled in _process() after anchor.
camera.position_smoothing_enabled = false
# Connect to simulation (test mode sets CONNECTED immediately)
SimBridge.connect_to_sim() SimBridge.connect_to_sim()
# Camera anchor: snap to player position before the first frame renders.
# In test mode poll_snapshot() returns synchronously — position is set
# immediately. In live mode the snapshot isn't available yet — _process
# handles it. No reset_smoothing() needed: smoothing is OFF.
var first_snapshot: Variant = SimBridge.poll_snapshot()
if first_snapshot != null:
GameState.apply_snapshot(first_snapshot)
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# D-061: Connect dialogue box signals # D-061: Connect dialogue box signals
if dialogue_box: if dialogue_box:
dialogue_box.option_selected.connect(_on_dialogue_option_selected) dialogue_box.option_selected.connect(_on_dialogue_option_selected)
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed) dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
# Main game loop: poll snapshot, apply state, flush input # Main game loop: poll snapshot, apply state, flush input
var snapshot = SimBridge.poll_snapshot() var snapshot: Variant = SimBridge.poll_snapshot()
if snapshot != null: if snapshot != null:
GameState.apply_snapshot(snapshot) GameState.apply_snapshot(snapshot)
# Late anchor: live mode — first snapshot arrives during _process.
# Smoothing is already OFF (disabled in _ready), so setting
# global_position takes effect immediately with no lerp.
if not _camera_anchored:
camera.global_position = GameState.player_position * Constants.TILE_SIZE
_camera_anchored = true
# Update renderers with new state # Update renderers with new state
if world_renderer and world_renderer.has_method("update_from_state"): if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state() world_renderer.update_from_state()
@@ -62,8 +92,16 @@ func _process(_delta: float) -> void:
_consume_dialogue() _consume_dialogue()
# Track camera to player position every frame (D-015: locked, no panning) # Track camera to player position every frame (D-015: locked, no panning)
# Camera2D smoothing handles interpolation — we just set the target if _camera_anchored:
camera.global_position = GameState.player_position * Constants.TILE_SIZE camera.global_position = GameState.player_position * Constants.TILE_SIZE
# Re-enable smoothing after the first anchored frame. The frame that just
# rendered used smoothing=OFF (correct viewport from frame one). Now we
# turn smoothing back on and sync its internal state so subsequent frames
# get smooth camera tracking during gameplay.
if _camera_anchored and not camera.position_smoothing_enabled:
camera.position_smoothing_enabled = true
camera.reset_smoothing()
# Send queued input to simulation # Send queued input to simulation
var inputs = InputMapper.flush_queue() var inputs = InputMapper.flush_queue()
@@ -92,24 +130,31 @@ func _process(_delta: float) -> void:
SimBridge.send_input(input) SimBridge.send_input(input)
# Consume-once: show monologue text, then clear to prevent re-display. # Consume-once per tick: show monologue text, then clear.
# Single monologue per snapshot is guaranteed by server. # Tick guard prevents re-triggering when the same tick is polled multiple
# times (client FPS > sim tick rate).
func _consume_monologue() -> void: func _consume_monologue() -> void:
if GameState.current_monologue != null and monologue_display: if GameState.current_monologue == null or not monologue_display:
var mono: Dictionary = GameState.current_monologue return
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0)) if GameState.current_tick == _last_monologue_tick:
GameState.current_monologue = null return
_last_monologue_tick = GameState.current_tick
var mono: Dictionary = GameState.current_monologue
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
GameState.current_monologue = null
# Consume-once with ID tracking: show dialogue, then clear. # Consume-once per tick with ID tracking: show dialogue, then clear.
# Uses dialogue_id to avoid re-triggering during fade-in if a null snapshot arrives. # Tick guard + is_dialogue_active check prevent re-triggering.
func _consume_dialogue() -> void: func _consume_dialogue() -> void:
if GameState.current_dialogue == null or not dialogue_box: if GameState.current_dialogue == null or not dialogue_box:
return return
# Don't re-show the same dialogue if it's already active with the same content if GameState.current_tick == _last_dialogue_tick:
return
if dialogue_box.is_dialogue_active(): if dialogue_box.is_dialogue_active():
GameState.current_dialogue = null GameState.current_dialogue = null
return return
_last_dialogue_tick = GameState.current_tick
var dlg: Dictionary = GameState.current_dialogue var dlg: Dictionary = GameState.current_dialogue
dialogue_box.show_dialogue( dialogue_box.show_dialogue(
dlg.get("npc_name", ""), dlg.get("npc_name", ""),