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
@@ -32,6 +32,7 @@ export class WalkingGeneralSwing extends PlayerAnimation {
32
32
  isRunning = false
33
33
  isMoving = true
34
34
  isCrouched = false
35
+ isRiding = false
35
36
 
36
37
  _dt = 0
37
38
  _phase = 0
@@ -127,7 +128,7 @@ export class WalkingGeneralSwing extends PlayerAnimation {
127
128
  if (!this._defaults) this._captureDefaults(player)
128
129
  this._applyDefaults(player)
129
130
 
130
- const targetMove = this.isMoving ? 1 : 0
131
+ const targetMove = this.isRiding ? 0 : this.isMoving ? 1 : 0
131
132
  const kMove = Math.min(1, dt * 20)
132
133
  this._moveBlend += (targetMove - this._moveBlend) * kMove
133
134
 
@@ -139,35 +140,46 @@ export class WalkingGeneralSwing extends PlayerAnimation {
139
140
 
140
141
  applyCrouchPose(player, this.isCrouched ? 1 : 0)
141
142
 
142
- const boundary = this.isRunning ? Math.cos(t) : Math.sin(t)
143
- if (Math.abs(boundary) < 0.02) {
144
- if (this.switchAnimationCallback) {
145
- reset = true
146
- }
147
- }
143
+ if (this.isRiding) {
144
+ player.skin.rightArm.rotation.x -= Math.PI / 5
145
+ player.skin.leftArm.rotation.x -= Math.PI / 5
148
146
 
149
- if (this.isRunning) {
150
- player.skin.leftLeg.rotation.x += Math.cos(t + Math.PI) * 1.3 * this._moveBlend
151
- player.skin.rightLeg.rotation.x += Math.cos(t) * 1.3 * this._moveBlend
147
+ // Vanilla HumanoidModel uses ModelPart Z→Y→X order with yRot/zRot signs as in Java.
148
+ // skinview3d Y/Z axes are opposite the Java model, so ZYX plus inverted Y/Z signs
149
+ // matches vanilla Rz × Ry × Rx (see BOAT_RIDING_LEG_SPREAD.md).
150
+ player.skin.rightLeg.rotation.set(-1.4137167, -Math.PI / 10, -Math.PI / 40, 'ZYX')
151
+ player.skin.leftLeg.rotation.set(-1.4137167, Math.PI / 10, Math.PI / 40, 'ZYX')
152
152
  } else {
153
- player.skin.leftLeg.rotation.x += Math.sin(t) * 0.5 * this._moveBlend
154
- player.skin.rightLeg.rotation.x += Math.sin(t + Math.PI) * 0.5 * this._moveBlend
155
- }
153
+ const boundary = this.isRunning ? Math.cos(t) : Math.sin(t)
154
+ if (Math.abs(boundary) < 0.02) {
155
+ if (this.switchAnimationCallback) {
156
+ reset = true
157
+ }
158
+ }
156
159
 
157
- if (this.isRunning) {
158
- player.skin.leftArm.rotation.x += Math.cos(t) * 1.5 * this._moveBlend
159
- player.skin.rightArm.rotation.x += Math.cos(t + Math.PI) * 1.5 * this._moveBlend
160
+ if (this.isRunning) {
161
+ player.skin.leftLeg.rotation.x += Math.cos(t + Math.PI) * 1.3 * this._moveBlend
162
+ player.skin.rightLeg.rotation.x += Math.cos(t) * 1.3 * this._moveBlend
163
+ } else {
164
+ player.skin.leftLeg.rotation.x += Math.sin(t) * 0.5 * this._moveBlend
165
+ player.skin.rightLeg.rotation.x += Math.sin(t + Math.PI) * 0.5 * this._moveBlend
166
+ }
160
167
 
161
- const basicArmRotationZ = Math.PI * 0.1
162
- player.skin.leftArm.rotation.z += (Math.cos(t) * 0.1 + basicArmRotationZ) * this._moveBlend
163
- player.skin.rightArm.rotation.z += (Math.cos(t + Math.PI) * 0.1 - basicArmRotationZ) * this._moveBlend
164
- } else {
165
- player.skin.leftArm.rotation.x += Math.sin(t + Math.PI) * 0.5 * this._moveBlend
166
- player.skin.rightArm.rotation.x += Math.sin(t) * 0.5 * this._moveBlend
168
+ if (this.isRunning) {
169
+ player.skin.leftArm.rotation.x += Math.cos(t) * 1.5 * this._moveBlend
170
+ player.skin.rightArm.rotation.x += Math.cos(t + Math.PI) * 1.5 * this._moveBlend
167
171
 
168
- const basicArmRotationZ = Math.PI * 0.02
169
- player.skin.leftArm.rotation.z += (Math.cos(t) * 0.03 + basicArmRotationZ) * this._moveBlend
170
- player.skin.rightArm.rotation.z += (Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ) * this._moveBlend
172
+ const basicArmRotationZ = Math.PI * 0.1
173
+ player.skin.leftArm.rotation.z += (Math.cos(t) * 0.1 + basicArmRotationZ) * this._moveBlend
174
+ player.skin.rightArm.rotation.z += (Math.cos(t + Math.PI) * 0.1 - basicArmRotationZ) * this._moveBlend
175
+ } else {
176
+ player.skin.leftArm.rotation.x += Math.sin(t + Math.PI) * 0.5 * this._moveBlend
177
+ player.skin.rightArm.rotation.x += Math.sin(t) * 0.5 * this._moveBlend
178
+
179
+ const basicArmRotationZ = Math.PI * 0.02
180
+ player.skin.leftArm.rotation.z += (Math.cos(t) * 0.03 + basicArmRotationZ) * this._moveBlend
181
+ player.skin.rightArm.rotation.z += (Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ) * this._moveBlend
182
+ }
171
183
  }
172
184
 
173
185
  if (this._swingTime !== null) {
@@ -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'