Skip to content

Commit febac99

Browse files
committed
Snap all player positions instantly on scene transitions; always use server as source of truth for movement and sync.
1 parent ec19bb0 commit febac99

File tree

6 files changed

+93
-114
lines changed

6 files changed

+93
-114
lines changed

client/game-client/src/components/GameCanvas.jsx

Lines changed: 32 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ export default function GameCanvas() {
2121
const cameraPitchRef = useRef(0.5) // Vertical angle (pitch) - 0 to 1, 0.5 is middle
2222
const cameraDistanceRef = useRef(20) // For zoom
2323
const targetedEnemiesRef = useRef(new Set())
24-
const playerTargetPositionsRef = useRef({}) // Track target positions for interpolation
25-
const localPlayerPositionRef = useRef({ x: 0, y: 0.5, z: 0 }) // Local player ACTUAL position (no interpolation)
26-
const pendingMovementRef = useRef({ x: 0, z: 0 }) // Movement to apply this frame
2724
const socketRef = useRef(null) // Socket ref for animation loop access
25+
const sceneTransitionRef = useRef(false) // True when switching scenes - snap positions instead of interpolating
26+
27+
// SIMPLE APPROACH: All positions come from the store. Period.
28+
// The store is the single source of truth for ALL player positions.
2829

2930
const { socket, playerId, players, enemies, inHubWorld, inDungeon, weaponStats, targetedEnemies, damageNumbers, removeDamageNumber, panelCollapsed, chatBubbles } = useGameStore()
3031

@@ -94,8 +95,9 @@ export default function GameCanvas() {
9495

9596
const currentPlayerId = useGameStore.getState().playerId
9697
const currentSocket = socketRef.current
98+
const currentPlayers = useGameStore.getState().players
9799

98-
// Process local player movement FIRST (client-side prediction)
100+
// Process keyboard input and send to server
99101
const keys = keysRef.current
100102
let dx = 0, dz = 0
101103
const moveSpeed = 0.15
@@ -116,35 +118,22 @@ export default function GameCanvas() {
116118
const moveX = dx * Math.cos(angle) + dz * Math.sin(angle)
117119
const moveZ = -dx * Math.sin(angle) + dz * Math.cos(angle)
118120

119-
// Update local position IMMEDIATELY (no interpolation for self)
120-
localPlayerPositionRef.current.x += moveX * moveSpeed
121-
localPlayerPositionRef.current.z += moveZ * moveSpeed
122-
123-
// Send to server
121+
// Just send to server - server will update position and broadcast back
124122
currentSocket.emit('updatePlayerPosition', {
125123
x: moveX * moveSpeed,
126124
z: moveZ * moveSpeed
127125
})
128126
}
129127

130-
// Update local player mesh DIRECTLY (no interpolation = no jitter)
131-
if (currentPlayerId && playersRef.current[currentPlayerId]) {
132-
const localMesh = playersRef.current[currentPlayerId]
133-
localMesh.position.x = localPlayerPositionRef.current.x
134-
localMesh.position.y = localPlayerPositionRef.current.y
135-
localMesh.position.z = localPlayerPositionRef.current.z
136-
}
137-
138-
// Interpolate OTHER players (not self) toward their server positions
139-
const interpolationSpeed = 0.2
128+
// Update ALL player meshes from store positions (simple interpolation for smoothness)
129+
const interpolationSpeed = 0.3
140130
Object.keys(playersRef.current).forEach(id => {
141-
if (id === currentPlayerId) return // Skip local player
142131
const mesh = playersRef.current[id]
143-
const targetPos = playerTargetPositionsRef.current[id]
144-
if (mesh && targetPos) {
145-
mesh.position.x += (targetPos.x - mesh.position.x) * interpolationSpeed
146-
mesh.position.y += (targetPos.y - mesh.position.y) * interpolationSpeed
147-
mesh.position.z += (targetPos.z - mesh.position.z) * interpolationSpeed
132+
const playerData = currentPlayers[id]
133+
if (mesh && playerData?.position) {
134+
mesh.position.x += (playerData.position.x - mesh.position.x) * interpolationSpeed
135+
mesh.position.y += (playerData.position.y - mesh.position.y) * interpolationSpeed
136+
mesh.position.z += (playerData.position.z - mesh.position.z) * interpolationSpeed
148137
}
149138
})
150139

@@ -232,6 +221,10 @@ export default function GameCanvas() {
232221
}
233222

234223
loadSceneAsync()
224+
225+
// Mark that we're in a scene transition - positions should snap, not interpolate
226+
sceneTransitionRef.current = true
227+
console.log('[GameCanvas] Scene transition started - will snap positions')
235228
}, [inHubWorld, inDungeon])
236229

237230
// Handle pointer lock for camera control
@@ -342,7 +335,10 @@ export default function GameCanvas() {
342335
existingMesh.geometry.dispose()
343336
existingMesh.material.dispose()
344337
delete playersRef.current[id]
345-
delete playerTargetPositionsRef.current[id]
338+
} else if (player.position && sceneTransitionRef.current) {
339+
// During scene transitions, snap all positions immediately
340+
existingMesh.position.set(player.position.x, player.position.y, player.position.z)
341+
console.log(`[GameCanvas] Snapped player ${id} to position during scene transition`)
346342
}
347343
}
348344

@@ -357,72 +353,31 @@ export default function GameCanvas() {
357353
const mesh = new THREE.Mesh(geometry, material)
358354
mesh.castShadow = true
359355
mesh.userData.shape = shape
360-
mesh.userData.isPlayer = true // ← IMPORTANT: Mark as player so it's not cleared by SceneLoader!
361-
const initialPos = {
362-
x: player.position?.x || 0,
363-
y: player.position?.y || 0.5,
364-
z: player.position?.z || 0
365-
}
366-
mesh.position.set(initialPos.x, initialPos.y, initialPos.z)
356+
mesh.userData.isPlayer = true
367357

368-
// For local player, initialize our authoritative position ref
369-
if (id === currentPlayerId) {
370-
localPlayerPositionRef.current = { ...initialPos }
371-
}
372-
// For other players, initialize target for interpolation
373-
playerTargetPositionsRef.current[id] = { ...initialPos }
358+
// Set initial position from server data
359+
const pos = player.position || { x: 0, y: 0.5, z: 0 }
360+
mesh.position.set(pos.x, pos.y, pos.z)
374361

375362
scene.add(mesh)
376363
playersRef.current[id] = mesh
377364
console.log(`Created player mesh for ${id}:`, { shape, color: color.toString(16), position: player.position })
378-
} else {
379-
// Update positions based on server data
380-
if (player.position) {
381-
if (id === currentPlayerId) {
382-
// For local player: smoothly reconcile with server to prevent drift
383-
// Apply gradual correction toward server position
384-
const localPos = localPlayerPositionRef.current
385-
const serverPos = player.position
386-
const dx = serverPos.x - localPos.x
387-
const dz = serverPos.z - localPos.z
388-
const drift = Math.sqrt(dx * dx + dz * dz)
389-
390-
if (drift > 0.5) {
391-
// Large drift - snap immediately (teleport/desync)
392-
if (drift > 2) {
393-
localPlayerPositionRef.current = {
394-
x: serverPos.x,
395-
y: serverPos.y,
396-
z: serverPos.z
397-
}
398-
} else {
399-
// Medium drift - blend toward server position to correct over time
400-
const correction = 0.1 // 10% correction per update
401-
localPlayerPositionRef.current.x += dx * correction
402-
localPlayerPositionRef.current.z += dz * correction
403-
}
404-
}
405-
// Small drift (<0.5) - ignore, client prediction is close enough
406-
} else {
407-
// For other players, update their interpolation target
408-
playerTargetPositionsRef.current[id] = {
409-
x: player.position.x,
410-
y: player.position.y,
411-
z: player.position.z
412-
}
413-
}
414-
}
415365
}
416366
})
417367

368+
// Clear scene transition flag after processing all players
369+
if (sceneTransitionRef.current) {
370+
sceneTransitionRef.current = false
371+
console.log('[GameCanvas] Scene transition complete - resuming interpolation')
372+
}
373+
418374
// Remove disconnected players
419375
Object.keys(playersRef.current).forEach(id => {
420376
if (!players[id]) {
421377
scene.remove(playersRef.current[id])
422378
playersRef.current[id].geometry.dispose()
423379
playersRef.current[id].material.dispose()
424380
delete playersRef.current[id]
425-
delete playerTargetPositionsRef.current[id]
426381
}
427382
})
428383

client/game-client/src/hooks/useSocket.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,17 @@ export function useSocket(ticket, onTicketInvalid) {
156156
store.setTargetedEnemies([])
157157
store.clearDamageNumbers()
158158

159+
// Force position reset - this triggers GameCanvas to snap localPlayerPositionRef
160+
if (data.position) {
161+
const playerId = store.playerId
162+
if (playerId) {
163+
store.updatePlayer(playerId, { position: data.position })
164+
}
165+
// Set force position to trigger immediate snap in GameCanvas
166+
store.setForcePosition(data.position)
167+
}
168+
159169
// Server will send areaPlayers with updated hub state
160-
// and playerJoined for this player to others
161170
})
162171

163172
// MMO-style area state - receive all players in current area
@@ -167,7 +176,7 @@ export function useSocket(ticket, onTicketInvalid) {
167176
const playersMap = {}
168177
players.forEach(p => {
169178
playersMap[p.id] = p
170-
console.log('Player in area:', p.id, p.username, p.shape, p.color)
179+
console.log('Player in area:', p.id, p.username, 'position:', p.position)
171180
})
172181
store.setPlayers(playersMap)
173182
})

client/game-client/src/stores/gameStore.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export const useGameStore = create((set, get) => ({
4444
// UI state
4545
panelCollapsed: false,
4646

47+
// Force position reset (for scene transitions)
48+
forcePosition: null,
49+
4750
// Actions
4851
setSocket: (socket) => set({ socket }),
4952
setConnected: (connected) => set({ connected }),
@@ -71,6 +74,8 @@ export const useGameStore = create((set, get) => ({
7174

7275
setInHubWorld: (inHubWorld) => set({ inHubWorld, inDungeon: !inHubWorld }),
7376
setInDungeon: (inDungeon) => set({ inDungeon, inHubWorld: !inDungeon }),
77+
setForcePosition: (position) => set({ forcePosition: position }),
78+
clearForcePosition: () => set({ forcePosition: null }),
7479

7580
setEnemies: (enemies) => set({ enemies }),
7681
updateEnemy: (enemyId, data) => set((state) => ({

server/gameServer.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,16 +253,43 @@ const partyManager = {
253253
party.members.delete(socket)
254254
playerToParty.delete(socket)
255255

256-
// Return to hub world if leaving party
256+
// Return to hub world if leaving party (was in dungeon)
257257
if (roomId) {
258258
playerToRoom.delete(socket)
259259
hubWorldPlayers.add(socket)
260+
261+
// Restore saved hub position or use origin as fallback
262+
const player = playersInServer.get(socket.user.id)
263+
if (player) {
264+
if (player.savedHubPosition) {
265+
player.position = { ...player.savedHubPosition }
266+
console.log(`[PARTY] Restored hub position for ${socket.user.username}:`, player.position)
267+
delete player.savedHubPosition
268+
} else {
269+
player.position = { x: 0, y: 0.5, z: 0 }
270+
}
271+
}
272+
260273
socket.emit('returnToHubWorld', {
274+
position: player?.position,
261275
playersInHub: Array.from(hubWorldPlayers).map(s => ({
262276
id: s.user.id,
263277
username: s.user.username
264278
}))
265279
})
280+
281+
// Send full hub state to returning player
282+
playerMonitor.sendAreaState(socket)
283+
284+
// Broadcast to other hub players that this player joined
285+
const playerData = playerMonitor.getPlayerData(socket)
286+
if (playerData) {
287+
hubWorldPlayers.forEach(hubSocket => {
288+
if (hubSocket !== socket) {
289+
hubSocket.emit('playerJoined', playerData)
290+
}
291+
})
292+
}
266293
}
267294

268295
// If leader left, assign new leader or disband
@@ -358,7 +385,15 @@ const partyManager = {
358385
party.roomId = roomId
359386

360387
// BROADCAST: Tell hub players that these party members are leaving
388+
// Also save each player's hub position before they enter the dungeon
361389
party.members.forEach(socket => {
390+
// Save current hub position before entering dungeon
391+
const player = playersInServer.get(socket.user.id)
392+
if (player && player.position) {
393+
player.savedHubPosition = { ...player.position }
394+
console.log(`[DUNGEON] Saved hub position for ${socket.user.username}:`, player.savedHubPosition)
395+
}
396+
362397
// Tell all OTHER hub players this player is leaving
363398
hubWorldPlayers.forEach(hubSocket => {
364399
if (hubSocket !== socket) {

server/scenes/dungeon_corridor.json

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -225,38 +225,6 @@
225225
"count": 3,
226226
"radius": 4,
227227
"enabled": true
228-
},
229-
{
230-
"id": "spawn-group-3",
231-
"entityType": "enemy",
232-
"position": [10, 1, 0],
233-
"count": 3,
234-
"radius": 4,
235-
"enabled": true
236-
},
237-
{
238-
"id": "spawn-group-4",
239-
"entityType": "enemy",
240-
"position": [0, 1, 10],
241-
"count": 4,
242-
"radius": 4,
243-
"enabled": true
244-
},
245-
{
246-
"id": "spawn-group-5",
247-
"entityType": "enemy",
248-
"position": [-15, 1, 5],
249-
"count": 3,
250-
"radius": 2.5,
251-
"enabled": true
252-
},
253-
{
254-
"id": "spawn-group-6",
255-
"entityType": "enemy",
256-
"position": [15, 1, -5],
257-
"count": 3,
258-
"radius": 2.5,
259-
"enabled": true
260228
}
261229
]
262230
}

server/systems/combatSystem.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,10 +316,17 @@ class CombatSystem {
316316

317317
const player = playersInServer.get(member.user.id)
318318
if (player) {
319-
player.position = { x: 0, y: 0.5, z: 0 }
319+
// Restore saved hub position or use origin as fallback
320+
if (player.savedHubPosition) {
321+
player.position = { ...player.savedHubPosition }
322+
console.log(`[DUNGEON] Restored hub position for ${member.user.username}:`, player.position)
323+
delete player.savedHubPosition // Clean up
324+
} else {
325+
player.position = { x: 0, y: 0.5, z: 0 }
326+
}
320327
}
321328

322-
member.emit('returnToHubWorld', { cleared: true })
329+
member.emit('returnToHubWorld', { cleared: true, position: player?.position })
323330
playerMonitor?.sendAreaState(member)
324331

325332
const playerData = playerMonitor?.getPlayerData(member)

0 commit comments

Comments
 (0)