minecraft-renderer 0.1.93 → 0.1.95

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 (42) hide show
  1. package/dist/minecraft-renderer.js +64 -64
  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/types.ts +4 -1
  7. package/src/lib/worldrendererCommon.ts +2 -2
  8. package/src/three/entities.ts +303 -33
  9. package/src/three/entity/EntityMesh.ts +8 -3
  10. package/src/three/entity/animations.js +37 -25
  11. package/src/three/entity/animations.test.ts +249 -0
  12. package/src/three/entity/boatModelRotation.test.ts +29 -0
  13. package/src/three/entity/boatModelRotation.ts +14 -0
  14. package/src/three/entity/boatPaddleAnimation.test.ts +108 -0
  15. package/src/three/entity/boatPaddleAnimation.ts +104 -0
  16. package/src/three/entity/boatPassengerRendering.ts +20 -0
  17. package/src/three/entity/boatPassengerRotation.test.ts +166 -0
  18. package/src/three/entity/boatPassengerRotation.ts +71 -0
  19. package/src/three/entity/boatRenderSetup.test.ts +236 -0
  20. package/src/three/entity/boatRenderSetup.ts +214 -0
  21. package/src/three/entity/entityHeadPose.test.ts +15 -0
  22. package/src/three/entity/entityHeadPose.ts +22 -0
  23. package/src/three/entity/horseEntityScale.test.ts +11 -0
  24. package/src/three/entity/horseEntityScale.ts +9 -0
  25. package/src/three/entity/horseHeadAnimation.test.ts +159 -0
  26. package/src/three/entity/horseHeadAnimation.ts +151 -0
  27. package/src/three/entity/horsePassengerRendering.test.ts +77 -0
  28. package/src/three/entity/interpolationPolicy.test.ts +281 -0
  29. package/src/three/entity/interpolationPolicy.ts +242 -0
  30. package/src/three/entity/localHorseCameraYawLock.test.ts +72 -0
  31. package/src/three/entity/localVehicleRendering.integration.test.ts +267 -0
  32. package/src/three/entity/minecartLocalPassengerRendering.test.ts +210 -0
  33. package/src/three/entity/networkHeadPitchRendering.test.ts +102 -0
  34. package/src/three/entity/networkHeadPitchRendering.ts +44 -0
  35. package/src/three/entity/remoteBoatPassengerRotation.integration.test.ts +229 -0
  36. package/src/three/entity/remoteBoatPassengerRotation.ts +128 -0
  37. package/src/three/entity/vehiclePassengerProduction.test.ts +229 -0
  38. package/src/three/entity/vehiclePassengerRendering.ts +56 -0
  39. package/src/three/entity/vehiclePassengerUpdate.ts +91 -0
  40. package/src/three/graphicsBackendBase.ts +5 -5
  41. package/src/three/graphicsBackendOffThread.ts +2 -2
  42. package/src/three/worldRendererThree.ts +48 -22
@@ -0,0 +1,210 @@
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
+ const TEST_VERSION = '1.17.1'
14
+ const MINECART_HEIGHT = 0.7
15
+
16
+ type PassengerLike = THREE.Group & {
17
+ playerObject: Record<string, unknown>
18
+ originalEntity: { id: number }
19
+ userData: Record<string, unknown>
20
+ }
21
+
22
+ function makePassenger(id: number): PassengerLike {
23
+ const passenger = new THREE.Group() as PassengerLike
24
+ passenger.playerObject = {}
25
+ passenger.originalEntity = { id }
26
+ passenger.userData = {}
27
+ return passenger
28
+ }
29
+
30
+ function resolveMinecartPassenger(
31
+ passengerId: number,
32
+ localPlayer: PassengerLike | null,
33
+ remotePlayers: Record<number, PassengerLike>
34
+ ): PassengerLike | undefined {
35
+ const isLocalPassenger = passengerId === localPlayer?.originalEntity.id
36
+ return isLocalPassenger ? (localPlayer ?? undefined) : remotePlayers[passengerId]
37
+ }
38
+
39
+ function anchorMinecartPassengers(
40
+ minecart: THREE.Group,
41
+ sceneOrigin: SceneOrigin,
42
+ passengerIds: number[],
43
+ localPlayer: PassengerLike | null,
44
+ remotePlayers: Record<number, PassengerLike>
45
+ ) {
46
+ const vehicleWorldPos = sceneOrigin.getWorldPosition(minecart)!
47
+ const seatPosition = getMinecartPassengerWorldPosition(vehicleWorldPos, TEST_VERSION, 'minecart', MINECART_HEIGHT)
48
+ for (const passengerId of passengerIds) {
49
+ const passenger = resolveMinecartPassenger(passengerId, localPlayer, remotePlayers)
50
+ if (!passenger?.playerObject) continue
51
+ anchorVehiclePassengerPosition(passenger, seatPosition, MINECART_ID)
52
+ }
53
+ return seatPosition
54
+ }
55
+
56
+ test('local and remote minecart passengers share the same seat Y', () => {
57
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
58
+ sceneOrigin.update(96, 60, 192)
59
+
60
+ const minecart = new THREE.Group()
61
+ sceneOrigin.track(minecart)
62
+ minecart.position.set(100, 64, 200)
63
+
64
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
65
+ const remotePlayer = makePassenger(REMOTE_PLAYER_ID)
66
+ sceneOrigin.track(localPlayer)
67
+ sceneOrigin.track(remotePlayer)
68
+
69
+ const seatPosition = anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID, REMOTE_PLAYER_ID], localPlayer, { [REMOTE_PLAYER_ID]: remotePlayer })
70
+
71
+ const localWorld = sceneOrigin.getWorldPosition(localPlayer)
72
+ const remoteWorld = sceneOrigin.getWorldPosition(remotePlayer)
73
+ expect(localWorld?.y).toBe(seatPosition.y)
74
+ expect(remoteWorld?.y).toBe(seatPosition.y)
75
+ expect(localWorld).toEqual(remoteWorld)
76
+ })
77
+
78
+ test('local third-person player follows interpolated minecart world position', () => {
79
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
80
+ sceneOrigin.update(96, 60, 192)
81
+
82
+ const minecart = new THREE.Group()
83
+ sceneOrigin.track(minecart)
84
+ minecart.position.set(100, 64, 200)
85
+
86
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
87
+ sceneOrigin.track(localPlayer)
88
+
89
+ anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
90
+
91
+ minecart.position.set(103, 64.5, 205)
92
+ sceneOrigin.update(112, 70, 208)
93
+ const movedSeat = anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
94
+
95
+ expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(movedSeat)
96
+ expect(movedSeat.x).toBe(103)
97
+ expect(movedSeat.y).toBeCloseTo(64.15)
98
+ })
99
+
100
+ test('minecart seat places player waist-up inside the cart instead of on camera feet', () => {
101
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
102
+ sceneOrigin.update(96, 60, 192)
103
+
104
+ const minecart = new THREE.Group()
105
+ sceneOrigin.track(minecart)
106
+ minecart.position.set(100, 64, 200)
107
+
108
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
109
+ sceneOrigin.track(localPlayer)
110
+
111
+ const cameraWorldPos = { x: 100, y: 64 + 0.7 + EYE_HEIGHT, z: 200 }
112
+ localPlayer.position.set(cameraWorldPos.x, cameraWorldPos.y - EYE_HEIGHT, cameraWorldPos.z)
113
+
114
+ const seatPosition = anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
115
+ const anchoredWorld = sceneOrigin.getWorldPosition(localPlayer)!
116
+
117
+ const minecartWorld = sceneOrigin.getWorldPosition(minecart)!
118
+ const cameraFeetY = cameraWorldPos.y - EYE_HEIGHT
119
+
120
+ expect(anchoredWorld.y).toBeLessThan(cameraFeetY)
121
+ expect(anchoredWorld.y).toBe(seatPosition.y)
122
+ const waistY = anchoredWorld.y + PLAYER_HEIGHT * 0.5
123
+ expect(waistY).toBeGreaterThan(minecartWorld.y)
124
+ expect(waistY).toBeGreaterThan(minecartWorld.y + 0.3)
125
+ expect(waistY).toBeLessThan(minecartWorld.y + 0.85)
126
+ })
127
+
128
+ test('first and third person keep the same minecart passenger anchor', () => {
129
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
130
+ sceneOrigin.update(96, 60, 192)
131
+
132
+ const minecart = new THREE.Group()
133
+ sceneOrigin.track(minecart)
134
+ minecart.position.set(100, 64, 200)
135
+
136
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
137
+ sceneOrigin.track(localPlayer)
138
+
139
+ anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
140
+ const thirdPersonAnchor = { ...sceneOrigin.getWorldPosition(localPlayer)! }
141
+
142
+ localPlayer.visible = false
143
+ anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
144
+
145
+ expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(thirdPersonAnchor)
146
+ expect(localPlayer.userData._passengerVehicleId).toBe(MINECART_ID)
147
+ })
148
+
149
+ test('minecart dismount releases local player anchor without snapping back', () => {
150
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
151
+ sceneOrigin.update(96, 60, 192)
152
+
153
+ const minecart = new THREE.Group()
154
+ sceneOrigin.track(minecart)
155
+ minecart.position.set(100, 64, 200)
156
+
157
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
158
+ sceneOrigin.track(localPlayer)
159
+
160
+ anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
161
+ minecart.position.set(105, 65, 210)
162
+ sceneOrigin.update(112, 70, 208)
163
+ anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {})
164
+
165
+ const detachedWorldPosition = sceneOrigin.getWorldPosition(localPlayer)
166
+ expect(releaseVehiclePassengerPosition(localPlayer, MINECART_ID, detachedWorldPosition)).toBe(true)
167
+ expect(localPlayer.userData._passengerVehicleId).toBeUndefined()
168
+ expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(detachedWorldPosition)
169
+
170
+ minecart.position.set(120, 66, 220)
171
+ sceneOrigin.update(128, 76, 228)
172
+ expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(detachedWorldPosition)
173
+ })
174
+
175
+ function shouldAnchorPassenger(passenger: PassengerLike | null | undefined, layout: 'boat' | 'minecart' | 'horse', isLocalPassenger: boolean): boolean {
176
+ if (!passenger?.playerObject) return false
177
+ if (isLocalPassenger && layout === 'horse') return false
178
+ return true
179
+ }
180
+
181
+ test('local player is anchored for boat and minecart but skipped for horse', () => {
182
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
183
+ expect(shouldAnchorPassenger(localPlayer, 'boat', true)).toBe(true)
184
+ expect(shouldAnchorPassenger(localPlayer, 'minecart', true)).toBe(true)
185
+ expect(shouldAnchorPassenger(localPlayer, 'horse', true)).toBe(false)
186
+ })
187
+
188
+ test('local boat passenger uses the same anchor as remote passenger', () => {
189
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
190
+ sceneOrigin.update(96, 60, 192)
191
+
192
+ const boat = new THREE.Group()
193
+ sceneOrigin.track(boat)
194
+ boat.position.set(100, 64, 200)
195
+
196
+ const localPlayer = makePassenger(LOCAL_PLAYER_ID)
197
+ const remotePlayer = makePassenger(REMOTE_PLAYER_ID)
198
+ sceneOrigin.track(localPlayer)
199
+ sceneOrigin.track(remotePlayer)
200
+
201
+ const boatSeat = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1, TEST_VERSION, 'oak_boat', 0.5625)
202
+ if (shouldAnchorPassenger(localPlayer, 'boat', true)) {
203
+ anchorVehiclePassengerPosition(localPlayer, boatSeat, '10')
204
+ }
205
+ anchorVehiclePassengerPosition(remotePlayer, boatSeat, '10')
206
+
207
+ expect(localPlayer.userData._passengerVehicleId).toBe('10')
208
+ expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(sceneOrigin.getWorldPosition(remotePlayer))
209
+ expect(sceneOrigin.getWorldPosition(localPlayer)?.y).toBeCloseTo(63.55)
210
+ })
@@ -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 }, '1.17.1', 'minecart', 0.7)
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
+ }
@@ -0,0 +1,229 @@
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 { BOAT_PASSENGER_MAX_HEAD_YAW_RAD, clampBoatPassengerRelativeHeadYaw, normalizeYawDelta } from './boatPassengerRotation'
7
+ import { storeNetworkHeadPitch, storeNetworkHeadYaw } from './networkHeadPitchRendering'
8
+ import {
9
+ applyRemoteBoatPassengerRotation,
10
+ processRemoteBoatPassengerRotation,
11
+ processRemoteBoatPassengerRotations,
12
+ restoreGenericRemotePassengerRotation,
13
+ type RemoteBoatPassengerEntity,
14
+ type RemoteBoatPassengerVehicle
15
+ } from './remoteBoatPassengerRotation'
16
+ import { anchorVehiclePassengerPosition } from './vehiclePassengerRendering'
17
+ import { updateVehiclePassengerPositions } from './vehiclePassengerUpdate'
18
+ import { SceneOrigin } from '../sceneOrigin'
19
+
20
+ const BOAT_YAW = -0.5645049299419149
21
+ const REMOTE_PLAYER_A = 8
22
+ const REMOTE_PLAYER_B = 9
23
+ const BOAT_ID = 10
24
+ const TEST_VERSION = '1.17.1'
25
+
26
+ function makePlayerObject(): PlayerObjectType {
27
+ return new PlayerObject() as PlayerObjectType
28
+ }
29
+
30
+ function makeRemotePassenger(id: number, lookYaw = BOAT_YAW) {
31
+ const passenger = new THREE.Group() as RemoteBoatPassengerEntity & THREE.Group & { originalEntity: { id: number } }
32
+ passenger.playerObject = makePlayerObject()
33
+ passenger.originalEntity = { id }
34
+ passenger.userData = {}
35
+ passenger.visible = true
36
+ passenger.rotation.y = lookYaw
37
+ storeNetworkHeadYaw(passenger.userData, lookYaw)
38
+ return passenger
39
+ }
40
+
41
+ function makeBoat(passengerIds: number[], yaw = BOAT_YAW) {
42
+ const boat = new THREE.Group() as RemoteBoatPassengerVehicle & THREE.Group & { originalEntity: { id: number; name: string; height: number } }
43
+ boat.originalEntity = { id: BOAT_ID, name: 'oak_boat', height: 1.6 }
44
+ boat.userData = {
45
+ renderHints: {
46
+ passengerLayout: 'boat',
47
+ passengerIds
48
+ }
49
+ }
50
+ boat.realName = 'oak_boat'
51
+ boat.rotation.y = yaw
52
+ return boat
53
+ }
54
+
55
+ function createHarness(passengerIds: number[]) {
56
+ const sceneOrigin = new SceneOrigin(new THREE.Scene())
57
+ sceneOrigin.update(96, 60, 192)
58
+
59
+ const entities: Record<string, RemoteBoatPassengerEntity & THREE.Group & { originalEntity: { id: number } }> = {}
60
+ for (const id of passengerIds) {
61
+ const passenger = makeRemotePassenger(id)
62
+ sceneOrigin.track(passenger)
63
+ entities[String(id)] = passenger
64
+ }
65
+
66
+ const boat = makeBoat(passengerIds)
67
+ sceneOrigin.track(boat)
68
+ boat.position.set(100, 64, 200)
69
+ entities[String(BOAT_ID)] = boat
70
+
71
+ const runPassengerUpdate = () =>
72
+ updateVehiclePassengerPositions({
73
+ entities: entities as Parameters<typeof updateVehiclePassengerPositions>[0]['entities'],
74
+ localPlayer: null,
75
+ getWorldPosition: target => sceneOrigin.getWorldPosition(target as THREE.Object3D),
76
+ version: TEST_VERSION
77
+ })
78
+
79
+ const runRemoteRotation = () =>
80
+ processRemoteBoatPassengerRotations({
81
+ entities,
82
+ syncArmor: () => undefined
83
+ })
84
+
85
+ return { sceneOrigin, entities, boat, runPassengerUpdate, runRemoteRotation }
86
+ }
87
+
88
+ test('remote anchored boat passenger uses boat yaw for body and relative head yaw', () => {
89
+ const lookYaw = BOAT_YAW + 0.6
90
+ const { entities, boat, runPassengerUpdate, runRemoteRotation } = createHarness([REMOTE_PLAYER_A])
91
+ const passenger = entities[String(REMOTE_PLAYER_A)]!
92
+ storeNetworkHeadYaw(passenger.userData, lookYaw)
93
+ passenger.rotation.y = lookYaw
94
+
95
+ runPassengerUpdate()
96
+ runRemoteRotation()
97
+
98
+ expect(passenger.userData._remoteBoatRotationApplied).toBe(true)
99
+ expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW)
100
+ expect(passenger.playerObject!.skin.head.rotation.y).toBeCloseTo(clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookYaw)))
101
+ expect(boat.rotation.y).toBeCloseTo(BOAT_YAW)
102
+ })
103
+
104
+ test('remote boat passenger head yaw updates when boat turns without new look packet', () => {
105
+ const lookYaw = BOAT_YAW + 0.4
106
+ const { entities, boat, runPassengerUpdate, runRemoteRotation } = createHarness([REMOTE_PLAYER_A])
107
+ const passenger = entities[String(REMOTE_PLAYER_A)]!
108
+ storeNetworkHeadYaw(passenger.userData, lookYaw)
109
+
110
+ runPassengerUpdate()
111
+ runRemoteRotation()
112
+ expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW)
113
+
114
+ boat.rotation.y = BOAT_YAW + 0.7
115
+ runPassengerUpdate()
116
+ runRemoteRotation()
117
+
118
+ expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW + 0.7)
119
+ expect(passenger.playerObject!.skin.head.rotation.y).toBeCloseTo(clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW + 0.7, lookYaw)))
120
+ })
121
+
122
+ test('two remote passengers keep independent head yaw on the same boat', () => {
123
+ const lookA = BOAT_YAW + 0.3
124
+ const lookB = BOAT_YAW - 0.5
125
+ const { entities, runPassengerUpdate, runRemoteRotation } = createHarness([REMOTE_PLAYER_A, REMOTE_PLAYER_B])
126
+ const passengerA = entities[String(REMOTE_PLAYER_A)]!
127
+ const passengerB = entities[String(REMOTE_PLAYER_B)]!
128
+
129
+ storeNetworkHeadYaw(passengerA.userData, lookA)
130
+ storeNetworkHeadYaw(passengerB.userData, lookB)
131
+
132
+ runPassengerUpdate()
133
+ runRemoteRotation()
134
+
135
+ expect(passengerA.playerObject!.skin.head.rotation.y).toBeCloseTo(clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookA)))
136
+ expect(passengerB.playerObject!.skin.head.rotation.y).toBeCloseTo(clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookB)))
137
+ })
138
+
139
+ test('dismount restores generic remote body yaw and zero head yaw', () => {
140
+ const lookYaw = BOAT_YAW + 0.55
141
+ const { entities, boat, runPassengerUpdate, runRemoteRotation } = createHarness([REMOTE_PLAYER_A])
142
+ const passenger = entities[String(REMOTE_PLAYER_A)]!
143
+ storeNetworkHeadYaw(passenger.userData, lookYaw)
144
+ storeNetworkHeadPitch(passenger.userData, 0.25)
145
+
146
+ runPassengerUpdate()
147
+ runRemoteRotation()
148
+ expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW)
149
+ expect(passenger.userData._remoteBoatRotationApplied).toBe(true)
150
+
151
+ boat.userData.renderHints = { passengerLayout: 'boat', passengerIds: [] }
152
+ runPassengerUpdate()
153
+ runRemoteRotation()
154
+
155
+ expect(passenger.userData._remoteBoatRotationApplied).toBe(false)
156
+ expect(passenger.rotation.y).toBeCloseTo(lookYaw)
157
+ expect(passenger.playerObject!.skin.head.rotation.y).toBe(0)
158
+ expect(passenger.playerObject!.skin.head.rotation.x).toBeCloseTo(-0.25)
159
+ })
160
+
161
+ test('missing network yaw on first frame keeps generic pose until look arrives', () => {
162
+ const passenger = makeRemotePassenger(REMOTE_PLAYER_A, BOAT_YAW + 0.9)
163
+ delete passenger.userData._networkHeadYaw
164
+ const boat = makeBoat([REMOTE_PLAYER_A])
165
+ anchorVehiclePassengerPosition(passenger, { x: 0, y: 0, z: 0 }, String(BOAT_ID))
166
+
167
+ processRemoteBoatPassengerRotation({
168
+ passenger,
169
+ vehicle: boat,
170
+ syncArmor: () => undefined
171
+ })
172
+
173
+ expect(passenger.userData._remoteBoatRotationApplied).toBeUndefined()
174
+ expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW + 0.9)
175
+ expect(passenger.playerObject!.skin.head.rotation.y).toBe(0)
176
+ })
177
+
178
+ test('restoreGenericRemotePassengerRotation uses stored network yaw', () => {
179
+ const lookYaw = BOAT_YAW + 1.1
180
+ const passenger = makeRemotePassenger(REMOTE_PLAYER_A, BOAT_YAW)
181
+ storeNetworkHeadYaw(passenger.userData, lookYaw)
182
+ storeNetworkHeadPitch(passenger.userData, -0.15)
183
+ passenger.userData._remoteBoatRotationApplied = true
184
+
185
+ restoreGenericRemotePassengerRotation(passenger)
186
+
187
+ expect(passenger.rotation.y).toBeCloseTo(lookYaw)
188
+ expect(passenger.playerObject!.skin.head.rotation.y).toBe(0)
189
+ expect(passenger.playerObject!.skin.head.rotation.x).toBeCloseTo(0.15)
190
+ expect(passenger.userData._remoteBoatRotationApplied).toBe(false)
191
+ })
192
+
193
+ test('applyRemoteBoatPassengerRotation stops active rotation tween', () => {
194
+ const lookYaw = BOAT_YAW + 0.2
195
+ const passenger = makeRemotePassenger(REMOTE_PLAYER_A, lookYaw)
196
+ const boat = makeBoat([REMOTE_PLAYER_A])
197
+ let tweenStopped = false
198
+ passenger.userData._rotTween = {
199
+ stop: () => {
200
+ tweenStopped = true
201
+ }
202
+ }
203
+
204
+ applyRemoteBoatPassengerRotation({
205
+ passenger,
206
+ vehicle: boat,
207
+ vehicleYaw: BOAT_YAW,
208
+ networkHeadYaw: lookYaw
209
+ })
210
+
211
+ expect(tweenStopped).toBe(true)
212
+ expect(passenger.userData._rotTween).toBeUndefined()
213
+ })
214
+
215
+ test('remote boat rotation clamps beyond ±105°', () => {
216
+ const lookYaw = BOAT_YAW + 2.0
217
+ const passenger = makeRemotePassenger(REMOTE_PLAYER_A, lookYaw)
218
+ const boat = makeBoat([REMOTE_PLAYER_A])
219
+ storeNetworkHeadYaw(passenger.userData, lookYaw)
220
+
221
+ applyRemoteBoatPassengerRotation({
222
+ passenger,
223
+ vehicle: boat,
224
+ vehicleYaw: BOAT_YAW,
225
+ networkHeadYaw: lookYaw
226
+ })
227
+
228
+ expect(passenger.playerObject!.skin.head.rotation.y).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
229
+ })