minecraft-renderer 0.1.92 → 0.1.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/minecraft-renderer.js +68 -68
  2. package/dist/minecraft-renderer.js.meta.json +1 -1
  3. package/dist/threeWorker.js +522 -522
  4. package/package.json +1 -1
  5. package/src/graphicsBackend/appViewer.ts +4 -3
  6. package/src/graphicsBackend/config.ts +1 -1
  7. package/src/graphicsBackend/types.ts +4 -1
  8. package/src/lib/worldrendererCommon.ts +3 -3
  9. package/src/three/entities.ts +329 -43
  10. package/src/three/entity/EntityMesh.ts +8 -3
  11. package/src/three/entity/animations.js +37 -25
  12. package/src/three/entity/animations.test.ts +249 -0
  13. package/src/three/entity/boatModelRotation.test.ts +29 -0
  14. package/src/three/entity/boatModelRotation.ts +14 -0
  15. package/src/three/entity/boatPaddleAnimation.test.ts +108 -0
  16. package/src/three/entity/boatPaddleAnimation.ts +104 -0
  17. package/src/three/entity/boatPassengerRendering.ts +20 -0
  18. package/src/three/entity/boatPassengerRotation.test.ts +166 -0
  19. package/src/three/entity/boatPassengerRotation.ts +71 -0
  20. package/src/three/entity/boatRenderSetup.test.ts +236 -0
  21. package/src/three/entity/boatRenderSetup.ts +214 -0
  22. package/src/three/entity/entityHeadPose.test.ts +15 -0
  23. package/src/three/entity/entityHeadPose.ts +22 -0
  24. package/src/three/entity/horseEntityScale.test.ts +11 -0
  25. package/src/three/entity/horseEntityScale.ts +9 -0
  26. package/src/three/entity/horseHeadAnimation.test.ts +159 -0
  27. package/src/three/entity/horseHeadAnimation.ts +151 -0
  28. package/src/three/entity/horsePassengerRendering.test.ts +77 -0
  29. package/src/three/entity/interpolationPolicy.test.ts +259 -0
  30. package/src/three/entity/interpolationPolicy.ts +187 -0
  31. package/src/three/entity/localHorseCameraYawLock.test.ts +72 -0
  32. package/src/three/entity/localVehicleRendering.integration.test.ts +247 -0
  33. package/src/three/entity/minecartLocalPassengerRendering.test.ts +208 -0
  34. package/src/three/entity/networkHeadPitchRendering.test.ts +102 -0
  35. package/src/three/entity/networkHeadPitchRendering.ts +44 -0
  36. package/src/three/entity/remoteBoatPassengerRotation.integration.test.ts +227 -0
  37. package/src/three/entity/remoteBoatPassengerRotation.ts +128 -0
  38. package/src/three/entity/vehiclePassengerProduction.test.ts +214 -0
  39. package/src/three/entity/vehiclePassengerRendering.ts +56 -0
  40. package/src/three/entity/vehiclePassengerUpdate.ts +82 -0
  41. package/src/three/graphicsBackendBase.ts +5 -5
  42. package/src/three/graphicsBackendOffThread.ts +2 -2
  43. package/src/three/worldRendererThree.ts +54 -27
@@ -0,0 +1,247 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+ import { expect, test, vi } from 'vitest'
4
+ import { SceneOrigin } from '../sceneOrigin'
5
+ import { anchorVehiclePassengerPosition, releaseVehiclePassengerPosition } from './vehiclePassengerRendering'
6
+ import {
7
+ ENTITY_TWEEN_DURATION_MS,
8
+ getBoatPassengerWorldPosition,
9
+ getEntityTweenDurationMs,
10
+ getLocalVehicleWorldPosition,
11
+ getMinecartPassengerWorldPosition,
12
+ usesCameraSyncedVehiclePosition
13
+ } from './interpolationPolicy'
14
+
15
+ type Vec3 = { x: number; y: number; z: number }
16
+
17
+ function staleOffsetVehiclePosition(cameraWorldPos: Vec3, vehiclePosition: Vec3, stalePassengerPosition: Vec3): Vec3 {
18
+ return {
19
+ x: cameraWorldPos.x + (vehiclePosition.x - stalePassengerPosition.x),
20
+ y: cameraWorldPos.y + (vehiclePosition.y - stalePassengerPosition.y),
21
+ z: cameraWorldPos.z + (vehiclePosition.z - stalePassengerPosition.z)
22
+ }
23
+ }
24
+
25
+ test('stale passenger snapshot does not shift rendered locally ridden vehicle X/Z relative to camera', () => {
26
+ const stalePassenger = { x: 0, y: 63, z: 0 }
27
+ const newVehicle = { x: 1, y: 63.2, z: 2 }
28
+ const cameraWorldPos = { x: 0.4, y: 63.5, z: 0.8 }
29
+
30
+ // 1. Vehicle position updates before passenger sync.
31
+ // 2. Renderer receives vehicle snapshot while passenger is still stale.
32
+ const broken = staleOffsetVehiclePosition(cameraWorldPos, newVehicle, stalePassenger)
33
+ const rendered = getLocalVehicleWorldPosition(cameraWorldPos, newVehicle.y)
34
+
35
+ expect(rendered.x).toBe(cameraWorldPos.x)
36
+ expect(rendered.z).toBe(cameraWorldPos.z)
37
+ expect(rendered.y).toBe(newVehicle.y)
38
+ expect(broken.x).not.toBe(cameraWorldPos.x)
39
+ expect(broken.z).not.toBe(cameraWorldPos.z)
40
+
41
+ // 3. Passenger synchronizes to vehicle.
42
+ const syncedPassenger = { ...newVehicle, y: 63 }
43
+ const syncedCamera = { x: 0.8, y: 63.5, z: 1.6 }
44
+
45
+ // 4. Boat X/Z remain fixed relative to the player/camera on intermediate frames.
46
+ const afterSync = getLocalVehicleWorldPosition(syncedCamera, newVehicle.y)
47
+ expect(afterSync.x - syncedCamera.x).toBe(0)
48
+ expect(afterSync.z - syncedCamera.z).toBe(0)
49
+ expect(afterSync.y).toBe(newVehicle.y)
50
+ expect(syncedPassenger.x).toBe(newVehicle.x)
51
+ expect(syncedPassenger.z).toBe(newVehicle.z)
52
+ })
53
+
54
+ test('render frames between press and release keep zero horizontal delta to camera for local boat', () => {
55
+ const vehicleY = 62.75
56
+ const frames = [
57
+ { x: 0, y: 63.4, z: 0 },
58
+ { x: 0.2, y: 63.4, z: 0 },
59
+ { x: 0.45, y: 63.4, z: 0 },
60
+ { x: 0.7, y: 63.4, z: 0 },
61
+ { x: 0.7, y: 63.4, z: 0 },
62
+ { x: 0.55, y: 63.4, z: 0 }
63
+ ]
64
+
65
+ for (const camera of frames) {
66
+ const boat = getLocalVehicleWorldPosition(camera, vehicleY)
67
+ expect(boat.x - camera.x).toBe(0)
68
+ expect(boat.z - camera.z).toBe(0)
69
+ expect(boat.y).toBe(vehicleY)
70
+ expect(boat.y).not.toBe(camera.y)
71
+ }
72
+ })
73
+
74
+ test('boat passenger positions use vanilla 1.17.1 riding and seat offsets', () => {
75
+ const boat = { x: 10, y: 64, z: 20 }
76
+
77
+ expect(getBoatPassengerWorldPosition(boat, 0, 0, 1)).toEqual({
78
+ x: 10,
79
+ y: 63.55,
80
+ z: 20
81
+ })
82
+ expect(getBoatPassengerWorldPosition(boat, 0, 0, 2)).toEqual({
83
+ x: 10,
84
+ y: 63.55,
85
+ z: 20.2
86
+ })
87
+ expect(getBoatPassengerWorldPosition(boat, 0, 1, 2)).toEqual({
88
+ x: 10,
89
+ y: 63.55,
90
+ z: 19.4
91
+ })
92
+
93
+ const rotated = getBoatPassengerWorldPosition(boat, Math.PI / 2, 0, 2)
94
+ expect(rotated.x).toBeCloseTo(9.8)
95
+ expect(rotated.y).toBe(63.55)
96
+ expect(rotated.z).toBeCloseTo(20)
97
+ })
98
+
99
+ test('remote player follows the tracked boat each frame and releases from an empty passenger list', () => {
100
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
101
+ sceneOrigin.update(96, 60, 192)
102
+
103
+ const boat = new THREE.Group()
104
+ sceneOrigin.track(boat)
105
+ boat.position.set(100, 64, 200)
106
+
107
+ const passenger = new THREE.Group()
108
+ const stopPositionTween = vi.fn()
109
+ passenger.userData._posTween = { stop: stopPositionTween }
110
+ sceneOrigin.track(passenger)
111
+ passenger.position.set(90, 64, 190)
112
+
113
+ const firstPassengerPosition = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1)
114
+ anchorVehiclePassengerPosition(passenger, firstPassengerPosition, '10')
115
+
116
+ expect(stopPositionTween).toHaveBeenCalledOnce()
117
+ expect(sceneOrigin.getWorldPosition(passenger)).toEqual({ x: 100, y: 63.55, z: 200 })
118
+ expect(passenger.userData._tweenTarget).toEqual({ x: 100, y: 63.55, z: 200 })
119
+
120
+ boat.position.set(102, 64.2, 203)
121
+ boat.rotation.y = Math.PI / 2
122
+ const movedPassengerPosition = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1)
123
+ anchorVehiclePassengerPosition(passenger, movedPassengerPosition, '10')
124
+ expect(sceneOrigin.getWorldPosition(passenger)?.x).toBe(102)
125
+ expect(sceneOrigin.getWorldPosition(passenger)?.y).toBeCloseTo(63.75)
126
+ expect(sceneOrigin.getWorldPosition(passenger)?.z).toBe(203)
127
+
128
+ sceneOrigin.update(112, 70, 208)
129
+ expect(sceneOrigin.getWorldPosition(passenger)?.x).toBe(102)
130
+ expect(sceneOrigin.getWorldPosition(passenger)?.y).toBeCloseTo(63.75)
131
+ expect(sceneOrigin.getWorldPosition(passenger)?.z).toBe(203)
132
+
133
+ const detachedWorldPosition = sceneOrigin.getWorldPosition(passenger)
134
+ expect(releaseVehiclePassengerPosition(passenger, '10', detachedWorldPosition)).toBe(true)
135
+ expect(passenger.userData._passengerVehicleId).toBeUndefined()
136
+ expect(passenger.userData._boatPassengerVehicleId).toBeUndefined()
137
+ expect(passenger.userData._tweenTarget).toEqual(detachedWorldPosition)
138
+
139
+ boat.position.set(110, 65, 210)
140
+ expect(sceneOrigin.getWorldPosition(passenger)).toEqual(detachedWorldPosition)
141
+ })
142
+
143
+ test('minecart passenger uses vanilla 1.17.1 riding Y offset', () => {
144
+ const minecart = { x: 10, y: 64, z: 20 }
145
+ expect(getMinecartPassengerWorldPosition(minecart)).toEqual({
146
+ x: 10,
147
+ y: 63.65,
148
+ z: 20
149
+ })
150
+ })
151
+
152
+ test('minecart passenger anchor follows vehicle world position without inheriting vehicle yaw', () => {
153
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
154
+ sceneOrigin.update(96, 60, 192)
155
+
156
+ const minecart = new THREE.Group()
157
+ sceneOrigin.track(minecart)
158
+ minecart.position.set(100, 64, 200)
159
+ minecart.rotation.y = Math.PI / 2
160
+
161
+ const passenger = new THREE.Group()
162
+ passenger.rotation.y = 1.25
163
+ sceneOrigin.track(passenger)
164
+ passenger.position.set(90, 64, 190)
165
+
166
+ const firstPosition = getMinecartPassengerWorldPosition(sceneOrigin.getWorldPosition(minecart)!)
167
+ anchorVehiclePassengerPosition(passenger, firstPosition, '42')
168
+ const anchoredYaw = passenger.rotation.y
169
+
170
+ minecart.position.set(103, 64.5, 205)
171
+ minecart.rotation.y = Math.PI
172
+ const movedPosition = getMinecartPassengerWorldPosition(sceneOrigin.getWorldPosition(minecart)!)
173
+ anchorVehiclePassengerPosition(passenger, movedPosition, '42')
174
+
175
+ expect(sceneOrigin.getWorldPosition(passenger)).toEqual({
176
+ x: 103,
177
+ y: 64.15,
178
+ z: 205
179
+ })
180
+ expect(passenger.rotation.y).toBe(anchoredYaw)
181
+
182
+ const detachedWorldPosition = sceneOrigin.getWorldPosition(passenger)
183
+ expect(releaseVehiclePassengerPosition(passenger, '42', detachedWorldPosition)).toBe(true)
184
+ expect(sceneOrigin.getWorldPosition(passenger)).toEqual(detachedWorldPosition)
185
+
186
+ minecart.position.set(120, 66, 220)
187
+ expect(sceneOrigin.getWorldPosition(passenger)).toEqual(detachedWorldPosition)
188
+ })
189
+
190
+ test('local minecart with localVehicle uses camera-synced positioning', () => {
191
+ expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true, passengerLayout: 'minecart' } })).toBe(true)
192
+ expect(getEntityTweenDurationMs({ renderHints: { localVehicle: true, passengerLayout: 'minecart' } }, false)).toBe(0)
193
+ })
194
+
195
+ test('remote minecart keeps ordinary entity interpolation', () => {
196
+ expect(usesCameraSyncedVehiclePosition({ renderHints: { passengerLayout: 'minecart' } })).toBe(false)
197
+ expect(getEntityTweenDurationMs({ renderHints: { passengerLayout: 'minecart' } }, false)).toBe(ENTITY_TWEEN_DURATION_MS)
198
+ })
199
+
200
+ test('local minecart X/Z stay aligned with camera on intermediate frames between server targets', () => {
201
+ const vehicleY = 63.7
202
+ const cameraFrames = [
203
+ { x: 0, y: 64.3, z: 0 },
204
+ { x: 1.2, y: 64.3, z: 0.6 },
205
+ { x: 5, y: 64.3, z: 3 },
206
+ { x: 4.1, y: 64.3, z: 2.5 }
207
+ ]
208
+
209
+ for (const camera of cameraFrames) {
210
+ const minecart = getLocalVehicleWorldPosition(camera, vehicleY)
211
+ expect(minecart.x - camera.x).toBeCloseTo(0, 5)
212
+ expect(minecart.z - camera.z).toBeCloseTo(0, 5)
213
+ expect(minecart.y).toBe(vehicleY)
214
+ }
215
+ })
216
+
217
+ test('local minecart passenger anchors to camera-synced vehicle position', () => {
218
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
219
+ sceneOrigin.update(96, 60, 192)
220
+
221
+ const minecart = new THREE.Group()
222
+ sceneOrigin.track(minecart)
223
+
224
+ const passenger = new THREE.Group()
225
+ sceneOrigin.track(passenger)
226
+
227
+ const vehicleY = 64
228
+ const cameraFrames = [
229
+ { x: 100, y: 64.7, z: 200 },
230
+ { x: 101.4, y: 64.7, z: 200.8 },
231
+ { x: 103, y: 64.7, z: 202 }
232
+ ]
233
+
234
+ for (const camera of cameraFrames) {
235
+ const syncedMinecart = getLocalVehicleWorldPosition(camera, vehicleY)
236
+ minecart.position.set(syncedMinecart.x, syncedMinecart.y, syncedMinecart.z)
237
+ const seatPosition = getMinecartPassengerWorldPosition(syncedMinecart)
238
+ anchorVehiclePassengerPosition(passenger, seatPosition, '42')
239
+ const passengerWorld = sceneOrigin.getWorldPosition(passenger)!
240
+
241
+ expect(syncedMinecart.x - camera.x).toBeCloseTo(0, 5)
242
+ expect(syncedMinecart.z - camera.z).toBeCloseTo(0, 5)
243
+ expect(passengerWorld.x).toBe(syncedMinecart.x)
244
+ expect(passengerWorld.z).toBe(syncedMinecart.z)
245
+ expect(passengerWorld.y).toBeCloseTo(63.65)
246
+ }
247
+ })
@@ -0,0 +1,208 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+ import { expect, test } from 'vitest'
4
+ import { SceneOrigin } from '../sceneOrigin'
5
+ import { anchorVehiclePassengerPosition, releaseVehiclePassengerPosition } from './vehiclePassengerRendering'
6
+ import { getBoatPassengerWorldPosition, getMinecartPassengerWorldPosition } from './interpolationPolicy'
7
+
8
+ const LOCAL_PLAYER_ID = 7
9
+ const REMOTE_PLAYER_ID = 8
10
+ const MINECART_ID = '42'
11
+ const EYE_HEIGHT = 1.62
12
+ const PLAYER_HEIGHT = 1.8
13
+
14
+ type PassengerLike = THREE.Group & {
15
+ playerObject: Record<string, unknown>
16
+ originalEntity: { id: number }
17
+ userData: Record<string, unknown>
18
+ }
19
+
20
+ function makePassenger(id: number): PassengerLike {
21
+ const passenger = new THREE.Group() as PassengerLike
22
+ passenger.playerObject = {}
23
+ passenger.originalEntity = { id }
24
+ passenger.userData = {}
25
+ return passenger
26
+ }
27
+
28
+ function resolveMinecartPassenger(
29
+ passengerId: number,
30
+ localPlayer: PassengerLike | null,
31
+ remotePlayers: Record<number, PassengerLike>
32
+ ): PassengerLike | undefined {
33
+ const isLocalPassenger = passengerId === localPlayer?.originalEntity.id
34
+ return isLocalPassenger ? (localPlayer ?? undefined) : remotePlayers[passengerId]
35
+ }
36
+
37
+ function anchorMinecartPassengers(
38
+ minecart: THREE.Group,
39
+ sceneOrigin: SceneOrigin,
40
+ passengerIds: number[],
41
+ localPlayer: PassengerLike | null,
42
+ remotePlayers: Record<number, PassengerLike>
43
+ ) {
44
+ const vehicleWorldPos = sceneOrigin.getWorldPosition(minecart)!
45
+ const seatPosition = getMinecartPassengerWorldPosition(vehicleWorldPos)
46
+ for (const passengerId of passengerIds) {
47
+ const passenger = resolveMinecartPassenger(passengerId, localPlayer, remotePlayers)
48
+ if (!passenger?.playerObject) continue
49
+ anchorVehiclePassengerPosition(passenger, seatPosition, MINECART_ID)
50
+ }
51
+ return seatPosition
52
+ }
53
+
54
+ test('local and remote minecart passengers share the same seat Y', () => {
55
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
56
+ sceneOrigin.update(96, 60, 192)
57
+
58
+ const minecart = new THREE.Group()
59
+ sceneOrigin.track(minecart)
60
+ minecart.position.set(100, 64, 200)
61
+
62
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
63
+ const remotePlayer = makePassenger(REMOTE_PLAYER_ID)
64
+ sceneOrigin.track(localPlayer)
65
+ sceneOrigin.track(remotePlayer)
66
+
67
+ const seatPosition = anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID, REMOTE_PLAYER_ID], localPlayer, { [REMOTE_PLAYER_ID]: remotePlayer })
68
+
69
+ const localWorld = sceneOrigin.getWorldPosition(localPlayer)
70
+ const remoteWorld = sceneOrigin.getWorldPosition(remotePlayer)
71
+ expect(localWorld?.y).toBe(seatPosition.y)
72
+ expect(remoteWorld?.y).toBe(seatPosition.y)
73
+ expect(localWorld).toEqual(remoteWorld)
74
+ })
75
+
76
+ test('local third-person player follows interpolated minecart world position', () => {
77
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
78
+ sceneOrigin.update(96, 60, 192)
79
+
80
+ const minecart = new THREE.Group()
81
+ sceneOrigin.track(minecart)
82
+ minecart.position.set(100, 64, 200)
83
+
84
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
85
+ sceneOrigin.track(localPlayer)
86
+
87
+ anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
88
+
89
+ minecart.position.set(103, 64.5, 205)
90
+ sceneOrigin.update(112, 70, 208)
91
+ const movedSeat = anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
92
+
93
+ expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(movedSeat)
94
+ expect(movedSeat.x).toBe(103)
95
+ expect(movedSeat.y).toBeCloseTo(64.15)
96
+ })
97
+
98
+ test('minecart seat places player waist-up inside the cart instead of on camera feet', () => {
99
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
100
+ sceneOrigin.update(96, 60, 192)
101
+
102
+ const minecart = new THREE.Group()
103
+ sceneOrigin.track(minecart)
104
+ minecart.position.set(100, 64, 200)
105
+
106
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
107
+ sceneOrigin.track(localPlayer)
108
+
109
+ const cameraWorldPos = { x: 100, y: 64 + 0.7 + EYE_HEIGHT, z: 200 }
110
+ localPlayer.position.set(cameraWorldPos.x, cameraWorldPos.y - EYE_HEIGHT, cameraWorldPos.z)
111
+
112
+ const seatPosition = anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
113
+ const anchoredWorld = sceneOrigin.getWorldPosition(localPlayer)!
114
+
115
+ const minecartWorld = sceneOrigin.getWorldPosition(minecart)!
116
+ const cameraFeetY = cameraWorldPos.y - EYE_HEIGHT
117
+
118
+ expect(anchoredWorld.y).toBeLessThan(cameraFeetY)
119
+ expect(anchoredWorld.y).toBe(seatPosition.y)
120
+ const waistY = anchoredWorld.y + PLAYER_HEIGHT * 0.5
121
+ expect(waistY).toBeGreaterThan(minecartWorld.y)
122
+ expect(waistY).toBeGreaterThan(minecartWorld.y + 0.3)
123
+ expect(waistY).toBeLessThan(minecartWorld.y + 0.85)
124
+ })
125
+
126
+ test('first and third person keep the same minecart passenger anchor', () => {
127
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
128
+ sceneOrigin.update(96, 60, 192)
129
+
130
+ const minecart = new THREE.Group()
131
+ sceneOrigin.track(minecart)
132
+ minecart.position.set(100, 64, 200)
133
+
134
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
135
+ sceneOrigin.track(localPlayer)
136
+
137
+ anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
138
+ const thirdPersonAnchor = { ...sceneOrigin.getWorldPosition(localPlayer)! }
139
+
140
+ localPlayer.visible = false
141
+ anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
142
+
143
+ expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(thirdPersonAnchor)
144
+ expect(localPlayer.userData._passengerVehicleId).toBe(MINECART_ID)
145
+ })
146
+
147
+ test('minecart dismount releases local player anchor without snapping back', () => {
148
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
149
+ sceneOrigin.update(96, 60, 192)
150
+
151
+ const minecart = new THREE.Group()
152
+ sceneOrigin.track(minecart)
153
+ minecart.position.set(100, 64, 200)
154
+
155
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
156
+ sceneOrigin.track(localPlayer)
157
+
158
+ anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
159
+ minecart.position.set(105, 65, 210)
160
+ sceneOrigin.update(112, 70, 208)
161
+ anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
162
+
163
+ const detachedWorldPosition = sceneOrigin.getWorldPosition(localPlayer)
164
+ expect(releaseVehiclePassengerPosition(localPlayer, MINECART_ID, detachedWorldPosition)).toBe(true)
165
+ expect(localPlayer.userData._passengerVehicleId).toBeUndefined()
166
+ expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(detachedWorldPosition)
167
+
168
+ minecart.position.set(120, 66, 220)
169
+ sceneOrigin.update(128, 76, 228)
170
+ expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(detachedWorldPosition)
171
+ })
172
+
173
+ function shouldAnchorPassenger(passenger: PassengerLike | null | undefined, layout: 'boat' | 'minecart' | 'horse', isLocalPassenger: boolean): boolean {
174
+ if (!passenger?.playerObject) return false
175
+ if (isLocalPassenger && layout === 'horse') return false
176
+ return true
177
+ }
178
+
179
+ test('local player is anchored for boat and minecart but skipped for horse', () => {
180
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
181
+ expect(shouldAnchorPassenger(localPlayer, 'boat', true)).toBe(true)
182
+ expect(shouldAnchorPassenger(localPlayer, 'minecart', true)).toBe(true)
183
+ expect(shouldAnchorPassenger(localPlayer, 'horse', true)).toBe(false)
184
+ })
185
+
186
+ test('local boat passenger uses the same anchor as remote passenger', () => {
187
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
188
+ sceneOrigin.update(96, 60, 192)
189
+
190
+ const boat = new THREE.Group()
191
+ sceneOrigin.track(boat)
192
+ boat.position.set(100, 64, 200)
193
+
194
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
195
+ const remotePlayer = makePassenger(REMOTE_PLAYER_ID)
196
+ sceneOrigin.track(localPlayer)
197
+ sceneOrigin.track(remotePlayer)
198
+
199
+ const boatSeat = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1)
200
+ if (shouldAnchorPassenger(localPlayer, 'boat', true)) {
201
+ anchorVehiclePassengerPosition(localPlayer, boatSeat, '10')
202
+ }
203
+ anchorVehiclePassengerPosition(remotePlayer, boatSeat, '10')
204
+
205
+ expect(localPlayer.userData._passengerVehicleId).toBe('10')
206
+ expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(sceneOrigin.getWorldPosition(remotePlayer))
207
+ expect(sceneOrigin.getWorldPosition(localPlayer)?.y).toBeCloseTo(63.55)
208
+ })
@@ -0,0 +1,102 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+ import { PlayerObject } from 'skinview3d'
4
+ import { expect, test } from 'vitest'
5
+ import type { PlayerObjectType } from '../../lib/createPlayerObject'
6
+ import { WalkingGeneralSwing } from './animations'
7
+ import { anchorVehiclePassengerPosition } from './vehiclePassengerRendering'
8
+ import { applyNetworkHeadPitch, storeNetworkHeadPitch, storeNetworkHeadYaw, type NetworkHeadRotationState } from './networkHeadPitchRendering'
9
+ import { getMinecartPassengerWorldPosition } from './interpolationPolicy'
10
+
11
+ function makePlayerObject(): PlayerObjectType {
12
+ const playerObject = new PlayerObject() as PlayerObjectType
13
+ playerObject.skin.head.rotation.set(0, 0, 0)
14
+ return playerObject
15
+ }
16
+
17
+ function simulateAnimationFrames(playerObject: PlayerObjectType, userData: NetworkHeadRotationState, frames: number, dt = 0.05) {
18
+ const animation = new WalkingGeneralSwing()
19
+ animation._captureDefaults(playerObject)
20
+ for (let i = 0; i < frames; i++) {
21
+ animation.update(playerObject, dt)
22
+ applyNetworkHeadPitch(playerObject, userData)
23
+ }
24
+ }
25
+
26
+ test('remote head pitch persists across animation frames', () => {
27
+ const playerObject = makePlayerObject()
28
+ const userData: NetworkHeadRotationState = {}
29
+ storeNetworkHeadPitch(userData, 0.6)
30
+
31
+ simulateAnimationFrames(playerObject, userData, 5)
32
+
33
+ expect(playerObject.skin.head.rotation.x).toBeCloseTo(-0.6)
34
+ expect(playerObject.skin.head.rotation.y).toBe(0)
35
+ })
36
+
37
+ test('remote head pitch updates when network pitch changes', () => {
38
+ const playerObject = makePlayerObject()
39
+ const userData: NetworkHeadRotationState = {}
40
+ storeNetworkHeadPitch(userData, 0.4)
41
+ simulateAnimationFrames(playerObject, userData, 2)
42
+ expect(playerObject.skin.head.rotation.x).toBeCloseTo(-0.4)
43
+
44
+ storeNetworkHeadPitch(userData, -0.2)
45
+ simulateAnimationFrames(playerObject, userData, 2)
46
+ expect(playerObject.skin.head.rotation.x).toBeCloseTo(0.2)
47
+ })
48
+
49
+ test('remote head pitch supports looking straight ahead', () => {
50
+ const playerObject = makePlayerObject()
51
+ const userData: NetworkHeadRotationState = {}
52
+ storeNetworkHeadPitch(userData, 0)
53
+ simulateAnimationFrames(playerObject, userData, 3)
54
+ expect(playerObject.skin.head.rotation.x).toBeCloseTo(0)
55
+ })
56
+
57
+ test('local player keeps camera pitch authoritative', () => {
58
+ const playerObject = makePlayerObject()
59
+ const userData: NetworkHeadRotationState = {}
60
+ storeNetworkHeadPitch(userData, 0.8)
61
+
62
+ const cameraPitch = -0.35
63
+ playerObject.skin.head.rotation.x = -cameraPitch
64
+
65
+ expect(playerObject.skin.head.rotation.x).toBeCloseTo(0.35)
66
+ })
67
+
68
+ test('minecart passenger anchor preserves head rotation', () => {
69
+ const playerObject = makePlayerObject()
70
+ const userData: NetworkHeadRotationState = {}
71
+ const passenger = new THREE.Group()
72
+ passenger.userData = userData
73
+
74
+ storeNetworkHeadPitch(userData, 0.45)
75
+ applyNetworkHeadPitch(playerObject, userData)
76
+
77
+ const seat = getMinecartPassengerWorldPosition({ x: 10, y: 64, z: 20 })
78
+ anchorVehiclePassengerPosition(passenger, seat, '42')
79
+ simulateAnimationFrames(playerObject, userData, 4)
80
+
81
+ expect(passenger.position.x).toBe(10)
82
+ expect(passenger.position.y).toBeCloseTo(63.65)
83
+ expect(playerObject.skin.head.rotation.x).toBeCloseTo(-0.45)
84
+ })
85
+
86
+ test('storeNetworkHeadYaw keeps last finite head yaw and ignores invalid updates', () => {
87
+ const userData: NetworkHeadRotationState = {}
88
+ storeNetworkHeadYaw(userData, 1.2)
89
+ expect(userData._networkHeadYaw).toBeCloseTo(1.2)
90
+
91
+ storeNetworkHeadYaw(userData, Number.NaN, undefined)
92
+ expect(userData._networkHeadYaw).toBeCloseTo(1.2)
93
+
94
+ storeNetworkHeadYaw(userData, undefined, 0.8)
95
+ expect(userData._networkHeadYaw).toBeCloseTo(0.8)
96
+ })
97
+
98
+ test('storeNetworkHeadYaw prefers explicit head yaw over fallback', () => {
99
+ const userData: NetworkHeadRotationState = {}
100
+ storeNetworkHeadYaw(userData, 0.5, 1.5)
101
+ expect(userData._networkHeadYaw).toBeCloseTo(0.5)
102
+ })
@@ -0,0 +1,44 @@
1
+ //@ts-nocheck
2
+ import type { PlayerObjectType } from '../../lib/createPlayerObject'
3
+
4
+ export type NetworkHeadRotationState = {
5
+ _networkHeadPitch?: number
6
+ _networkHeadYaw?: number
7
+ _remoteBoatRotationApplied?: boolean
8
+ }
9
+
10
+ /** @deprecated Use NetworkHeadRotationState */
11
+ export type NetworkHeadPitchState = NetworkHeadRotationState
12
+
13
+ export function storeNetworkHeadPitch(userData: NetworkHeadRotationState, pitch: unknown): void {
14
+ if (typeof pitch === 'number' && Number.isFinite(pitch)) {
15
+ userData._networkHeadPitch = pitch
16
+ }
17
+ }
18
+
19
+ export function storeNetworkHeadYaw(userData: NetworkHeadRotationState, headYaw: unknown, fallbackYaw?: unknown): void {
20
+ if (typeof headYaw === 'number' && Number.isFinite(headYaw)) {
21
+ userData._networkHeadYaw = headYaw
22
+ return
23
+ }
24
+ if (typeof fallbackYaw === 'number' && Number.isFinite(fallbackYaw)) {
25
+ userData._networkHeadYaw = fallbackYaw
26
+ }
27
+ }
28
+
29
+ export function getNetworkHeadPitch(userData: NetworkHeadRotationState): number {
30
+ const pitch = userData._networkHeadPitch
31
+ return typeof pitch === 'number' && Number.isFinite(pitch) ? pitch : 0
32
+ }
33
+
34
+ export function applyNetworkHeadPitch(playerObject: PlayerObjectType, userData: NetworkHeadRotationState): void {
35
+ const pitch = userData._networkHeadPitch
36
+ if (typeof pitch !== 'number' || !Number.isFinite(pitch)) return
37
+ playerObject.skin.head.rotation.y = 0
38
+ playerObject.skin.head.rotation.x = -pitch
39
+ }
40
+
41
+ export function restoreGenericRemotePlayerHeadRotation(playerObject: PlayerObjectType, userData: NetworkHeadRotationState): void {
42
+ playerObject.skin.head.rotation.y = 0
43
+ applyNetworkHeadPitch(playerObject, userData)
44
+ }