feat: Implement core player movement, input, and player scene setup, including grid-based movement, rotation, push mechanics, and multiplayer synchronization.

This commit is contained in:
Yogi Wiguna
2026-03-04 12:33:06 +08:00
parent 8b33ed3f56
commit b32565203f
3 changed files with 161 additions and 61 deletions
+110 -29
View File
@@ -1,4 +1,4 @@
extends Node3D extends CharacterBody3D
# Managers # Managers
var movement_manager var movement_manager
@@ -133,6 +133,10 @@ var finish_locations: Array:
get: return race_manager.finish_locations if race_manager else [] get: return race_manager.finish_locations if race_manager else []
var target_visual_position: Vector3 = Vector3.ZERO # For client-side smoothing 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 var spawn_point_selected = false
@@ -176,6 +180,11 @@ const AVAILABLE_CHARACTERS: Array[String] = ["Bob", "Masbro", "Gatot", "Oldpop"]
func _ready(): func _ready():
# Ensure name is set first (node name = authority ID for multiplayer identification) # 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). # 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 # Look up player's display name from LobbyManager
var my_id = get_multiplayer_authority() var my_id = get_multiplayer_authority()
@@ -769,14 +778,13 @@ func apply_slow_effect(duration: float = 3.0):
# Restore after duration # Restore after duration
# Note: If they stand in the zone, this will be re-applied constantly, resetting the visual # 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. # 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 # The persistent zone logic reapplies every frame, so we want this timer to be short
# OR we rely on the zone logic. # OR we rely on the zone logic.
# The RPC call says "apply_slow_effect(0.5)", so it expires quickly. # The RPC call says "apply_slow_effect(0.5)", so it expires quickly.
await get_tree().create_timer(duration).timeout
if movement_manager: if movement_manager:
movement_manager.set_speed_multiplier(1.0) movement_manager.set_speed_multiplier(1.0)
@@ -1020,18 +1028,10 @@ func _process(delta):
if can_rpc(): if can_rpc():
rpc("ping_existence") rpc("ping_existence")
else: else:
# Client-side visual smoothing # Client-side visual smoothing with snapshot interpolation
# Only interpolate if NOT running a movement tween, OR if the drift is large (teleport/snap) # Only interpolate if NOT running a movement tween
if not is_player_moving: if not is_player_moving:
var dist_sq = global_position.distance_squared_to(target_visual_position) _process_remote_interpolation(delta)
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)
# Delegate rotation to movement manager # Delegate rotation to movement manager
if movement_manager: if movement_manager:
@@ -1044,6 +1044,48 @@ func _process(delta):
immunity_timer = 0 immunity_timer = 0
_apply_tint_recursive(self , Color.WHITE) # Remove immunity tint _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") @rpc("any_peer", "call_local")
func ping_existence(): func ping_existence():
# This just lets other clients know this player exists # This just lets other clients know this player exists
@@ -1053,11 +1095,19 @@ func ping_existence():
var last_sent_position: Vector3 var last_sent_position: Vector3
func _physics_process(delta): func _physics_process(delta):
# Sync position periodically (Heartbeat / Smoothing)
if is_multiplayer_authority(): if is_multiplayer_authority():
if global_position.distance_squared_to(last_sent_position) > 0.001: # OPTIMIZATION: Only send smoothing updates if we ARE NOT currently mid-tween
if can_rpc(): # The start/end of paths are already synced via start_movement_along_path.
rpc("remote_set_position", global_position) # Sending packets during a tween just adds noise/jitter for the client.
last_sent_position = global_position 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 # 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): func move_player_to_clicked_position(grid_position: Vector2i):
movement_manager.move_to_clicked_position(grid_position) 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): 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]) print("[Player] %s starting move along path: %s" % [name, path])
# SERVER-SIDE VIOLATION CHECK (for Stop n Go) # SERVER-SIDE VIOLATION CHECK (for Stop n Go)
if multiplayer.is_server() and LobbyManager.game_mode == "Stop n Go": if multiplayer.is_server() and LobbyManager.game_mode == "Stop n Go":
var main = get_tree().root.get_node_or_null("Main") var main = get_tree().root.get_node_or_null("Main")
if main: if main:
var sng_manager = main.get_node_or_null("StopNGoManager") var sng_manager = main.get_node_or_null("StopNGoManager")
if sng_manager and sng_manager.has_method("check_movement_violation"): 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 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) 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: if path.size() > 0:
target_position = Vector2i(path[-1].x, path[-1].y) target_position = Vector2i(path[-1].x, path[-1].y)
# FORCE SNAP START: Ensure we start animating from our actual grid position # RELAXED SNAP START: Only snap if the visual node is far from where it should be (> 0.5 units)
# This prevents "jumps" if the visual node drifted or was set via global_position vs position mismatch # 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) 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 global_position = start_world_pos
if _movement_tween: if _movement_tween:
@@ -1330,7 +1382,7 @@ func start_movement_along_path(path: Array, clear_visual: bool = true):
is_player_moving = false is_player_moving = false
_movement_tween = null _movement_tween = null
target_position = Vector2i(-1, -1) 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]) print("[Player] %s finished move. %s -> %s" % [name, old_pos, current_position])
if is_carrying_tekton and is_instance_valid(carried_tekton): 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: if enhanced_gridmap:
enhanced_gridmap.clear_path_visualization() 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 # Check for buffered input
if movement_manager and movement_manager.has_method("_on_movement_finished"): if movement_manager and movement_manager.has_method("_on_movement_finished"):
movement_manager._on_movement_finished() movement_manager._on_movement_finished()
@@ -1470,9 +1526,34 @@ static func reset_race_stats():
race_mgr.lap1_finishers = 0 race_mgr.lap1_finishers = 0
race_mgr.lap2_finishers = 0 race_mgr.lap2_finishers = 0
@rpc("any_peer", "call_local", "unreliable") @rpc("any_peer", "call_remote", "unreliable")
func remote_set_position(authority_position): func remote_set_position(authority_position: Vector3, timestamp: int = -1):
# Don't snap directly, update target for interpolation 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 target_visual_position = authority_position
@rpc("any_peer", "call_local") @rpc("any_peer", "call_local")
+23 -18
View File
@@ -18,24 +18,20 @@ func _process(delta):
return return
# Continuous movement input # 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
# 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)
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)
# Action inputs (still momentary) # Action inputs (still momentary)
if Input.is_action_just_pressed("action_grab"): if Input.is_action_just_pressed("action_grab"):
@@ -43,7 +39,16 @@ func _process(delta):
elif Input.is_action_just_pressed("action_put"): elif Input.is_action_just_pressed("action_put"):
player.auto_put_item() 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) movement_manager.simple_move_to(target_position)
+28 -14
View File
@@ -22,7 +22,7 @@ func initialize(p_player: Node3D, p_gridmap: Node):
use_diagonal_movement = player.use_diagonal_movement use_diagonal_movement = player.use_diagonal_movement
signal movement_finished 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 var current_move_direction: Vector2i = Vector2i.ZERO
func _process(delta): func _process(delta):
@@ -49,10 +49,17 @@ func _can_rpc() -> bool:
func simple_move_to(grid_position: Vector2i) -> bool: func simple_move_to(grid_position: Vector2i) -> bool:
if is_moving: if is_moving:
var direction = grid_position - player.current_position # Check if we are already moving to this position or if it's already queued
if direction != current_move_direction: var current_target = player.target_position
buffer_move_input(direction) if movement_queue.is_empty():
return false 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(): 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()]) 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: if main and main.goals_cycle_manager and not main.goals_cycle_manager.is_match_active:
return false return false
var distance: int var distance: int
if use_diagonal_movement: if use_diagonal_movement:
distance = max(abs(grid_position.x - player.current_position.x), abs(grid_position.y - player.current_position.y)) distance = max(abs(grid_position.x - player.current_position.x), abs(grid_position.y - player.current_position.y))
else: else:
distance = abs(grid_position.x - player.current_position.x) + abs(grid_position.y - player.current_position.y) 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): if not enhanced_gridmap.is_position_valid(grid_position):
# print("[Move] Failed: Position not valid on GridMap %s" % grid_position) # print("[Move] Failed: Position not valid on GridMap %s" % grid_position)
return false return false
@@ -130,11 +139,13 @@ func simple_move_to(grid_position: Vector2i) -> bool:
current_move_direction = grid_position - player.current_position current_move_direction = grid_position - player.current_position
if player.is_multiplayer_authority(): if player.is_multiplayer_authority():
# Authority starts their own tween locally
var is_bot = player.is_bot or player.is_in_group("Bots") 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(): if _can_rpc():
player.rpc("start_movement_along_path", path, not is_bot) player.rpc("start_movement_along_path", path, not is_bot)
else:
player.start_movement_along_path(path, not is_bot)
return true 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. # 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(): func _on_movement_finished():
if buffered_direction != Vector2i.ZERO: if not movement_queue.is_empty():
var target = player.current_position + buffered_direction var next_target = movement_queue.pop_front()
buffered_direction = Vector2i.ZERO # Use a small delay or call_deferred to avoid recursion issues,
simple_move_to(target) # 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: else:
current_move_direction = Vector2i.ZERO current_move_direction = Vector2i.ZERO
emit_signal("movement_finished") emit_signal("movement_finished")