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")
+23 -18
View File
@@ -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)
+28 -14
View File
@@ -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")