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
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")