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,
|
# 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", ""),
|
||||||
|
|||||||
Reference in New Issue
Block a user