From b32565203feacba327634611abed499b20e59a85 Mon Sep 17 00:00:00 2001 From: Yogi Wiguna Date: Wed, 4 Mar 2026 12:33:06 +0800 Subject: [PATCH] feat: Implement core player movement, input, and player scene setup, including grid-based movement, rotation, push mechanics, and multiplayer synchronization. --- scenes/player.gd | 139 ++++++++++++++++---- scripts/managers/player_input_manager.gd | 41 +++--- scripts/managers/player_movement_manager.gd | 42 ++++-- 3 files changed, 161 insertions(+), 61 deletions(-) diff --git a/scenes/player.gd b/scenes/player.gd index 5d2367e..b92f162 100644 --- a/scenes/player.gd +++ b/scenes/player.gd @@ -1,4 +1,4 @@ -extends Node3D +extends CharacterBody3D # Managers var movement_manager @@ -133,6 +133,10 @@ var finish_locations: Array: get: return race_manager.finish_locations if race_manager else [] var target_visual_position: Vector3 = Vector3.ZERO # For client-side smoothing +var snapshot_buffer: Array = [] # Array of {time: int, pos: Vector3} +const INTERPOLATION_OFFSET: int = 150 # Increased to 150ms for better jitter buffering +var _last_received_timestamp: int = 0 +var _last_movement_finish_time: int = 0 var spawn_point_selected = false @@ -176,6 +180,11 @@ const AVAILABLE_CHARACTERS: Array[String] = ["Bob", "Masbro", "Gatot", "Oldpop"] func _ready(): # Ensure name is set first (node name = authority ID for multiplayer identification) + # 3. SET PHYSICS MODE TO FLOATING + # This prevents "grounding" friction and gravity from fighting our Tweens + motion_mode = CharacterBody3D.MOTION_MODE_FLOATING + + print("[Player] initialized: ", name, " Authority: ", get_multiplayer_authority()) # name = str(get_multiplayer_authority()) # CRITICAL FIX: Do NOT overwrite name. Bots have authority 1 but unique names (IDs). # Look up player's display name from LobbyManager var my_id = get_multiplayer_authority() @@ -769,14 +778,13 @@ func apply_slow_effect(duration: float = 3.0): # Restore after duration # Note: If they stand in the zone, this will be re-applied constantly, resetting the visual - await get_tree().create_timer(duration).timeout - - # Only restore if not re-applied recently (simple check: if still tinted?) # A better way is managing a "slow_timer" but for now let's just reset if timer expires. # The persistent zone logic reapplies every frame, so we want this timer to be short # OR we rely on the zone logic. # The RPC call says "apply_slow_effect(0.5)", so it expires quickly. + await get_tree().create_timer(duration).timeout + if movement_manager: movement_manager.set_speed_multiplier(1.0) @@ -1020,18 +1028,10 @@ func _process(delta): if can_rpc(): rpc("ping_existence") else: - # Client-side visual smoothing - # Only interpolate if NOT running a movement tween, OR if the drift is large (teleport/snap) + # Client-side visual smoothing with snapshot interpolation + # Only interpolate if NOT running a movement tween if not is_player_moving: - var dist_sq = global_position.distance_squared_to(target_visual_position) - - if dist_sq > 4.0: # If distance > 2.0 units (teleport/spawn), snap immediately - global_position = target_visual_position - elif dist_sq < 0.001: # Prevent micro-jitter - global_position = target_visual_position - else: - # Interpolate towards the target position received from authority - global_position = global_position.lerp(target_visual_position, delta * 15.0) + _process_remote_interpolation(delta) # Delegate rotation to movement manager if movement_manager: @@ -1044,6 +1044,48 @@ func _process(delta): immunity_timer = 0 _apply_tint_recursive(self , Color.WHITE) # Remove immunity tint +func _process_remote_interpolation(_delta): + if snapshot_buffer.size() < 2: + # Fallback to simple lerp if not enough snapshots + # Keep this very soft to smooth out transitions between tween and interpolation + if global_position.distance_squared_to(target_visual_position) > 0.001: + global_position = global_position.lerp(target_visual_position, _delta * 10.0) + return + + var render_time = Time.get_ticks_msec() - INTERPOLATION_OFFSET + + # Find the two snapshots to interpolate between + var older = snapshot_buffer[0] + var newer = null + + for i in range(1, snapshot_buffer.size()): + if snapshot_buffer[i].time > render_time: + newer = snapshot_buffer[i] + older = snapshot_buffer[i-1] + break + + if newer: + # Interpolate + var total_time = newer.time - older.time + var elapsed_time = render_time - older.time + var t = float(elapsed_time) / float(total_time) + + var target_pos = older.pos.lerp(newer.pos, t) + + # If drift is massive (teleport), snap + var dist_sq = global_position.distance_squared_to(target_pos) + if dist_sq > 16.0: + global_position = target_pos + elif dist_sq > 0.0001: + # Use a lower lerp weight for much softer tracking + global_position = global_position.lerp(target_pos, _delta * 30.0) + else: + # Extrapolate or just stay at latest if render_time is ahead of buffer + var latest = snapshot_buffer[-1] + if global_position.distance_squared_to(latest.pos) > 0.001: + global_position = global_position.lerp(latest.pos, _delta * 20.0) # Slower catchup to latest + + @rpc("any_peer", "call_local") func ping_existence(): # This just lets other clients know this player exists @@ -1053,11 +1095,19 @@ func ping_existence(): var last_sent_position: Vector3 func _physics_process(delta): + # Sync position periodically (Heartbeat / Smoothing) if is_multiplayer_authority(): - if global_position.distance_squared_to(last_sent_position) > 0.001: - if can_rpc(): - rpc("remote_set_position", global_position) - last_sent_position = global_position + # OPTIMIZATION: Only send smoothing updates if we ARE NOT currently mid-tween + # The start/end of paths are already synced via start_movement_along_path. + # Sending packets during a tween just adds noise/jitter for the client. + if not is_player_moving: + var current_world_pos = global_position + var last_sent_pos = get_meta("_last_sent_pos", Vector3.ZERO) + + # Use a slightly larger threshold (0.05) to ignore micro-vibrations + if current_world_pos.distance_to(last_sent_pos) > 0.05: + set_meta("_last_sent_pos", current_world_pos) + rpc("remote_set_position", current_world_pos, Time.get_ticks_msec()) # NOTE: Finish line checking removed - game uses cycle-based goals system now @@ -1286,17 +1336,19 @@ func simple_move_to(grid_position: Vector2i): func move_player_to_clicked_position(grid_position: Vector2i): movement_manager.move_to_clicked_position(grid_position) -@rpc("any_peer", "call_local") +@rpc("any_peer", "call_remote", "unreliable") func start_movement_along_path(path: Array, clear_visual: bool = true): + if is_player_moving: + return # ALREADY MOVING. Guard against redundant RPCs or interruptions. + print("[Player] %s starting move along path: %s" % [name, path]) + # SERVER-SIDE VIOLATION CHECK (for Stop n Go) if multiplayer.is_server() and LobbyManager.game_mode == "Stop n Go": var main = get_tree().root.get_node_or_null("Main") if main: var sng_manager = main.get_node_or_null("StopNGoManager") if sng_manager and sng_manager.has_method("check_movement_violation"): - # Check if the START of this movement is a violation - # We use current_position as the 'from' point. var target_pos = Vector2i(path[0].x, path[0].y) if path.size() > 0 else current_position sng_manager.check_movement_violation(name.to_int(), current_position, target_pos) @@ -1304,10 +1356,10 @@ func start_movement_along_path(path: Array, clear_visual: bool = true): if path.size() > 0: target_position = Vector2i(path[-1].x, path[-1].y) - # FORCE SNAP START: Ensure we start animating from our actual grid position - # This prevents "jumps" if the visual node drifted or was set via global_position vs position mismatch + # RELAXED SNAP START: Only snap if the visual node is far from where it should be (> 0.5 units) + # This prevents "twitching" when a move starts if the node was still lerping slowly to its final spot. var start_world_pos = grid_to_world(current_position) - if global_position.distance_squared_to(start_world_pos) > 0.001: + if global_position.distance_to(start_world_pos) > 0.5: global_position = start_world_pos if _movement_tween: @@ -1330,7 +1382,7 @@ func start_movement_along_path(path: Array, clear_visual: bool = true): is_player_moving = false _movement_tween = null target_position = Vector2i(-1, -1) - + _last_movement_finish_time = Time.get_ticks_msec() print("[Player] %s finished move. %s -> %s" % [name, old_pos, current_position]) if is_carrying_tekton and is_instance_valid(carried_tekton): @@ -1369,6 +1421,10 @@ func start_movement_along_path(path: Array, clear_visual: bool = true): if enhanced_gridmap: enhanced_gridmap.clear_path_visualization() + # Reset interpolation buffer to prevent "jumping back" 150ms after tween ends + snapshot_buffer.clear() + target_visual_position = grid_to_world(current_position) + # Check for buffered input if movement_manager and movement_manager.has_method("_on_movement_finished"): movement_manager._on_movement_finished() @@ -1470,9 +1526,34 @@ static func reset_race_stats(): race_mgr.lap1_finishers = 0 race_mgr.lap2_finishers = 0 -@rpc("any_peer", "call_local", "unreliable") -func remote_set_position(authority_position): - # Don't snap directly, update target for interpolation +@rpc("any_peer", "call_remote", "unreliable") +func remote_set_position(authority_position: Vector3, timestamp: int = -1): + if timestamp == -1: + timestamp = Time.get_ticks_msec() + + # DISCARD STALE PACKETS: + # 1. If we've already seen a newer packet + if timestamp <= _last_received_timestamp: + return + + # 2. If this packet was sent before we finished our last authoritative movement step + # (Preventing "past" packets from overriding the "present" destination) + if timestamp <= _last_movement_finish_time: + return + + _last_received_timestamp = timestamp + + # Add to snapshot buffer + snapshot_buffer.append({"time": timestamp, "pos": authority_position}) + + # Ensure chronological order (in case of network jitter/reordering) + snapshot_buffer.sort_custom(func(a, b): return a.time < b.time) + + # Keep buffer size manageable + if snapshot_buffer.size() > 30: + snapshot_buffer.pop_front() + + # Update target visual position as fallback for simpler lerping target_visual_position = authority_position @rpc("any_peer", "call_local") diff --git a/scripts/managers/player_input_manager.gd b/scripts/managers/player_input_manager.gd index 3da2287..c023ce1 100644 --- a/scripts/managers/player_input_manager.gd +++ b/scripts/managers/player_input_manager.gd @@ -18,24 +18,20 @@ func _process(delta): return # Continuous movement input - var target_position = player.current_position + var move_vec = Vector2i.ZERO + if Input.is_action_pressed("move_north"): move_vec.y -= 1 + if Input.is_action_pressed("move_south"): move_vec.y += 1 + if Input.is_action_pressed("move_east"): move_vec.x += 1 + if Input.is_action_pressed("move_west"): move_vec.x -= 1 - if Input.is_action_pressed("move_north"): - target_position += Vector2i(0, -1) - elif Input.is_action_pressed("move_northeast"): - target_position += Vector2i(1, -1) - elif Input.is_action_pressed("move_east"): - target_position += Vector2i(1, 0) - elif Input.is_action_pressed("move_southeast"): - target_position += Vector2i(1, 1) - elif Input.is_action_pressed("move_south"): - target_position += Vector2i(0, 1) - elif Input.is_action_pressed("move_southwest"): - target_position += Vector2i(-1, 1) - elif Input.is_action_pressed("move_west"): - target_position += Vector2i(-1, 0) - elif Input.is_action_pressed("move_northwest"): - target_position += Vector2i(-1, -1) + # Fallback for explicit diagonal actions (if mapped) + if move_vec == Vector2i.ZERO: + if Input.is_action_pressed("move_northeast"): move_vec = Vector2i(1, -1) + elif Input.is_action_pressed("move_southeast"): move_vec = Vector2i(1, 1) + elif Input.is_action_pressed("move_southwest"): move_vec = Vector2i(-1, 1) + elif Input.is_action_pressed("move_northwest"): move_vec = Vector2i(-1, -1) + + # Action inputs (still momentary) if Input.is_action_just_pressed("action_grab"): @@ -43,7 +39,16 @@ func _process(delta): elif Input.is_action_just_pressed("action_put"): player.auto_put_item() - if target_position != player.current_position: + if move_vec != Vector2i.ZERO: + # Calculate target relative to intent (future position) to prevent zigzagging + var reference_pos = player.current_position + if movement_manager.is_moving: + if not movement_manager.movement_queue.is_empty(): + reference_pos = movement_manager.movement_queue[-1] + elif player.target_position != Vector2i(-1, -1): + reference_pos = player.target_position + + var target_position = reference_pos + move_vec movement_manager.simple_move_to(target_position) diff --git a/scripts/managers/player_movement_manager.gd b/scripts/managers/player_movement_manager.gd index 662e6d7..5111293 100644 --- a/scripts/managers/player_movement_manager.gd +++ b/scripts/managers/player_movement_manager.gd @@ -22,7 +22,7 @@ func initialize(p_player: Node3D, p_gridmap: Node): use_diagonal_movement = player.use_diagonal_movement signal movement_finished -var buffered_direction: Vector2i = Vector2i.ZERO +var movement_queue: Array[Vector2i] = [] # Queue of target grid positions var current_move_direction: Vector2i = Vector2i.ZERO func _process(delta): @@ -49,10 +49,17 @@ func _can_rpc() -> bool: func simple_move_to(grid_position: Vector2i) -> bool: if is_moving: - var direction = grid_position - player.current_position - if direction != current_move_direction: - buffer_move_input(direction) - return false + # Check if we are already moving to this position or if it's already queued + var current_target = player.target_position + if movement_queue.is_empty(): + if current_target == grid_position: + return false + elif movement_queue[-1] == grid_position: + return false + + if movement_queue.size() < 1: # Buffer at most 1 future move + movement_queue.append(grid_position) + return true if not player.is_multiplayer_authority(): print("[Move] Failed: Not authority for %s (Authority: %d, My Peer: %d)" % [player.name, player.get_multiplayer_authority(), player.multiplayer.get_unique_id()]) @@ -67,13 +74,15 @@ func simple_move_to(grid_position: Vector2i) -> bool: if main and main.goals_cycle_manager and not main.goals_cycle_manager.is_match_active: return false - var distance: int if use_diagonal_movement: distance = max(abs(grid_position.x - player.current_position.x), abs(grid_position.y - player.current_position.y)) else: distance = abs(grid_position.x - player.current_position.x) + abs(grid_position.y - player.current_position.y) + if distance > movement_range: + return false + if not enhanced_gridmap.is_position_valid(grid_position): # print("[Move] Failed: Position not valid on GridMap %s" % grid_position) return false @@ -130,11 +139,13 @@ func simple_move_to(grid_position: Vector2i) -> bool: current_move_direction = grid_position - player.current_position if player.is_multiplayer_authority(): + # Authority starts their own tween locally var is_bot = player.is_bot or player.is_in_group("Bots") + player.start_movement_along_path(path, not is_bot) + + # Authority sends RPC to others (call_remote) to start their tweens if _can_rpc(): player.rpc("start_movement_along_path", path, not is_bot) - else: - player.start_movement_along_path(path, not is_bot) return true @@ -250,14 +261,17 @@ func set_speed_multiplier(multiplier: float): # Let's check how 'start_movement_along_path' is implemented in player.gd. -func buffer_move_input(direction: Vector2i): - buffered_direction = direction func _on_movement_finished(): - if buffered_direction != Vector2i.ZERO: - var target = player.current_position + buffered_direction - buffered_direction = Vector2i.ZERO - simple_move_to(target) + if not movement_queue.is_empty(): + var next_target = movement_queue.pop_front() + # Use a small delay or call_deferred to avoid recursion issues, + # but keep it snappy by executing immediately if possible. + if not simple_move_to(next_target): + # If next move failed, clear queue and signal finished + movement_queue.clear() + current_move_direction = Vector2i.ZERO + emit_signal("movement_finished") else: current_move_direction = Vector2i.ZERO emit_signal("movement_finished")