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,249 @@
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
+
8
+ const RIDING_LEG_X = -1.4137167
9
+ const VANILLA_RIDING_LEG_Y = Math.PI / 10
10
+ const VANILLA_RIDING_LEG_Z = Math.PI / 40
11
+ const SKINVIEW_RIDING_LEG_Y = -VANILLA_RIDING_LEG_Y
12
+ const SKINVIEW_RIDING_LEG_Z = -VANILLA_RIDING_LEG_Z
13
+ const RIDING_LEG_ORDER = 'ZYX'
14
+ const RIDING_ARM_DELTA = Math.PI / 5
15
+
16
+ const YZ_AXIS_FLIP = new THREE.Matrix4().makeScale(1, -1, -1)
17
+
18
+ function makePlayerObject(): PlayerObjectType {
19
+ const playerObject = new PlayerObject() as PlayerObjectType
20
+ playerObject.skin.leftLeg.rotation.set(0.1, 0.05, -0.02)
21
+ playerObject.skin.rightLeg.rotation.set(-0.08, -0.04, 0.03)
22
+ playerObject.skin.leftArm.rotation.set(0.2, 0, 0.01)
23
+ playerObject.skin.rightArm.rotation.set(-0.15, 0, -0.01)
24
+ return playerObject
25
+ }
26
+
27
+ function captureRotations(playerObject: PlayerObjectType) {
28
+ const { leftArm, rightArm, leftLeg, rightLeg } = playerObject.skin
29
+ return {
30
+ leftArm: leftArm.rotation.clone(),
31
+ rightArm: rightArm.rotation.clone(),
32
+ leftLeg: leftLeg.rotation.clone(),
33
+ rightLeg: rightLeg.rotation.clone()
34
+ }
35
+ }
36
+
37
+ function runAnimationFrame(animation: WalkingGeneralSwing, playerObject: PlayerObjectType, dt = 0.05) {
38
+ animation.update(playerObject, dt)
39
+ }
40
+
41
+ function makeJavaRidingLegMatrix(ySign: 1 | -1): THREE.Matrix4 {
42
+ return new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(RIDING_LEG_X, ySign * VANILLA_RIDING_LEG_Y, ySign * VANILLA_RIDING_LEG_Z, RIDING_LEG_ORDER))
43
+ }
44
+
45
+ function makeSkinviewRidingLegMatrix(y: number, z: number): THREE.Matrix4 {
46
+ return new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(RIDING_LEG_X, y, z, RIDING_LEG_ORDER))
47
+ }
48
+
49
+ function conjugateYzAxisFlip(rotationMatrix: THREE.Matrix4): THREE.Matrix4 {
50
+ return YZ_AXIS_FLIP.clone().multiply(rotationMatrix).multiply(YZ_AXIS_FLIP.clone())
51
+ }
52
+
53
+ function expectMatrixNear(a: THREE.Matrix4, b: THREE.Matrix4) {
54
+ for (let i = 0; i < 16; i++) {
55
+ expect(a.elements[i]).toBeCloseTo(b.elements[i])
56
+ }
57
+ }
58
+
59
+ function expectRidingLegPose(playerObject: PlayerObjectType) {
60
+ expect(playerObject.skin.rightLeg.rotation.order).toBe(RIDING_LEG_ORDER)
61
+ expect(playerObject.skin.rightLeg.rotation.x).toBeCloseTo(RIDING_LEG_X)
62
+ expect(playerObject.skin.rightLeg.rotation.y).toBeCloseTo(SKINVIEW_RIDING_LEG_Y)
63
+ expect(playerObject.skin.rightLeg.rotation.z).toBeCloseTo(SKINVIEW_RIDING_LEG_Z)
64
+
65
+ expect(playerObject.skin.leftLeg.rotation.order).toBe(RIDING_LEG_ORDER)
66
+ expect(playerObject.skin.leftLeg.rotation.x).toBeCloseTo(RIDING_LEG_X)
67
+ expect(playerObject.skin.leftLeg.rotation.y).toBeCloseTo(-SKINVIEW_RIDING_LEG_Y)
68
+ expect(playerObject.skin.leftLeg.rotation.z).toBeCloseTo(-SKINVIEW_RIDING_LEG_Z)
69
+ }
70
+
71
+ test('riding sets ZYX leg rotations with inverted Y/Z signs for skinview3d axes', () => {
72
+ const playerObject = makePlayerObject()
73
+ const animation = new WalkingGeneralSwing()
74
+ animation._captureDefaults(playerObject)
75
+ animation.isRiding = true
76
+
77
+ runAnimationFrame(animation, playerObject)
78
+
79
+ expectRidingLegPose(playerObject)
80
+ })
81
+
82
+ test('riding leg rotation matrix matches vanilla after Y/Z axis conversion', () => {
83
+ const javaRight = makeJavaRidingLegMatrix(1)
84
+ const skinviewRight = makeSkinviewRidingLegMatrix(SKINVIEW_RIDING_LEG_Y, SKINVIEW_RIDING_LEG_Z)
85
+ expectMatrixNear(conjugateYzAxisFlip(skinviewRight), javaRight)
86
+
87
+ const javaLeft = makeJavaRidingLegMatrix(-1)
88
+ const skinviewLeft = makeSkinviewRidingLegMatrix(-SKINVIEW_RIDING_LEG_Y, -SKINVIEW_RIDING_LEG_Z)
89
+ expectMatrixNear(conjugateYzAxisFlip(skinviewLeft), javaLeft)
90
+ })
91
+
92
+ test('animated riding legs match expected rotation matrices', () => {
93
+ const playerObject = makePlayerObject()
94
+ const animation = new WalkingGeneralSwing()
95
+ animation._captureDefaults(playerObject)
96
+ animation.isRiding = true
97
+ runAnimationFrame(animation, playerObject)
98
+
99
+ playerObject.skin.rightLeg.updateMatrix()
100
+ playerObject.skin.leftLeg.updateMatrix()
101
+
102
+ const rightRot = new THREE.Matrix4().extractRotation(playerObject.skin.rightLeg.matrix)
103
+ const leftRot = new THREE.Matrix4().extractRotation(playerObject.skin.leftLeg.matrix)
104
+
105
+ expectMatrixNear(rightRot, makeSkinviewRidingLegMatrix(SKINVIEW_RIDING_LEG_Y, SKINVIEW_RIDING_LEG_Z))
106
+ expectMatrixNear(leftRot, makeSkinviewRidingLegMatrix(-SKINVIEW_RIDING_LEG_Y, -SKINVIEW_RIDING_LEG_Z))
107
+ })
108
+
109
+ test('riding applies arm X delta relative to defaults', () => {
110
+ const playerObject = makePlayerObject()
111
+ const defaults = captureRotations(playerObject)
112
+ const animation = new WalkingGeneralSwing()
113
+ animation._captureDefaults(playerObject)
114
+ animation.isRiding = true
115
+
116
+ runAnimationFrame(animation, playerObject)
117
+
118
+ expect(playerObject.skin.rightArm.rotation.x).toBeCloseTo(defaults.rightArm.x - RIDING_ARM_DELTA)
119
+ expect(playerObject.skin.leftArm.rotation.x).toBeCloseTo(defaults.leftArm.x - RIDING_ARM_DELTA)
120
+ })
121
+
122
+ test('riding does not apply old arm Z spread', () => {
123
+ const playerObject = makePlayerObject()
124
+ const defaults = captureRotations(playerObject)
125
+ const animation = new WalkingGeneralSwing()
126
+ animation._captureDefaults(playerObject)
127
+ animation.isRiding = true
128
+
129
+ runAnimationFrame(animation, playerObject)
130
+
131
+ expect(playerObject.skin.leftArm.rotation.z).toBeCloseTo(defaults.leftArm.z)
132
+ expect(playerObject.skin.rightArm.rotation.z).toBeCloseTo(defaults.rightArm.z)
133
+ })
134
+
135
+ test('idle to riding to idle restores limb rotations including order', () => {
136
+ const playerObject = makePlayerObject()
137
+ const animation = new WalkingGeneralSwing()
138
+ animation._captureDefaults(playerObject)
139
+ const defaults = captureRotations(playerObject)
140
+
141
+ animation.isMoving = false
142
+ runAnimationFrame(animation, playerObject)
143
+ const idleRotations = captureRotations(playerObject)
144
+
145
+ animation.isRiding = true
146
+ runAnimationFrame(animation, playerObject)
147
+ expectRidingLegPose(playerObject)
148
+
149
+ animation.isRiding = false
150
+ animation.isMoving = false
151
+ runAnimationFrame(animation, playerObject)
152
+ const restored = captureRotations(playerObject)
153
+
154
+ expect(restored.leftArm.x).toBeCloseTo(idleRotations.leftArm.x)
155
+ expect(restored.leftArm.y).toBeCloseTo(idleRotations.leftArm.y)
156
+ expect(restored.leftArm.z).toBeCloseTo(idleRotations.leftArm.z)
157
+ expect(restored.leftArm.order).toBe(idleRotations.leftArm.order)
158
+ expect(restored.rightArm.x).toBeCloseTo(idleRotations.rightArm.x)
159
+ expect(restored.rightArm.y).toBeCloseTo(idleRotations.rightArm.y)
160
+ expect(restored.rightArm.z).toBeCloseTo(idleRotations.rightArm.z)
161
+ expect(restored.rightArm.order).toBe(idleRotations.rightArm.order)
162
+ expect(restored.leftLeg.x).toBeCloseTo(defaults.leftLeg.x)
163
+ expect(restored.leftLeg.y).toBeCloseTo(defaults.leftLeg.y)
164
+ expect(restored.leftLeg.z).toBeCloseTo(defaults.leftLeg.z)
165
+ expect(restored.leftLeg.order).toBe(defaults.leftLeg.order)
166
+ expect(restored.rightLeg.x).toBeCloseTo(defaults.rightLeg.x)
167
+ expect(restored.rightLeg.y).toBeCloseTo(defaults.rightLeg.y)
168
+ expect(restored.rightLeg.z).toBeCloseTo(defaults.rightLeg.z)
169
+ expect(restored.rightLeg.order).toBe(defaults.rightLeg.order)
170
+ })
171
+
172
+ test('multiple riding frames do not accumulate angles', () => {
173
+ const playerObject = makePlayerObject()
174
+ const animation = new WalkingGeneralSwing()
175
+ animation._captureDefaults(playerObject)
176
+ animation.isRiding = true
177
+
178
+ runAnimationFrame(animation, playerObject)
179
+ const firstFrame = captureRotations(playerObject)
180
+
181
+ for (let i = 0; i < 10; i++) {
182
+ runAnimationFrame(animation, playerObject)
183
+ }
184
+ const lastFrame = captureRotations(playerObject)
185
+
186
+ expect(lastFrame.leftLeg.x).toBeCloseTo(firstFrame.leftLeg.x)
187
+ expect(lastFrame.leftLeg.y).toBeCloseTo(firstFrame.leftLeg.y)
188
+ expect(lastFrame.leftLeg.z).toBeCloseTo(firstFrame.leftLeg.z)
189
+ expect(lastFrame.leftLeg.order).toBe(firstFrame.leftLeg.order)
190
+ expect(lastFrame.rightLeg.x).toBeCloseTo(firstFrame.rightLeg.x)
191
+ expect(lastFrame.rightLeg.y).toBeCloseTo(firstFrame.rightLeg.y)
192
+ expect(lastFrame.rightLeg.z).toBeCloseTo(firstFrame.rightLeg.z)
193
+ expect(lastFrame.rightLeg.order).toBe(firstFrame.rightLeg.order)
194
+ expect(lastFrame.leftArm.x).toBeCloseTo(firstFrame.leftArm.x)
195
+ expect(lastFrame.rightArm.x).toBeCloseTo(firstFrame.rightArm.x)
196
+ })
197
+
198
+ test('oneSwing during riding keeps isRiding enabled', () => {
199
+ const playerObject = makePlayerObject()
200
+ const animation = new WalkingGeneralSwing()
201
+ animation._captureDefaults(playerObject)
202
+ animation.isRiding = true
203
+
204
+ animation.swingArm()
205
+ runAnimationFrame(animation, playerObject)
206
+
207
+ expect(animation.isRiding).toBe(true)
208
+ })
209
+
210
+ test('oneSwing during riding keeps legs in riding pose', () => {
211
+ const playerObject = makePlayerObject()
212
+ const animation = new WalkingGeneralSwing()
213
+ animation._captureDefaults(playerObject)
214
+ animation.isRiding = true
215
+
216
+ animation.swingArm()
217
+ runAnimationFrame(animation, playerObject)
218
+
219
+ expectRidingLegPose(playerObject)
220
+ })
221
+
222
+ test('leg armor attached to legs follows riding Y/Z rotations', () => {
223
+ const playerObject = makePlayerObject()
224
+ const leftLegArmor = new THREE.Group()
225
+ const rightLegArmor = new THREE.Group()
226
+ playerObject.skin.leftLeg.add(leftLegArmor)
227
+ playerObject.skin.rightLeg.add(rightLegArmor)
228
+
229
+ const animation = new WalkingGeneralSwing()
230
+ animation._captureDefaults(playerObject)
231
+ animation.isRiding = true
232
+ runAnimationFrame(animation, playerObject)
233
+
234
+ expectRidingLegPose(playerObject)
235
+
236
+ leftLegArmor.updateMatrixWorld(true)
237
+ rightLegArmor.updateMatrixWorld(true)
238
+ const leftWorldQuat = new THREE.Quaternion()
239
+ const rightWorldQuat = new THREE.Quaternion()
240
+ leftLegArmor.getWorldQuaternion(leftWorldQuat)
241
+ rightLegArmor.getWorldQuaternion(rightWorldQuat)
242
+ const leftWorldEuler = new THREE.Euler().setFromQuaternion(leftWorldQuat, 'XYZ')
243
+ const rightWorldEuler = new THREE.Euler().setFromQuaternion(rightWorldQuat, 'XYZ')
244
+
245
+ expect(leftWorldEuler.y).not.toBeCloseTo(0)
246
+ expect(leftWorldEuler.z).not.toBeCloseTo(0)
247
+ expect(rightWorldEuler.y).not.toBeCloseTo(0)
248
+ expect(rightWorldEuler.z).not.toBeCloseTo(0)
249
+ })
@@ -0,0 +1,29 @@
1
+ //@ts-nocheck
2
+ import { expect, test } from 'vitest'
3
+ import { BOAT_MESH_YAW_OFFSET, getBoatMeshYawOffset, isBoatEntityName } from './boatModelRotation'
4
+
5
+ test('boat mesh yaw offset is -90°', () => {
6
+ expect(BOAT_MESH_YAW_OFFSET).toBe(-Math.PI / 2)
7
+ })
8
+
9
+ test.each([
10
+ 'boat',
11
+ 'chest_boat',
12
+ 'oak_boat',
13
+ 'spruce_boat',
14
+ 'birch_boat',
15
+ 'jungle_boat',
16
+ 'acacia_boat',
17
+ 'dark_oak_boat',
18
+ 'mangrove_boat',
19
+ 'cherry_boat',
20
+ 'bamboo_raft'
21
+ ])('detects boat entity %s', name => {
22
+ expect(isBoatEntityName(name)).toBe(true)
23
+ expect(getBoatMeshYawOffset(name)).toBe(BOAT_MESH_YAW_OFFSET)
24
+ })
25
+
26
+ test.each(['minecart', 'horse', 'player', 'item', 'oak_boat_with_chest'])('ignores non-boat entity %s', name => {
27
+ expect(isBoatEntityName(name)).toBe(false)
28
+ expect(getBoatMeshYawOffset(name)).toBeNull()
29
+ })
@@ -0,0 +1,14 @@
1
+ //@ts-nocheck
2
+ /** OBJ boat models face -X at yaw=0; physics forward is -Z → rotate mesh locally. */
3
+ export const BOAT_MESH_YAW_OFFSET = -Math.PI / 2
4
+
5
+ export function isBoatEntityName(entityName: string | undefined): boolean {
6
+ if (!entityName) return false
7
+ const name = entityName.toLowerCase()
8
+ if (name === 'boat' || name === 'chest_boat') return true
9
+ return name.endsWith('_boat') || name.endsWith('_raft')
10
+ }
11
+
12
+ export function getBoatMeshYawOffset(entityName: string | undefined): number | null {
13
+ return isBoatEntityName(entityName) ? BOAT_MESH_YAW_OFFSET : null
14
+ }
@@ -0,0 +1,108 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+ import { expect, test } from 'vitest'
4
+ import {
5
+ BOAT_PADDLE_RADIANS_PER_SECOND,
6
+ BOAT_PADDLE_STEP,
7
+ advanceBoatPaddlePhase,
8
+ createBoatPaddlePivotScratch,
9
+ getBoatPaddleRelativeQuaternion,
10
+ getVanillaBoatPaddleAngles,
11
+ syncBoatPaddleAnimationTargets,
12
+ updateBoatPaddleAnimationState,
13
+ createBoatPaddleAnimationState
14
+ } from './boatPaddleAnimation'
15
+
16
+ test('50ms active advancement equals pi/8', () => {
17
+ expect(advanceBoatPaddlePhase(0, true, 0.05)).toBeCloseTo(BOAT_PADDLE_STEP, 5)
18
+ })
19
+
20
+ test('equal elapsed time split across frame sizes gives the same phase', () => {
21
+ const total = 0.1
22
+ const single = advanceBoatPaddlePhase(0, true, total)
23
+ const split = advanceBoatPaddlePhase(advanceBoatPaddlePhase(0, true, total / 2), true, total / 2)
24
+ expect(split).toBeCloseTo(single, 5)
25
+ })
26
+
27
+ test('inactive paddle resets phase to zero', () => {
28
+ expect(advanceBoatPaddlePhase(Math.PI, false, 0.05)).toBe(0)
29
+ })
30
+
31
+ test('active phase remains within [0, 2pi)', () => {
32
+ let phase = 0
33
+ for (let i = 0; i < 200; i++) {
34
+ phase = advanceBoatPaddlePhase(phase, true, 0.016)
35
+ expect(phase).toBeGreaterThanOrEqual(0)
36
+ expect(phase).toBeLessThan(Math.PI * 2)
37
+ expect(Number.isFinite(phase)).toBe(true)
38
+ }
39
+ })
40
+
41
+ test('vanilla angles at phase 0, pi/4, and pi/2 match extracted formulas', () => {
42
+ const phases = [0, Math.PI / 4, Math.PI / 2] as const
43
+ for (const phase of phases) {
44
+ const angles = getVanillaBoatPaddleAngles(phase, 0)
45
+ const tX = (Math.sin(-phase) + 1) / 2
46
+ const tY = (Math.sin(-phase + 1) + 1) / 2
47
+ expect(angles.xRot).toBeCloseTo(-Math.PI / 3 + (-Math.PI / 12 - -Math.PI / 3) * tX, 5)
48
+ expect(angles.yRot).toBeCloseTo(-Math.PI / 4 + (Math.PI / 2) * tY, 5)
49
+ expect(angles.zRot).toBeCloseTo(Math.PI / 16, 5)
50
+ }
51
+ })
52
+
53
+ test('right-side Y is mirrored', () => {
54
+ const left = getVanillaBoatPaddleAngles(Math.PI / 3, 0)
55
+ const right = getVanillaBoatPaddleAngles(Math.PI / 3, 1)
56
+ expect(right.yRot).toBeCloseTo(Math.PI - left.yRot, 5)
57
+ expect(right.xRot).toBeCloseTo(left.xRot, 5)
58
+ })
59
+
60
+ test('relative quaternion at phase zero is identity', () => {
61
+ const scratch = createBoatPaddlePivotScratch()
62
+ const quat = getBoatPaddleRelativeQuaternion(0, 0, scratch)
63
+ expect(quat.x).toBeCloseTo(0, 5)
64
+ expect(quat.y).toBeCloseTo(0, 5)
65
+ expect(quat.z).toBeCloseTo(0, 5)
66
+ expect(quat.w).toBeCloseTo(1, 5)
67
+ })
68
+
69
+ test('returned phases and quaternions are finite', () => {
70
+ const scratch = createBoatPaddlePivotScratch()
71
+ const phase = advanceBoatPaddlePhase(0, true, 0.05)
72
+ const quat = getBoatPaddleRelativeQuaternion(phase, 1, scratch)
73
+ expect(Number.isFinite(phase)).toBe(true)
74
+ expect(Number.isFinite(quat.x)).toBe(true)
75
+ expect(Number.isFinite(quat.y)).toBe(true)
76
+ expect(Number.isFinite(quat.z)).toBe(true)
77
+ expect(Number.isFinite(quat.w)).toBe(true)
78
+ })
79
+
80
+ test('syncBoatPaddleAnimationTargets resets phase when activity changes', () => {
81
+ const state = createBoatPaddleAnimationState()
82
+ state.leftPhase = 1.2
83
+ state.leftActive = true
84
+
85
+ syncBoatPaddleAnimationTargets(state, false, false)
86
+ expect(state.leftActive).toBe(false)
87
+ expect(state.leftPhase).toBe(0)
88
+ })
89
+
90
+ test('updateBoatPaddleAnimationState advances active paddles and rotates pivots', () => {
91
+ const state = createBoatPaddleAnimationState()
92
+ state.leftActive = true
93
+ const leftPivot = new THREE.Object3D()
94
+ const scratch = createBoatPaddlePivotScratch()
95
+
96
+ updateBoatPaddleAnimationState(state, 0.05, leftPivot, undefined, scratch)
97
+ expect(state.leftPhase).toBeCloseTo(BOAT_PADDLE_STEP, 5)
98
+ expect(leftPivot.quaternion.w).not.toBeCloseTo(1, 3)
99
+ })
100
+
101
+ test('negative or non-finite dt is treated as zero advancement', () => {
102
+ expect(advanceBoatPaddlePhase(0.5, true, Number.NaN)).toBe(0.5)
103
+ expect(advanceBoatPaddlePhase(0.5, true, -1)).toBe(0.5)
104
+ })
105
+
106
+ test('boat paddle radians per second matches vanilla tick rate', () => {
107
+ expect(BOAT_PADDLE_RADIANS_PER_SECOND).toBeCloseTo((Math.PI / 8) * 20, 5)
108
+ })
@@ -0,0 +1,104 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+
4
+ export const BOAT_PADDLE_STEP = Math.PI / 8
5
+ export const BOAT_TICKS_PER_SECOND = 20
6
+ export const BOAT_PADDLE_RADIANS_PER_SECOND = BOAT_PADDLE_STEP * BOAT_TICKS_PER_SECOND
7
+
8
+ export type BoatPaddleAnimationState = {
9
+ leftActive: boolean
10
+ rightActive: boolean
11
+ leftPhase: number
12
+ rightPhase: number
13
+ }
14
+
15
+ export type BoatPaddlePivotScratch = {
16
+ current: THREE.Quaternion
17
+ rest: THREE.Quaternion
18
+ delta: THREE.Quaternion
19
+ euler: THREE.Euler
20
+ }
21
+
22
+ export function createBoatPaddlePivotScratch(): BoatPaddlePivotScratch {
23
+ return {
24
+ current: new THREE.Quaternion(),
25
+ rest: new THREE.Quaternion(),
26
+ delta: new THREE.Quaternion(),
27
+ euler: new THREE.Euler(0, 0, 0, 'ZYX')
28
+ }
29
+ }
30
+
31
+ export function createBoatPaddleAnimationState(): BoatPaddleAnimationState {
32
+ return {
33
+ leftActive: false,
34
+ rightActive: false,
35
+ leftPhase: 0,
36
+ rightPhase: 0
37
+ }
38
+ }
39
+
40
+ function lerp(a: number, b: number, t: number): number {
41
+ return a + (b - a) * t
42
+ }
43
+
44
+ export function advanceBoatPaddlePhase(phase: number, active: boolean, dt: number): number {
45
+ if (!active) return 0
46
+ const safeDt = Number.isFinite(dt) && dt >= 0 ? dt : 0
47
+ const twoPi = Math.PI * 2
48
+ return (phase + safeDt * BOAT_PADDLE_RADIANS_PER_SECOND) % twoPi
49
+ }
50
+
51
+ export function getVanillaBoatPaddleAngles(phase: number, side: 0 | 1): { xRot: number; yRot: number; zRot: number } {
52
+ const xRot = lerp(-Math.PI / 3, -Math.PI / 12, (Math.sin(-phase) + 1) / 2)
53
+ let yRot = lerp(-Math.PI / 4, Math.PI / 4, (Math.sin(-phase + 1) + 1) / 2)
54
+ if (side === 1) yRot = Math.PI - yRot
55
+ const zRot = Math.PI / 16
56
+ return { xRot, yRot, zRot }
57
+ }
58
+
59
+ function vanillaAnglesToQuaternion(angles: { xRot: number; yRot: number; zRot: number }, target: THREE.Quaternion, euler: THREE.Euler): THREE.Quaternion {
60
+ euler.set(-angles.xRot, angles.yRot, -angles.zRot, 'ZYX')
61
+ return target.setFromEuler(euler)
62
+ }
63
+
64
+ export function getBoatPaddleRelativeQuaternion(phase: number, side: 0 | 1, scratch: BoatPaddlePivotScratch): THREE.Quaternion {
65
+ if (phase === 0) {
66
+ return scratch.delta.identity()
67
+ }
68
+ vanillaAnglesToQuaternion(getVanillaBoatPaddleAngles(phase, side), scratch.current, scratch.euler)
69
+ vanillaAnglesToQuaternion(getVanillaBoatPaddleAngles(0, side), scratch.rest, scratch.euler)
70
+ return scratch.delta.copy(scratch.current).multiply(scratch.rest.invert())
71
+ }
72
+
73
+ export function applyBoatPaddlePivotRotation(pivot: THREE.Object3D, phase: number, side: 0 | 1, scratch: BoatPaddlePivotScratch): void {
74
+ pivot.quaternion.copy(getBoatPaddleRelativeQuaternion(phase, side, scratch))
75
+ }
76
+
77
+ export function syncBoatPaddleAnimationTargets(state: BoatPaddleAnimationState, leftActive: boolean, rightActive: boolean): void {
78
+ if (state.leftActive !== leftActive) {
79
+ state.leftActive = leftActive
80
+ state.leftPhase = 0
81
+ }
82
+ if (state.rightActive !== rightActive) {
83
+ state.rightActive = rightActive
84
+ state.rightPhase = 0
85
+ }
86
+ }
87
+
88
+ export function updateBoatPaddleAnimationState(
89
+ state: BoatPaddleAnimationState,
90
+ dt: number,
91
+ leftPivot: THREE.Object3D | undefined,
92
+ rightPivot: THREE.Object3D | undefined,
93
+ scratch: BoatPaddlePivotScratch
94
+ ): void {
95
+ state.leftPhase = advanceBoatPaddlePhase(state.leftPhase, state.leftActive, dt)
96
+ state.rightPhase = advanceBoatPaddlePhase(state.rightPhase, state.rightActive, dt)
97
+
98
+ if (leftPivot) {
99
+ applyBoatPaddlePivotRotation(leftPivot, state.leftPhase, 0, scratch)
100
+ }
101
+ if (rightPivot) {
102
+ applyBoatPaddlePivotRotation(rightPivot, state.rightPhase, 1, scratch)
103
+ }
104
+ }
@@ -0,0 +1,20 @@
1
+ //@ts-nocheck
2
+ import type { Vec3Like } from './interpolationPolicy'
3
+
4
+ export type VehiclePassengerRenderState = {
5
+ position: {
6
+ set: (x: number, y: number, z: number) => unknown
7
+ }
8
+ userData: {
9
+ _posTween?: { stop: () => unknown }
10
+ _tweenTarget?: Vec3Like
11
+ _passengerVehicleId?: string
12
+ /** @deprecated Use _passengerVehicleId */
13
+ _boatPassengerVehicleId?: string
14
+ }
15
+ }
16
+
17
+ export {
18
+ anchorVehiclePassengerPosition as anchorBoatPassengerPosition,
19
+ releaseVehiclePassengerPosition as releaseBoatPassengerPosition
20
+ } from './vehiclePassengerRendering'
@@ -0,0 +1,166 @@
1
+ //@ts-nocheck
2
+ import { expect, test } from 'vitest'
3
+ import {
4
+ BOAT_PASSENGER_MAX_HEAD_YAW_RAD,
5
+ clampBoatPassengerRelativeHeadYaw,
6
+ getClampedBoatPassengerYaw,
7
+ normalizeYaw,
8
+ normalizeYawDelta,
9
+ resolveBoatPassengerThirdPersonRotation,
10
+ shouldApplyBoatPassengerRotation,
11
+ shouldApplyBoatPassengerThirdPersonRotation
12
+ } from './boatPassengerRotation'
13
+
14
+ const BOAT_YAW = -0.5645049299419149
15
+
16
+ test('clamp relative head yaw at ±105° boundaries', () => {
17
+ expect(clampBoatPassengerRelativeHeadYaw(BOAT_PASSENGER_MAX_HEAD_YAW_RAD)).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
18
+ expect(clampBoatPassengerRelativeHeadYaw(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD)).toBeCloseTo(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
19
+ })
20
+
21
+ test('clamp relative head yaw beyond both boundaries', () => {
22
+ const over = BOAT_PASSENGER_MAX_HEAD_YAW_RAD + 0.5
23
+ const under = -BOAT_PASSENGER_MAX_HEAD_YAW_RAD - 0.5
24
+ expect(clampBoatPassengerRelativeHeadYaw(over)).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
25
+ expect(clampBoatPassengerRelativeHeadYaw(under)).toBeCloseTo(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
26
+ })
27
+
28
+ test('normalizeYawDelta handles π/-π seam', () => {
29
+ expect(normalizeYawDelta(Math.PI, -Math.PI)).toBeCloseTo(0)
30
+ expect(normalizeYawDelta(-Math.PI, Math.PI)).toBeCloseTo(0)
31
+ expect(normalizeYawDelta(0.1, 2 * Math.PI + 0.1)).toBeCloseTo(0)
32
+ })
33
+
34
+ test('normalizeYaw wraps 2π to 0', () => {
35
+ expect(normalizeYaw(2 * Math.PI)).toBeCloseTo(0)
36
+ expect(normalizeYaw(-2 * Math.PI)).toBeCloseTo(0)
37
+ })
38
+
39
+ test('getClampedBoatPassengerYaw clamps absolute look beyond 105° from boat', () => {
40
+ const requested = BOAT_YAW + 2.5
41
+ const clamped = getClampedBoatPassengerYaw(requested, BOAT_YAW)
42
+ expect(normalizeYawDelta(BOAT_YAW, clamped)).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD)
43
+ })
44
+
45
+ test('renderer and camera share the same effective yaw', () => {
46
+ const cameraYaw = BOAT_YAW + 1.9
47
+ const clampedLook = getClampedBoatPassengerYaw(cameraYaw, BOAT_YAW)
48
+ const resolved = resolveBoatPassengerThirdPersonRotation({
49
+ cameraYaw,
50
+ cameraPitch: 0.2,
51
+ vehicleYaw: BOAT_YAW
52
+ })
53
+ expect(resolved.effectiveCameraYaw).toBeCloseTo(clampedLook)
54
+ expect(normalizeYaw(BOAT_YAW + resolved.headYaw)).toBeCloseTo(clampedLook)
55
+ })
56
+
57
+ test('positive camera delta yields positive local head yaw', () => {
58
+ const cameraYaw = BOAT_YAW + 0.5
59
+ const resolved = resolveBoatPassengerThirdPersonRotation({
60
+ cameraYaw,
61
+ cameraPitch: 0,
62
+ vehicleYaw: BOAT_YAW
63
+ })
64
+ expect(resolved.headYaw).toBeGreaterThan(0)
65
+ expect(resolved.headYaw).toBeCloseTo(normalizeYawDelta(BOAT_YAW, cameraYaw))
66
+ })
67
+
68
+ test('head yaw sign follows normalize(cameraYaw - vehicleYaw) without inversion', () => {
69
+ const positive = resolveBoatPassengerThirdPersonRotation({
70
+ cameraYaw: BOAT_YAW + 0.3,
71
+ cameraPitch: 0,
72
+ vehicleYaw: BOAT_YAW
73
+ })
74
+ const negative = resolveBoatPassengerThirdPersonRotation({
75
+ cameraYaw: BOAT_YAW - 0.3,
76
+ cameraPitch: 0,
77
+ vehicleYaw: BOAT_YAW
78
+ })
79
+ expect(positive.headYaw).toBeCloseTo(0.3)
80
+ expect(negative.headYaw).toBeCloseTo(-0.3)
81
+ })
82
+
83
+ test('shouldApplyBoatPassengerRotation excludes minecart and horse without third-person requirement', () => {
84
+ const base = {
85
+ isAnchoredPassenger: true,
86
+ vehicleYaw: BOAT_YAW
87
+ }
88
+ expect(shouldApplyBoatPassengerRotation({ ...base, vehicleName: 'boat' })).toBe(true)
89
+ expect(shouldApplyBoatPassengerRotation({ ...base, vehicleName: 'minecart' })).toBe(false)
90
+ expect(shouldApplyBoatPassengerRotation({ ...base, vehicleName: 'horse' })).toBe(false)
91
+ })
92
+
93
+ test('shouldApplyBoatPassengerThirdPersonRotation excludes minecart and horse', () => {
94
+ const base = {
95
+ isThirdPerson: true,
96
+ isAnchoredPassenger: true,
97
+ vehicleYaw: BOAT_YAW
98
+ }
99
+ expect(shouldApplyBoatPassengerThirdPersonRotation({ ...base, vehicleName: 'boat' })).toBe(true)
100
+ expect(shouldApplyBoatPassengerThirdPersonRotation({ ...base, vehicleName: 'minecart' })).toBe(false)
101
+ expect(shouldApplyBoatPassengerThirdPersonRotation({ ...base, vehicleName: 'horse' })).toBe(false)
102
+ })
103
+
104
+ test('first mount frame without anchor uses camera fallback gate', () => {
105
+ expect(
106
+ shouldApplyBoatPassengerThirdPersonRotation({
107
+ isThirdPerson: true,
108
+ isAnchoredPassenger: false,
109
+ vehicleName: 'boat',
110
+ vehicleYaw: BOAT_YAW
111
+ })
112
+ ).toBe(false)
113
+ })
114
+
115
+ test('first dismount frame without anchor uses camera fallback gate', () => {
116
+ expect(
117
+ shouldApplyBoatPassengerThirdPersonRotation({
118
+ isThirdPerson: true,
119
+ isAnchoredPassenger: false,
120
+ vehicleName: undefined,
121
+ vehicleYaw: undefined
122
+ })
123
+ ).toBe(false)
124
+ })
125
+
126
+ test('non-finite or missing vehicle yaw disables boat branch', () => {
127
+ expect(
128
+ shouldApplyBoatPassengerThirdPersonRotation({
129
+ isThirdPerson: true,
130
+ isAnchoredPassenger: true,
131
+ vehicleName: 'boat',
132
+ vehicleYaw: Number.NaN
133
+ })
134
+ ).toBe(false)
135
+ expect(getClampedBoatPassengerYaw(1.2, Number.NaN)).toBe(1.2)
136
+ expect(getClampedBoatPassengerYaw(Number.NaN, BOAT_YAW)).toBeNaN()
137
+ })
138
+
139
+ test('acceptance identity: headYaw ≈ clamp(normalize(cameraYaw - boatYaw))', () => {
140
+ const cameraYaw = BOAT_YAW + 1.1
141
+ const resolved = resolveBoatPassengerThirdPersonRotation({
142
+ cameraYaw,
143
+ cameraPitch: -0.4,
144
+ vehicleYaw: BOAT_YAW
145
+ })
146
+ const expectedHead = clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, cameraYaw))
147
+ expect(resolved.bodyYaw).toBeCloseTo(BOAT_YAW)
148
+ expect(resolved.headYaw).toBeCloseTo(expectedHead)
149
+ expect(resolved.effectiveCameraYaw).toBeCloseTo(normalizeYaw(BOAT_YAW + expectedHead))
150
+ expect(resolved.headPitch).toBeCloseTo(0.4)
151
+ })
152
+
153
+ test('helmet head armor copies skin head yaw after boat passenger rotation', () => {
154
+ const resolved = resolveBoatPassengerThirdPersonRotation({
155
+ cameraYaw: BOAT_YAW + 0.45,
156
+ cameraPitch: -0.2,
157
+ vehicleYaw: BOAT_YAW
158
+ })
159
+ const skinHead = { x: resolved.headPitch, y: resolved.headYaw, z: 0 }
160
+ const helmet = { x: 0, y: 0, z: 0 }
161
+ helmet.x = -skinHead.x
162
+ helmet.y = skinHead.y
163
+ helmet.z = skinHead.z
164
+ expect(helmet.y).toBeCloseTo(resolved.headYaw)
165
+ expect(helmet.x).toBeCloseTo(-resolved.headPitch)
166
+ })