diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 21d575e01..20e6114da 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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", ""),