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.
- package/dist/minecraft-renderer.js +64 -64
- package/dist/minecraft-renderer.js.meta.json +1 -1
- package/dist/threeWorker.js +522 -522
- package/package.json +1 -1
- package/src/graphicsBackend/appViewer.ts +4 -3
- package/src/graphicsBackend/types.ts +4 -1
- package/src/lib/worldrendererCommon.ts +2 -2
- package/src/three/entities.ts +303 -33
- package/src/three/entity/EntityMesh.ts +8 -3
- package/src/three/entity/animations.js +37 -25
- package/src/three/entity/animations.test.ts +249 -0
- package/src/three/entity/boatModelRotation.test.ts +29 -0
- package/src/three/entity/boatModelRotation.ts +14 -0
- package/src/three/entity/boatPaddleAnimation.test.ts +108 -0
- package/src/three/entity/boatPaddleAnimation.ts +104 -0
- package/src/three/entity/boatPassengerRendering.ts +20 -0
- package/src/three/entity/boatPassengerRotation.test.ts +166 -0
- package/src/three/entity/boatPassengerRotation.ts +71 -0
- package/src/three/entity/boatRenderSetup.test.ts +236 -0
- package/src/three/entity/boatRenderSetup.ts +214 -0
- package/src/three/entity/entityHeadPose.test.ts +15 -0
- package/src/three/entity/entityHeadPose.ts +22 -0
- package/src/three/entity/horseEntityScale.test.ts +11 -0
- package/src/three/entity/horseEntityScale.ts +9 -0
- package/src/three/entity/horseHeadAnimation.test.ts +159 -0
- package/src/three/entity/horseHeadAnimation.ts +151 -0
- package/src/three/entity/horsePassengerRendering.test.ts +77 -0
- package/src/three/entity/interpolationPolicy.test.ts +281 -0
- package/src/three/entity/interpolationPolicy.ts +242 -0
- package/src/three/entity/localHorseCameraYawLock.test.ts +72 -0
- package/src/three/entity/localVehicleRendering.integration.test.ts +267 -0
- package/src/three/entity/minecartLocalPassengerRendering.test.ts +210 -0
- package/src/three/entity/networkHeadPitchRendering.test.ts +102 -0
- package/src/three/entity/networkHeadPitchRendering.ts +44 -0
- package/src/three/entity/remoteBoatPassengerRotation.integration.test.ts +229 -0
- package/src/three/entity/remoteBoatPassengerRotation.ts +128 -0
- package/src/three/entity/vehiclePassengerProduction.test.ts +229 -0
- package/src/three/entity/vehiclePassengerRendering.ts +56 -0
- package/src/three/entity/vehiclePassengerUpdate.ts +91 -0
- package/src/three/graphicsBackendBase.ts +5 -5
- package/src/three/graphicsBackendOffThread.ts +2 -2
- package/src/three/worldRendererThree.ts +48 -22
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
import { expect, test } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
HORSE_HEAD_MAX_YAW,
|
|
6
|
+
applyHorseHeadPose,
|
|
7
|
+
calculateHorseHeadPose,
|
|
8
|
+
createHorseHeadAnimationState,
|
|
9
|
+
createHorseHeadRig,
|
|
10
|
+
getInterpolatedHorseHeadAnimation,
|
|
11
|
+
HORSE_HEAD_TICK_SECONDS,
|
|
12
|
+
setHorseHeadAnimationPosition,
|
|
13
|
+
updateHorseHeadAnimationFrame
|
|
14
|
+
} from './horseHeadAnimation'
|
|
15
|
+
|
|
16
|
+
test('head rig keeps world bounds and includes duplicate head parts', () => {
|
|
17
|
+
const root = new THREE.Group()
|
|
18
|
+
const body = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))
|
|
19
|
+
body.name = 'Body'
|
|
20
|
+
const head = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))
|
|
21
|
+
head.name = 'Head'
|
|
22
|
+
head.position.set(0, 2, -1)
|
|
23
|
+
const headSaddleA = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))
|
|
24
|
+
headSaddleA.name = 'HeadSaddle'
|
|
25
|
+
headSaddleA.position.set(0, 1.5, -1)
|
|
26
|
+
const headSaddleB = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))
|
|
27
|
+
headSaddleB.name = 'HeadSaddle'
|
|
28
|
+
headSaddleB.position.set(0, 1.8, -1)
|
|
29
|
+
root.add(body, head, headSaddleA, headSaddleB)
|
|
30
|
+
root.updateMatrixWorld(true)
|
|
31
|
+
const before = new THREE.Box3().setFromObject(root)
|
|
32
|
+
|
|
33
|
+
const rig = createHorseHeadRig(root)
|
|
34
|
+
root.updateMatrixWorld(true)
|
|
35
|
+
const after = new THREE.Box3().setFromObject(root)
|
|
36
|
+
|
|
37
|
+
expect(rig.userData.horseHeadParts).toHaveLength(3)
|
|
38
|
+
expect(rig.userData.horseHeadParts).toContain(head)
|
|
39
|
+
expect(rig.userData.horseHeadParts.filter(part => part.name === 'HeadSaddle')).toHaveLength(2)
|
|
40
|
+
expect(after.min.toArray()).toEqual(before.min.toArray())
|
|
41
|
+
expect(after.max.toArray()).toEqual(before.max.toArray())
|
|
42
|
+
expect(root.children.filter(child => child.name === 'horse_head_rig')).toHaveLength(1)
|
|
43
|
+
expect(createHorseHeadRig(root)).toBe(rig)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('horse head pitch follows entity pitch in radians', () => {
|
|
47
|
+
const pose = calculateHorseHeadPose({
|
|
48
|
+
entityPitch: -Math.PI / 6,
|
|
49
|
+
headYaw: 0,
|
|
50
|
+
bodyYaw: 0,
|
|
51
|
+
limbSwing: 0,
|
|
52
|
+
limbSwingAmount: 0
|
|
53
|
+
})
|
|
54
|
+
expect(pose.pitch).toBeCloseTo(-Math.PI / 6)
|
|
55
|
+
expect(pose.yaw).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('horse gait subtracts from pitch at full limb amount', () => {
|
|
59
|
+
const pose = calculateHorseHeadPose({
|
|
60
|
+
entityPitch: 0,
|
|
61
|
+
headYaw: 0,
|
|
62
|
+
bodyYaw: 0,
|
|
63
|
+
limbSwing: 0,
|
|
64
|
+
limbSwingAmount: 1
|
|
65
|
+
})
|
|
66
|
+
expect(pose.pitch).toBeCloseTo(-0.15)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('horse gait is disabled for a small limb amount', () => {
|
|
70
|
+
expect(
|
|
71
|
+
calculateHorseHeadPose({
|
|
72
|
+
entityPitch: 0.3,
|
|
73
|
+
headYaw: 0,
|
|
74
|
+
bodyYaw: 0,
|
|
75
|
+
limbSwing: Math.PI,
|
|
76
|
+
limbSwingAmount: 0.2
|
|
77
|
+
}).pitch
|
|
78
|
+
).toBeCloseTo(0.3)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('relative head yaw is shortest-path and limited to twenty degrees', () => {
|
|
82
|
+
expect(
|
|
83
|
+
calculateHorseHeadPose({
|
|
84
|
+
entityPitch: 0,
|
|
85
|
+
headYaw: -Math.PI + 0.1,
|
|
86
|
+
bodyYaw: Math.PI - 0.1,
|
|
87
|
+
limbSwing: 0,
|
|
88
|
+
limbSwingAmount: 0
|
|
89
|
+
}).yaw
|
|
90
|
+
).toBeCloseTo(0.2)
|
|
91
|
+
expect(
|
|
92
|
+
calculateHorseHeadPose({
|
|
93
|
+
entityPitch: 0,
|
|
94
|
+
headYaw: Math.PI / 2,
|
|
95
|
+
bodyYaw: 0,
|
|
96
|
+
limbSwing: 0,
|
|
97
|
+
limbSwingAmount: 0
|
|
98
|
+
}).yaw
|
|
99
|
+
).toBeCloseTo(HORSE_HEAD_MAX_YAW)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('animation phase uses authoritative horizontal distance and interpolates between ticks', () => {
|
|
103
|
+
const state = createHorseHeadAnimationState()
|
|
104
|
+
setHorseHeadAnimationPosition(state, { x: 10, z: 10 })
|
|
105
|
+
updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS)
|
|
106
|
+
setHorseHeadAnimationPosition(state, { x: 10.1, z: 10 })
|
|
107
|
+
updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS)
|
|
108
|
+
expect(state.limbSwingAmount).toBeCloseTo(0.16)
|
|
109
|
+
expect(getInterpolatedHorseHeadAnimation(state, 0).limbSwingAmount).toBe(0)
|
|
110
|
+
expect(getInterpolatedHorseHeadAnimation(state, 1).limbSwingAmount).toBeCloseTo(0.16)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('movement position is consumed once per render tick', () => {
|
|
114
|
+
const state = createHorseHeadAnimationState()
|
|
115
|
+
setHorseHeadAnimationPosition(state, { x: 10, z: 10 })
|
|
116
|
+
updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS)
|
|
117
|
+
setHorseHeadAnimationPosition(state, { x: 10.1, z: 10 })
|
|
118
|
+
updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS)
|
|
119
|
+
expect(state.limbSwingAmount).toBeCloseTo(0.16)
|
|
120
|
+
|
|
121
|
+
setHorseHeadAnimationPosition(state, { x: 10.2, z: 10 })
|
|
122
|
+
expect(state.limbSwingAmount).toBeCloseTo(0.16)
|
|
123
|
+
updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS)
|
|
124
|
+
expect(state.limbSwingAmount).toBeCloseTo(0.256)
|
|
125
|
+
expect(state.limbSwing).toBeCloseTo(0.416)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('render frames decay gait when a remote horse stops sending movement updates', () => {
|
|
129
|
+
const state = createHorseHeadAnimationState()
|
|
130
|
+
setHorseHeadAnimationPosition(state, { x: 10, z: 10 })
|
|
131
|
+
updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS)
|
|
132
|
+
setHorseHeadAnimationPosition(state, { x: 10.1, z: 10 })
|
|
133
|
+
updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS)
|
|
134
|
+
updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS)
|
|
135
|
+
expect(state.limbSwingAmount).toBeCloseTo(0.096)
|
|
136
|
+
expect(state.limbSwing).toBeCloseTo(0.256)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('interpolates the leftover time after a tick at 30 FPS', () => {
|
|
140
|
+
const state = createHorseHeadAnimationState()
|
|
141
|
+
setHorseHeadAnimationPosition(state, { x: 10, z: 10 })
|
|
142
|
+
updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS)
|
|
143
|
+
setHorseHeadAnimationPosition(state, { x: 10.1, z: 10 })
|
|
144
|
+
|
|
145
|
+
updateHorseHeadAnimationFrame(state, 0.033)
|
|
146
|
+
const interpolated = updateHorseHeadAnimationFrame(state, 0.033)
|
|
147
|
+
|
|
148
|
+
expect(state.tickAccumulatorSeconds).toBeCloseTo(0.016)
|
|
149
|
+
expect(state.elapsedSeconds / HORSE_HEAD_TICK_SECONDS).toBeCloseTo(0.32)
|
|
150
|
+
expect(interpolated.limbSwingAmount).toBeCloseTo(0.16 * 0.32)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('applying a pose changes only the runtime rig', () => {
|
|
154
|
+
const rig = new THREE.Group()
|
|
155
|
+
applyHorseHeadPose(rig, { pitch: 0.4, yaw: -0.2 })
|
|
156
|
+
expect(rig.rotation.x).toBeCloseTo(0.4)
|
|
157
|
+
expect(rig.rotation.y).toBeCloseTo(-0.2)
|
|
158
|
+
expect(rig.rotation.z).toBe(0)
|
|
159
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
|
|
4
|
+
export const HORSE_HEAD_PIVOT = { x: 0, y: 1.25, z: -0.75 } as const
|
|
5
|
+
export const HORSE_HEAD_MAX_YAW = THREE.MathUtils.degToRad(20)
|
|
6
|
+
export const HORSE_HEAD_TICK_SECONDS = 0.05
|
|
7
|
+
|
|
8
|
+
const HORSE_HEAD_PART_NAMES = new Set([
|
|
9
|
+
'Head',
|
|
10
|
+
'UMouth',
|
|
11
|
+
'Ear1',
|
|
12
|
+
'Ear2',
|
|
13
|
+
'MuleEarL',
|
|
14
|
+
'MuleEarR',
|
|
15
|
+
'Neck',
|
|
16
|
+
'Mane',
|
|
17
|
+
'SaddleMouthL',
|
|
18
|
+
'SaddleMouthR',
|
|
19
|
+
'SaddleMouthLine',
|
|
20
|
+
'SaddleMouthLineR',
|
|
21
|
+
'HeadSaddle'
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
export type HorseHeadRig = THREE.Group & {
|
|
25
|
+
userData: THREE.Group['userData'] & { horseHeadParts: THREE.Object3D[] }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const HORSE_HEAD_RIG_USER_DATA_KEY = '_horseHeadRig'
|
|
29
|
+
export const HORSE_HEAD_ANIMATION_USER_DATA_KEY = '_horseHeadAnimation'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reparents every horse head-related OBJ object under the vanilla head_parts
|
|
33
|
+
* pivot while retaining each object's world transform.
|
|
34
|
+
*/
|
|
35
|
+
export function createHorseHeadRig(root: THREE.Object3D): HorseHeadRig {
|
|
36
|
+
const existing = root.userData[HORSE_HEAD_RIG_USER_DATA_KEY] as HorseHeadRig | undefined
|
|
37
|
+
if (existing) return existing
|
|
38
|
+
|
|
39
|
+
const parts: THREE.Object3D[] = []
|
|
40
|
+
root.traverse(child => {
|
|
41
|
+
if (child !== root && HORSE_HEAD_PART_NAMES.has(child.name)) parts.push(child)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const rig = new THREE.Group() as HorseHeadRig
|
|
45
|
+
rig.name = 'horse_head_rig'
|
|
46
|
+
rig.position.set(HORSE_HEAD_PIVOT.x, HORSE_HEAD_PIVOT.y, HORSE_HEAD_PIVOT.z)
|
|
47
|
+
rig.userData.horseHeadParts = parts
|
|
48
|
+
root.add(rig)
|
|
49
|
+
|
|
50
|
+
for (const part of parts) rig.attach(part)
|
|
51
|
+
|
|
52
|
+
root.userData[HORSE_HEAD_RIG_USER_DATA_KEY] = rig
|
|
53
|
+
return rig
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type HorseHeadPoseInput = {
|
|
57
|
+
entityPitch: number
|
|
58
|
+
headYaw: number
|
|
59
|
+
bodyYaw: number
|
|
60
|
+
limbSwing: number
|
|
61
|
+
limbSwingAmount: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type HorseHeadPose = {
|
|
65
|
+
pitch: number
|
|
66
|
+
yaw: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function normalizeYawDelta(fromYaw: number, toYaw: number): number {
|
|
70
|
+
const tau = Math.PI * 2
|
|
71
|
+
const delta = ((((toYaw - fromYaw + Math.PI) % tau) + tau) % tau) - Math.PI
|
|
72
|
+
return delta === -Math.PI ? Math.PI : delta
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function calculateHorseHeadPose(input: HorseHeadPoseInput): HorseHeadPose {
|
|
76
|
+
const { entityPitch, headYaw, bodyYaw, limbSwing, limbSwingAmount } = input
|
|
77
|
+
const safeAmount = Number.isFinite(limbSwingAmount) ? Math.max(0, limbSwingAmount) : 0
|
|
78
|
+
const gait = safeAmount > 0.2 ? Math.cos(limbSwing * 0.4) * 0.15 * safeAmount : 0
|
|
79
|
+
const relativeYaw = THREE.MathUtils.clamp(normalizeYawDelta(bodyYaw, headYaw), -HORSE_HEAD_MAX_YAW, HORSE_HEAD_MAX_YAW)
|
|
80
|
+
return {
|
|
81
|
+
pitch: (Number.isFinite(entityPitch) ? entityPitch : 0) - gait,
|
|
82
|
+
yaw: relativeYaw
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type HorseHeadAnimationState = {
|
|
87
|
+
lastPosition?: { x: number; z: number }
|
|
88
|
+
pendingPosition?: { x: number; z: number }
|
|
89
|
+
prevLimbSwing: number
|
|
90
|
+
limbSwing: number
|
|
91
|
+
prevLimbSwingAmount: number
|
|
92
|
+
limbSwingAmount: number
|
|
93
|
+
elapsedSeconds: number
|
|
94
|
+
tickAccumulatorSeconds: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createHorseHeadAnimationState(): HorseHeadAnimationState {
|
|
98
|
+
return {
|
|
99
|
+
prevLimbSwing: 0,
|
|
100
|
+
limbSwing: 0,
|
|
101
|
+
prevLimbSwingAmount: 0,
|
|
102
|
+
limbSwingAmount: 0,
|
|
103
|
+
elapsedSeconds: HORSE_HEAD_TICK_SECONDS,
|
|
104
|
+
tickAccumulatorSeconds: 0
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Save authoritative entity coordinates until the next animation tick. */
|
|
109
|
+
export function setHorseHeadAnimationPosition(state: HorseHeadAnimationState, position: { x: number; z: number }): void {
|
|
110
|
+
state.pendingPosition = { x: position.x, z: position.z }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Advance using authoritative entity coordinates, before camera transforms. */
|
|
114
|
+
export function advanceHorseHeadAnimation(state: HorseHeadAnimationState, position: { x: number; z: number }): void {
|
|
115
|
+
state.prevLimbSwing = state.limbSwing
|
|
116
|
+
state.prevLimbSwingAmount = state.limbSwingAmount
|
|
117
|
+
|
|
118
|
+
const distance = state.lastPosition == null ? 0 : Math.hypot(position.x - state.lastPosition.x, position.z - state.lastPosition.z)
|
|
119
|
+
const targetAmount = Math.min(1, distance * 4)
|
|
120
|
+
state.limbSwingAmount += (targetAmount - state.limbSwingAmount) * 0.4
|
|
121
|
+
state.limbSwing += state.limbSwingAmount
|
|
122
|
+
state.lastPosition = { x: position.x, z: position.z }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getInterpolatedHorseHeadAnimation(state: HorseHeadAnimationState, partialTick: number): { limbSwing: number; limbSwingAmount: number } {
|
|
126
|
+
const alpha = THREE.MathUtils.clamp(partialTick, 0, 1)
|
|
127
|
+
return {
|
|
128
|
+
limbSwing: state.prevLimbSwing + (state.limbSwing - state.prevLimbSwing) * alpha,
|
|
129
|
+
limbSwingAmount: state.prevLimbSwingAmount + (state.limbSwingAmount - state.prevLimbSwingAmount) * alpha
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function updateHorseHeadAnimationFrame(state: HorseHeadAnimationState, deltaSeconds: number): { limbSwing: number; limbSwingAmount: number } {
|
|
134
|
+
const safeDeltaSeconds = Math.max(0, deltaSeconds)
|
|
135
|
+
state.elapsedSeconds = Math.min(HORSE_HEAD_TICK_SECONDS, state.elapsedSeconds + safeDeltaSeconds)
|
|
136
|
+
state.tickAccumulatorSeconds += safeDeltaSeconds
|
|
137
|
+
|
|
138
|
+
while (state.tickAccumulatorSeconds >= HORSE_HEAD_TICK_SECONDS) {
|
|
139
|
+
const position = state.pendingPosition ?? state.lastPosition
|
|
140
|
+
if (position) advanceHorseHeadAnimation(state, position)
|
|
141
|
+
state.pendingPosition = undefined
|
|
142
|
+
state.tickAccumulatorSeconds -= HORSE_HEAD_TICK_SECONDS
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
state.elapsedSeconds = state.tickAccumulatorSeconds
|
|
146
|
+
return getInterpolatedHorseHeadAnimation(state, state.elapsedSeconds / HORSE_HEAD_TICK_SECONDS)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function applyHorseHeadPose(rig: THREE.Object3D, pose: HorseHeadPose): void {
|
|
150
|
+
rig.rotation.set(pose.pitch, pose.yaw, 0)
|
|
151
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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 { getHorsePassengerWorldPosition } from './interpolationPolicy'
|
|
7
|
+
|
|
8
|
+
const LOCAL_PLAYER_ID = 7
|
|
9
|
+
const REMOTE_PLAYER_ID = 8
|
|
10
|
+
const HORSE_ID = '42'
|
|
11
|
+
const HORSE_HEIGHT = 1.6
|
|
12
|
+
|
|
13
|
+
type PassengerLike = THREE.Group & {
|
|
14
|
+
playerObject: Record<string, unknown>
|
|
15
|
+
originalEntity: { id: number }
|
|
16
|
+
userData: Record<string, unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makePassenger(id: number): PassengerLike {
|
|
20
|
+
const passenger = new THREE.Group() as PassengerLike
|
|
21
|
+
passenger.playerObject = {}
|
|
22
|
+
passenger.originalEntity = { id }
|
|
23
|
+
passenger.userData = {}
|
|
24
|
+
return passenger
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test.each([
|
|
28
|
+
['horse', 1.6, 0.85],
|
|
29
|
+
['zombie_horse', 1.6, 0.85],
|
|
30
|
+
['donkey', 1.5, 0.525],
|
|
31
|
+
['mule', 1.6, 0.6],
|
|
32
|
+
['skeleton_horse', 1.6, 0.6625]
|
|
33
|
+
])('horse variant %s seat Y uses vanilla offset', (name, height, expectedFeetOffset) => {
|
|
34
|
+
const seat = getHorsePassengerWorldPosition({ x: 0, y: 64, z: 0 }, name, height)
|
|
35
|
+
expect(seat.y).toBeCloseTo(64 + expectedFeetOffset, 5)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('local and remote horse passengers share the same anchor', () => {
|
|
39
|
+
const sceneOrigin = new SceneOrigin(new THREE.Scene())
|
|
40
|
+
sceneOrigin.update(96, 60, 192)
|
|
41
|
+
|
|
42
|
+
const horse = new THREE.Group()
|
|
43
|
+
sceneOrigin.track(horse)
|
|
44
|
+
horse.position.set(100, 64, 200)
|
|
45
|
+
|
|
46
|
+
const localPlayer = makePassenger(LOCAL_PLAYER_ID)
|
|
47
|
+
const remotePlayer = makePassenger(REMOTE_PLAYER_ID)
|
|
48
|
+
sceneOrigin.track(localPlayer)
|
|
49
|
+
sceneOrigin.track(remotePlayer)
|
|
50
|
+
|
|
51
|
+
const vehicleWorldPos = sceneOrigin.getWorldPosition(horse)!
|
|
52
|
+
const seatPosition = getHorsePassengerWorldPosition(vehicleWorldPos, 'horse', HORSE_HEIGHT)
|
|
53
|
+
|
|
54
|
+
anchorVehiclePassengerPosition(localPlayer, seatPosition, HORSE_ID)
|
|
55
|
+
anchorVehiclePassengerPosition(remotePlayer, seatPosition, HORSE_ID)
|
|
56
|
+
|
|
57
|
+
const localWorld = sceneOrigin.getWorldPosition(localPlayer)
|
|
58
|
+
const remoteWorld = sceneOrigin.getWorldPosition(remotePlayer)
|
|
59
|
+
expect(localWorld).toEqual(remoteWorld)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('passenger anchor releases after detach', () => {
|
|
63
|
+
const sceneOrigin = new SceneOrigin(new THREE.Scene())
|
|
64
|
+
sceneOrigin.update(0, 0, 0)
|
|
65
|
+
|
|
66
|
+
const horse = new THREE.Group()
|
|
67
|
+
sceneOrigin.track(horse)
|
|
68
|
+
horse.position.set(0, 64, 0)
|
|
69
|
+
|
|
70
|
+
const passenger = makePassenger(LOCAL_PLAYER_ID)
|
|
71
|
+
sceneOrigin.track(passenger)
|
|
72
|
+
|
|
73
|
+
const seatPosition = getHorsePassengerWorldPosition({ x: 0, y: 64, z: 0 }, 'horse', HORSE_HEIGHT)
|
|
74
|
+
anchorVehiclePassengerPosition(passenger, seatPosition, HORSE_ID)
|
|
75
|
+
releaseVehiclePassengerPosition(passenger, HORSE_ID, sceneOrigin.getWorldPosition(passenger))
|
|
76
|
+
expect(passenger.userData._passengerVehicleId).toBeUndefined()
|
|
77
|
+
})
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import { expect, test } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
ENTITY_TWEEN_DURATION_MS,
|
|
5
|
+
LOCAL_MOVEMENT_TWEEN_DURATION_MS,
|
|
6
|
+
SPECTATING_CAMERA_TWEEN_DURATION_MS,
|
|
7
|
+
getCameraMovementTweenDurationMs,
|
|
8
|
+
getEntityRotationTweenDurationMs,
|
|
9
|
+
getEntityTweenDurationMs,
|
|
10
|
+
getHorsePassengerWorldPosition,
|
|
11
|
+
getLocalVehicleWorldPosition,
|
|
12
|
+
getMinecartPassengerWorldPosition,
|
|
13
|
+
getVehiclePassengerFeetOffsetY,
|
|
14
|
+
resolveLocalVehicleWorldPosition,
|
|
15
|
+
samePosition,
|
|
16
|
+
shouldRestartCameraPositionTween,
|
|
17
|
+
usesCameraSyncedVehiclePosition
|
|
18
|
+
} from './interpolationPolicy'
|
|
19
|
+
|
|
20
|
+
test('local movement tween matches camera tween duration', () => {
|
|
21
|
+
expect(LOCAL_MOVEMENT_TWEEN_DURATION_MS).toBe(50)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('ordinary entity tween stays at 120 ms', () => {
|
|
25
|
+
expect(ENTITY_TWEEN_DURATION_MS).toBe(120)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('server-vehicle camera uses entity tween duration', () => {
|
|
29
|
+
expect(getCameraMovementTweenDurationMs('server-vehicle')).toBe(120)
|
|
30
|
+
expect(getCameraMovementTweenDurationMs('local-player')).toBe(50)
|
|
31
|
+
expect(getCameraMovementTweenDurationMs('spectating')).toBe(SPECTATING_CAMERA_TWEEN_DURATION_MS)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('forced teleport camera update is instant', () => {
|
|
35
|
+
expect(getCameraMovementTweenDurationMs('server-vehicle', true)).toBe(0)
|
|
36
|
+
expect(getCameraMovementTweenDurationMs('local-player', true)).toBe(0)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('same target does not restart camera tween', () => {
|
|
40
|
+
const target = { x: 1, y: 63.7, z: 2 }
|
|
41
|
+
expect(
|
|
42
|
+
shouldRestartCameraPositionTween({
|
|
43
|
+
target,
|
|
44
|
+
currentTarget: { ...target },
|
|
45
|
+
movementMode: 'server-vehicle',
|
|
46
|
+
previousMovementMode: 'server-vehicle',
|
|
47
|
+
instant: false
|
|
48
|
+
})
|
|
49
|
+
).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('dismount switches camera tween mode back to local-player', () => {
|
|
53
|
+
const target = { x: 1, y: 63.7, z: 2 }
|
|
54
|
+
expect(
|
|
55
|
+
shouldRestartCameraPositionTween({
|
|
56
|
+
target,
|
|
57
|
+
currentTarget: { ...target },
|
|
58
|
+
movementMode: 'local-player',
|
|
59
|
+
previousMovementMode: 'server-vehicle',
|
|
60
|
+
instant: false
|
|
61
|
+
})
|
|
62
|
+
).toBe(true)
|
|
63
|
+
expect(getCameraMovementTweenDurationMs('local-player')).toBe(50)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('sequential minecart targets restart tween only when position changes', () => {
|
|
67
|
+
const first = { x: 0, y: 63.7, z: 0 }
|
|
68
|
+
const second = { x: 1, y: 63.7, z: 0 }
|
|
69
|
+
expect(
|
|
70
|
+
shouldRestartCameraPositionTween({
|
|
71
|
+
target: first,
|
|
72
|
+
currentTarget: null,
|
|
73
|
+
movementMode: 'server-vehicle',
|
|
74
|
+
previousMovementMode: null,
|
|
75
|
+
instant: false
|
|
76
|
+
})
|
|
77
|
+
).toBe(true)
|
|
78
|
+
expect(
|
|
79
|
+
shouldRestartCameraPositionTween({
|
|
80
|
+
target: first,
|
|
81
|
+
currentTarget: first,
|
|
82
|
+
movementMode: 'server-vehicle',
|
|
83
|
+
previousMovementMode: 'server-vehicle',
|
|
84
|
+
instant: false
|
|
85
|
+
})
|
|
86
|
+
).toBe(false)
|
|
87
|
+
expect(
|
|
88
|
+
shouldRestartCameraPositionTween({
|
|
89
|
+
target: second,
|
|
90
|
+
currentTarget: first,
|
|
91
|
+
movementMode: 'server-vehicle',
|
|
92
|
+
previousMovementMode: 'server-vehicle',
|
|
93
|
+
instant: false
|
|
94
|
+
})
|
|
95
|
+
).toBe(true)
|
|
96
|
+
expect(second.x).toBeGreaterThan(first.x)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('samePosition uses epsilon comparison', () => {
|
|
100
|
+
expect(samePosition({ x: 1, y: 2, z: 3 }, { x: 1.00001, y: 2.00001, z: 3.00001 })).toBe(true)
|
|
101
|
+
expect(samePosition({ x: 1, y: 2, z: 3 }, { x: 1.01, y: 2, z: 3 })).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('local vehicle skips position tween', () => {
|
|
105
|
+
expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true } })).toBe(true)
|
|
106
|
+
expect(getEntityTweenDurationMs({ renderHints: { localVehicle: true } }, false)).toBe(0)
|
|
107
|
+
expect(getEntityTweenDurationMs({ renderHints: { localVehicle: true } }, true)).toBe(0)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('local vehicle skips rotation tween while remote vehicles keep it', () => {
|
|
111
|
+
expect(getEntityRotationTweenDurationMs({ renderHints: { localVehicle: true } }, false)).toBe(0)
|
|
112
|
+
expect(getEntityRotationTweenDurationMs({ renderHints: { localVehicle: false } }, false)).toBe(ENTITY_TWEEN_DURATION_MS)
|
|
113
|
+
expect(getEntityRotationTweenDurationMs(undefined, true)).toBe(0)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('locally ridden vehicle camera-sync policy stays separate from server-vehicle mode', () => {
|
|
117
|
+
expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true } })).toBe(true)
|
|
118
|
+
expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true, passengerLayout: 'minecart' } })).toBe(true)
|
|
119
|
+
expect(usesCameraSyncedVehiclePosition({ renderHints: { passengerLayout: 'minecart' } })).toBe(false)
|
|
120
|
+
expect(getCameraMovementTweenDurationMs('local-player')).toBe(50)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('remote entities keep ordinary tween duration', () => {
|
|
124
|
+
expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: false } })).toBe(false)
|
|
125
|
+
expect(getEntityTweenDurationMs({ renderHints: { localVehicle: false } }, false)).toBe(120)
|
|
126
|
+
expect(getEntityTweenDurationMs({}, false)).toBe(120)
|
|
127
|
+
expect(getEntityTweenDurationMs(undefined, false)).toBe(120)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('local vehicle world position uses camera X/Z and vehicle Y', () => {
|
|
131
|
+
const camera = { x: 10, y: 64.5, z: -3 }
|
|
132
|
+
expect(getLocalVehicleWorldPosition(camera, 63.2)).toEqual({
|
|
133
|
+
x: 10,
|
|
134
|
+
y: 63.2,
|
|
135
|
+
z: -3
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('intermediate camera frames keep boat X/Z aligned with player', () => {
|
|
140
|
+
const vehicleY = 63.2
|
|
141
|
+
const start = getLocalVehicleWorldPosition({ x: 0, y: 64, z: 0 }, vehicleY)
|
|
142
|
+
const mid = getLocalVehicleWorldPosition({ x: 0.5, y: 64, z: 0 }, vehicleY)
|
|
143
|
+
const end = getLocalVehicleWorldPosition({ x: 1, y: 64, z: 0 }, vehicleY)
|
|
144
|
+
|
|
145
|
+
expect(start.x).toBe(0)
|
|
146
|
+
expect(mid.x).toBe(0.5)
|
|
147
|
+
expect(end.x).toBe(1)
|
|
148
|
+
expect(start.z).toBe(mid.z)
|
|
149
|
+
expect(mid.z).toBe(end.z)
|
|
150
|
+
expect(start.y).toBe(vehicleY)
|
|
151
|
+
expect(mid.y).toBe(vehicleY)
|
|
152
|
+
expect(end.y).toBe(vehicleY)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('horse vertical camera lock keeps constant offset from camera across tween progress', () => {
|
|
156
|
+
const eyeHeight = 1.62
|
|
157
|
+
const feetOffset = 0.85
|
|
158
|
+
const expectedGap = -(eyeHeight + feetOffset)
|
|
159
|
+
const rawVehicleY = 64
|
|
160
|
+
const cameraStartY = rawVehicleY + feetOffset + eyeHeight
|
|
161
|
+
const tweenProgress = [0, 0.25, 0.5, 0.75, 1]
|
|
162
|
+
|
|
163
|
+
for (const progress of tweenProgress) {
|
|
164
|
+
const cameraY = cameraStartY + progress * 0.8
|
|
165
|
+
const resolved = resolveLocalVehicleWorldPosition({
|
|
166
|
+
cameraWorldPos: { x: 1 + progress, y: cameraY, z: 2 },
|
|
167
|
+
rawVehicleY,
|
|
168
|
+
eyeHeight,
|
|
169
|
+
vehicleName: 'horse',
|
|
170
|
+
vehicleHeight: 1.6,
|
|
171
|
+
verticalCameraLock: 'horse'
|
|
172
|
+
})
|
|
173
|
+
expect(resolved.y - cameraY).toBeCloseTo(expectedGap, 5)
|
|
174
|
+
expect(resolved.x).toBe(1 + progress)
|
|
175
|
+
expect(resolved.z).toBe(2)
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('horse vertical camera lock uses vanilla feet offset value', () => {
|
|
180
|
+
const eyeHeight = 1.62
|
|
181
|
+
const cameraY = 66.47
|
|
182
|
+
const resolved = resolveLocalVehicleWorldPosition({
|
|
183
|
+
cameraWorldPos: { x: 0, y: cameraY, z: 0 },
|
|
184
|
+
rawVehicleY: 64,
|
|
185
|
+
eyeHeight,
|
|
186
|
+
vehicleName: 'horse',
|
|
187
|
+
vehicleHeight: 1.6,
|
|
188
|
+
verticalCameraLock: 'horse'
|
|
189
|
+
})
|
|
190
|
+
expect(resolved.y).toBeCloseTo(cameraY - 2.47, 5)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('horse vertical camera lock is variant-aware for donkey and skeleton_horse', () => {
|
|
194
|
+
const eyeHeight = 1.62
|
|
195
|
+
const cameraY = 65
|
|
196
|
+
|
|
197
|
+
const donkey = resolveLocalVehicleWorldPosition({
|
|
198
|
+
cameraWorldPos: { x: 0, y: cameraY, z: 0 },
|
|
199
|
+
rawVehicleY: 64,
|
|
200
|
+
eyeHeight,
|
|
201
|
+
vehicleName: 'donkey',
|
|
202
|
+
vehicleHeight: 1.6,
|
|
203
|
+
verticalCameraLock: 'horse'
|
|
204
|
+
})
|
|
205
|
+
expect(donkey.y).toBeCloseTo(cameraY - eyeHeight - 0.6, 5)
|
|
206
|
+
|
|
207
|
+
const skeleton = resolveLocalVehicleWorldPosition({
|
|
208
|
+
cameraWorldPos: { x: 0, y: cameraY, z: 0 },
|
|
209
|
+
rawVehicleY: 64,
|
|
210
|
+
eyeHeight,
|
|
211
|
+
vehicleName: 'skeleton_horse',
|
|
212
|
+
vehicleHeight: 1.6,
|
|
213
|
+
verticalCameraLock: 'horse'
|
|
214
|
+
})
|
|
215
|
+
expect(skeleton.y).toBeCloseTo(cameraY - eyeHeight - 0.6625, 5)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('without vertical camera lock resolver keeps raw vehicle Y', () => {
|
|
219
|
+
const camera = { x: 10, y: 64.5, z: -3 }
|
|
220
|
+
const rawVehicleY = 63.2
|
|
221
|
+
expect(
|
|
222
|
+
resolveLocalVehicleWorldPosition({
|
|
223
|
+
cameraWorldPos: camera,
|
|
224
|
+
rawVehicleY,
|
|
225
|
+
eyeHeight: 1.62,
|
|
226
|
+
vehicleName: 'horse',
|
|
227
|
+
vehicleHeight: 1.6
|
|
228
|
+
})
|
|
229
|
+
).toEqual(getLocalVehicleWorldPosition(camera, rawVehicleY))
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('horse vertical camera lock falls back to raw Y when result is non-finite', () => {
|
|
233
|
+
const camera = { x: 1, y: 64, z: 2 }
|
|
234
|
+
const rawVehicleY = 63.5
|
|
235
|
+
expect(
|
|
236
|
+
resolveLocalVehicleWorldPosition({
|
|
237
|
+
cameraWorldPos: camera,
|
|
238
|
+
rawVehicleY,
|
|
239
|
+
eyeHeight: Number.NaN,
|
|
240
|
+
vehicleName: 'horse',
|
|
241
|
+
vehicleHeight: 1.6,
|
|
242
|
+
verticalCameraLock: 'horse'
|
|
243
|
+
})
|
|
244
|
+
).toEqual(getLocalVehicleWorldPosition(camera, rawVehicleY))
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('minecart passenger feet Y is vehicleY - 0.35', () => {
|
|
248
|
+
expect(getMinecartPassengerWorldPosition({ x: 0, y: 64, z: 0 }, '1.17.1', 'minecart', 0.7).y).toBeCloseTo(63.65, 5)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test.each([
|
|
252
|
+
['1.17.1', 'boat', -0.45],
|
|
253
|
+
['1.20.1', 'boat', -0.45],
|
|
254
|
+
['1.20.2', 'boat', -0.4125],
|
|
255
|
+
['1.21.4', 'boat', -0.4125],
|
|
256
|
+
['1.17.1', 'minecart', -0.35],
|
|
257
|
+
['1.20.1', 'minecart', -0.35],
|
|
258
|
+
['1.20.2', 'minecart', -0.4125],
|
|
259
|
+
['1.21.4', 'minecart', -0.4125],
|
|
260
|
+
['1.21.4', 'bamboo_raft', -0.1]
|
|
261
|
+
])('vehicle %s %s seat offset matches vanilla', (version, vehicleName, expected) => {
|
|
262
|
+
const layout = vehicleName === 'minecart' ? 'minecart' : 'boat'
|
|
263
|
+
const height = layout === 'boat' ? 0.5625 : 0.7
|
|
264
|
+
expect(getVehiclePassengerFeetOffsetY(layout, version, vehicleName, height)).toBeCloseTo(expected, 5)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('vehicle seat offset falls back to the default vehicle height', () => {
|
|
268
|
+
expect(getVehiclePassengerFeetOffsetY('boat', '1.20.2', 'oak_boat', undefined)).toBeCloseTo(-0.4125, 5)
|
|
269
|
+
expect(getVehiclePassengerFeetOffsetY('minecart', '1.20.2', 'minecart', undefined)).toBeCloseTo(-0.4125, 5)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test.each([
|
|
273
|
+
['horse', 1.6, 0.85],
|
|
274
|
+
['zombie_horse', 1.6, 0.85],
|
|
275
|
+
['donkey', 1.5, 0.525],
|
|
276
|
+
['mule', 1.6, 0.6],
|
|
277
|
+
['skeleton_horse', 1.6, 0.6625]
|
|
278
|
+
])('horse variant %s feet offset matches vanilla 1.17.1', (name, height, expectedFeetOffset) => {
|
|
279
|
+
const seat = getHorsePassengerWorldPosition({ x: 0, y: 64, z: 0 }, name, height)
|
|
280
|
+
expect(seat.y).toBeCloseTo(64 + expectedFeetOffset, 5)
|
|
281
|
+
})
|