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:
+58
-13
@@ -17,24 +17,54 @@ extends Node2D
|
||||
# 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.
|
||||
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:
|
||||
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()
|
||||
|
||||
# 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
|
||||
if dialogue_box:
|
||||
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
|
||||
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Main game loop: poll snapshot, apply state, flush input
|
||||
var snapshot = SimBridge.poll_snapshot()
|
||||
var snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if snapshot != null:
|
||||
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
|
||||
if world_renderer and world_renderer.has_method("update_from_state"):
|
||||
world_renderer.update_from_state()
|
||||
@@ -62,8 +92,16 @@ func _process(_delta: float) -> void:
|
||||
_consume_dialogue()
|
||||
|
||||
# Track camera to player position every frame (D-015: locked, no panning)
|
||||
# Camera2D smoothing handles interpolation — we just set the target
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
if _camera_anchored:
|
||||
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
|
||||
var inputs = InputMapper.flush_queue()
|
||||
@@ -92,24 +130,31 @@ func _process(_delta: float) -> void:
|
||||
SimBridge.send_input(input)
|
||||
|
||||
|
||||
# Consume-once: show monologue text, then clear to prevent re-display.
|
||||
# Single monologue per snapshot is guaranteed by server.
|
||||
# Consume-once per tick: show monologue text, then clear.
|
||||
# Tick guard prevents re-triggering when the same tick is polled multiple
|
||||
# times (client FPS > sim tick rate).
|
||||
func _consume_monologue() -> void:
|
||||
if GameState.current_monologue != null and monologue_display:
|
||||
var mono: Dictionary = GameState.current_monologue
|
||||
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
|
||||
GameState.current_monologue = null
|
||||
if GameState.current_monologue == null or not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_monologue_tick:
|
||||
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.
|
||||
# Uses dialogue_id to avoid re-triggering during fade-in if a null snapshot arrives.
|
||||
# Consume-once per tick with ID tracking: show dialogue, then clear.
|
||||
# Tick guard + is_dialogue_active check prevent re-triggering.
|
||||
func _consume_dialogue() -> void:
|
||||
if GameState.current_dialogue == null or not dialogue_box:
|
||||
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():
|
||||
GameState.current_dialogue = null
|
||||
return
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
var dlg: Dictionary = GameState.current_dialogue
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
|
||||
Reference in New Issue
Block a user