minecraft-renderer 0.1.93 → 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.
- 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 +302 -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 +259 -0
- package/src/three/entity/interpolationPolicy.ts +187 -0
- package/src/three/entity/localHorseCameraYawLock.test.ts +72 -0
- package/src/three/entity/localVehicleRendering.integration.test.ts +247 -0
- package/src/three/entity/minecartLocalPassengerRendering.test.ts +208 -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 +227 -0
- package/src/three/entity/remoteBoatPassengerRotation.ts +128 -0
- package/src/three/entity/vehiclePassengerProduction.test.ts +214 -0
- package/src/three/entity/vehiclePassengerRendering.ts +56 -0
- package/src/three/entity/vehiclePassengerUpdate.ts +82 -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,71 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import { isBoatEntityName } from './boatModelRotation'
|
|
3
|
+
|
|
4
|
+
/** Vanilla 1.17.1 `Boat.clampRotation` limit (degrees → radians). */
|
|
5
|
+
export const BOAT_PASSENGER_MAX_HEAD_YAW_RAD = (105 * Math.PI) / 180
|
|
6
|
+
|
|
7
|
+
export function normalizeYawDelta(from: number, to: number): number {
|
|
8
|
+
let delta = to - from
|
|
9
|
+
while (delta > Math.PI) delta -= Math.PI * 2
|
|
10
|
+
while (delta < -Math.PI) delta += Math.PI * 2
|
|
11
|
+
return delta
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeYaw(yaw: number): number {
|
|
15
|
+
return normalizeYawDelta(0, yaw)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function clampBoatPassengerRelativeHeadYaw(relativeYaw: number): number {
|
|
19
|
+
return Math.max(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD, Math.min(BOAT_PASSENGER_MAX_HEAD_YAW_RAD, relativeYaw))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Clamp absolute look yaw for a boat passenger (vanilla `Boat.clampRotation`). */
|
|
23
|
+
export function getClampedBoatPassengerYaw(requestedYaw: number, boatYaw: number): number {
|
|
24
|
+
if (!Number.isFinite(requestedYaw) || !Number.isFinite(boatYaw)) return requestedYaw
|
|
25
|
+
const relative = normalizeYawDelta(boatYaw, requestedYaw)
|
|
26
|
+
const clampedRelative = clampBoatPassengerRelativeHeadYaw(relative)
|
|
27
|
+
return normalizeYaw(boatYaw + clampedRelative)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type BoatPassengerThirdPersonRotation = {
|
|
31
|
+
bodyYaw: number
|
|
32
|
+
headYaw: number
|
|
33
|
+
headPitch: number
|
|
34
|
+
effectiveCameraYaw: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveBoatPassengerThirdPersonRotation(params: {
|
|
38
|
+
cameraYaw: number
|
|
39
|
+
cameraPitch: number
|
|
40
|
+
vehicleYaw: number
|
|
41
|
+
}): BoatPassengerThirdPersonRotation {
|
|
42
|
+
const { cameraYaw, cameraPitch, vehicleYaw } = params
|
|
43
|
+
const relativeHeadYaw = normalizeYawDelta(vehicleYaw, cameraYaw)
|
|
44
|
+
const headYaw = clampBoatPassengerRelativeHeadYaw(relativeHeadYaw)
|
|
45
|
+
const effectiveCameraYaw = normalizeYaw(vehicleYaw + headYaw)
|
|
46
|
+
return {
|
|
47
|
+
bodyYaw: vehicleYaw,
|
|
48
|
+
headYaw,
|
|
49
|
+
headPitch: -cameraPitch,
|
|
50
|
+
effectiveCameraYaw
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function shouldApplyBoatPassengerRotation(params: {
|
|
55
|
+
isAnchoredPassenger: boolean
|
|
56
|
+
vehicleName: string | undefined
|
|
57
|
+
vehicleYaw: number | undefined
|
|
58
|
+
}): boolean {
|
|
59
|
+
return params.isAnchoredPassenger && isBoatEntityName(params.vehicleName) && typeof params.vehicleYaw === 'number' && Number.isFinite(params.vehicleYaw)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function shouldApplyBoatPassengerThirdPersonRotation(params: {
|
|
63
|
+
isThirdPerson: boolean
|
|
64
|
+
isAnchoredPassenger: boolean
|
|
65
|
+
vehicleName: string | undefined
|
|
66
|
+
vehicleYaw: number | undefined
|
|
67
|
+
}): boolean {
|
|
68
|
+
return params.isThirdPerson && shouldApplyBoatPassengerRotation(params)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { isBoatEntityName }
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
import { expect, test } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
BOAT_HULL_RENDER_ORDER,
|
|
6
|
+
BOAT_OBJ_OFFSET_Y,
|
|
7
|
+
BOAT_PADDLE_GEOMETRY_DELTA,
|
|
8
|
+
BOAT_PADDLE_LEFT_PIVOT,
|
|
9
|
+
BOAT_PADDLE_PIVOT_LEFT_NAME,
|
|
10
|
+
BOAT_PADDLE_PIVOT_RIGHT_NAME,
|
|
11
|
+
BOAT_PADDLE_RIGHT_PIVOT,
|
|
12
|
+
BOAT_PADDLE_SOURCE_LEFT,
|
|
13
|
+
BOAT_WATER_PATCH_CENTER_Y,
|
|
14
|
+
BOAT_WATER_PATCH_CENTER_Z,
|
|
15
|
+
BOAT_WATER_PATCH_DEPTH,
|
|
16
|
+
BOAT_WATER_PATCH_HEIGHT,
|
|
17
|
+
BOAT_WATER_PATCH_NAME,
|
|
18
|
+
BOAT_WATER_PATCH_RENDER_ORDER,
|
|
19
|
+
BOAT_WATER_PATCH_WIDTH,
|
|
20
|
+
BOAT_WATER_PATCH_WORLD_BOUNDS,
|
|
21
|
+
applyBoatHullRenderSettings,
|
|
22
|
+
boatObjLocalToEntitySpace,
|
|
23
|
+
createBoatWaterPatchMesh,
|
|
24
|
+
getBoatWaterPatchEntitySpaceBounds,
|
|
25
|
+
setupBoatMesh,
|
|
26
|
+
setupBoatPaddlePivots
|
|
27
|
+
} from './boatRenderSetup'
|
|
28
|
+
|
|
29
|
+
test('water patch uses vanilla dimensions in blocks', () => {
|
|
30
|
+
const patch = createBoatWaterPatchMesh()
|
|
31
|
+
const size = new THREE.Vector3()
|
|
32
|
+
patch.geometry.computeBoundingBox()
|
|
33
|
+
patch.geometry.boundingBox!.getSize(size)
|
|
34
|
+
expect(size.x).toBeCloseTo(BOAT_WATER_PATCH_WIDTH, 5)
|
|
35
|
+
expect(size.y).toBeCloseTo(BOAT_WATER_PATCH_HEIGHT, 5)
|
|
36
|
+
expect(size.z).toBeCloseTo(BOAT_WATER_PATCH_DEPTH, 5)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('water patch local center matches vanilla OBJ placement', () => {
|
|
40
|
+
const patch = createBoatWaterPatchMesh()
|
|
41
|
+
expect(patch.position.x).toBe(0)
|
|
42
|
+
expect(patch.position.y).toBeCloseTo(BOAT_WATER_PATCH_CENTER_Y, 5)
|
|
43
|
+
expect(patch.position.z).toBeCloseTo(BOAT_WATER_PATCH_CENTER_Z, 5)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('water patch entity-space bounds match vanilla world-relative bounds', () => {
|
|
47
|
+
const bounds = getBoatWaterPatchEntitySpaceBounds(BOAT_OBJ_OFFSET_Y)
|
|
48
|
+
expect(bounds.minX).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.minX, 5)
|
|
49
|
+
expect(bounds.maxX).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.maxX, 5)
|
|
50
|
+
expect(bounds.minY).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.minY, 5)
|
|
51
|
+
expect(bounds.maxY).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.maxY, 5)
|
|
52
|
+
expect(bounds.minZ).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.minZ, 5)
|
|
53
|
+
expect(bounds.maxZ).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.maxZ, 5)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('water patch material is depth-only', () => {
|
|
57
|
+
const patch = createBoatWaterPatchMesh()
|
|
58
|
+
const material = patch.material as THREE.MeshBasicMaterial
|
|
59
|
+
expect(material.colorWrite).toBe(false)
|
|
60
|
+
expect(material.depthWrite).toBe(true)
|
|
61
|
+
expect(material.depthTest).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('boat hull renders in opaque list before depth-only patch', () => {
|
|
65
|
+
const root = new THREE.Object3D()
|
|
66
|
+
const hull = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.1 }))
|
|
67
|
+
hull.name = 'bottom'
|
|
68
|
+
root.add(hull)
|
|
69
|
+
const { waterPatch } = setupBoatMesh(root)
|
|
70
|
+
|
|
71
|
+
expect(hull.renderOrder).toBe(BOAT_HULL_RENDER_ORDER)
|
|
72
|
+
expect(waterPatch.renderOrder).toBe(BOAT_WATER_PATCH_RENDER_ORDER)
|
|
73
|
+
expect(waterPatch.renderOrder).toBeGreaterThan(hull.renderOrder)
|
|
74
|
+
expect(waterPatch.name).toBe(BOAT_WATER_PATCH_NAME)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('boat hull material is alpha-tested opaque with depth write', () => {
|
|
78
|
+
const root = new THREE.Object3D()
|
|
79
|
+
const hull = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.05 }))
|
|
80
|
+
root.add(hull)
|
|
81
|
+
applyBoatHullRenderSettings(root)
|
|
82
|
+
const material = hull.material as THREE.MeshBasicMaterial
|
|
83
|
+
expect(material.transparent).toBe(false)
|
|
84
|
+
expect(material.alphaTest).toBeGreaterThanOrEqual(0.1)
|
|
85
|
+
expect(material.depthWrite).toBe(true)
|
|
86
|
+
expect(material.depthTest).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('boat OBJ hull entity-space Y bounds after BOAT_OBJ_OFFSET_Y', () => {
|
|
90
|
+
const fs = require('node:fs')
|
|
91
|
+
const path = require('node:path')
|
|
92
|
+
const objPath = path.join(__dirname, 'models/boat.obj')
|
|
93
|
+
const txt = fs.readFileSync(objPath, 'utf8') as string
|
|
94
|
+
const groups: Record<string, { min: number; max: number }> = {}
|
|
95
|
+
let current = ''
|
|
96
|
+
for (const line of txt.split('\n')) {
|
|
97
|
+
if (line.startsWith('o ')) current = line.slice(2).trim()
|
|
98
|
+
if (!line.startsWith('v ')) continue
|
|
99
|
+
const y = Number(line.split(/\s+/)[2])
|
|
100
|
+
groups[current] ??= { min: Infinity, max: -Infinity }
|
|
101
|
+
groups[current].min = Math.min(groups[current].min, y + BOAT_OBJ_OFFSET_Y)
|
|
102
|
+
groups[current].max = Math.max(groups[current].max, y + BOAT_OBJ_OFFSET_Y)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
expect(groups.bottom.min).toBeCloseTo(0, 5)
|
|
106
|
+
expect(groups.bottom.max).toBeCloseTo(0.1875, 5)
|
|
107
|
+
for (const name of ['front', 'back', 'left', 'right'] as const) {
|
|
108
|
+
expect(groups[name].min).toBeCloseTo(0.1875, 5)
|
|
109
|
+
expect(groups[name].max).toBeCloseTo(0.5625, 5)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const patch = getBoatWaterPatchEntitySpaceBounds(BOAT_OBJ_OFFSET_Y)
|
|
113
|
+
expect(patch.minY).toBeCloseTo(0.375, 5)
|
|
114
|
+
expect(patch.maxY).toBeCloseTo(0.5625, 5)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
function makeBoatRootWithPaddles(): THREE.Object3D {
|
|
118
|
+
const root = new THREE.Object3D()
|
|
119
|
+
const leftShaft = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.5))
|
|
120
|
+
leftShaft.name = 'paddle_left'
|
|
121
|
+
leftShaft.position.set(0.2, 1.5, 1.0)
|
|
122
|
+
const leftBlade = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.2))
|
|
123
|
+
leftBlade.name = 'paddle_left'
|
|
124
|
+
leftBlade.position.set(0.15, 1.4, 1.2)
|
|
125
|
+
const rightShaft = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.5))
|
|
126
|
+
rightShaft.name = 'paddle_right'
|
|
127
|
+
rightShaft.position.set(0.2, 1.5, -1.0)
|
|
128
|
+
const rightBlade = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.2))
|
|
129
|
+
rightBlade.name = 'paddle_right'
|
|
130
|
+
rightBlade.position.set(0.15, 1.4, -1.2)
|
|
131
|
+
root.add(leftShaft, leftBlade, rightShaft, rightBlade)
|
|
132
|
+
return root
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test('setupBoatPaddlePivots groups both paddle pieces by side', () => {
|
|
136
|
+
const root = makeBoatRootWithPaddles()
|
|
137
|
+
const { leftPivot, rightPivot } = setupBoatPaddlePivots(root)
|
|
138
|
+
|
|
139
|
+
expect(leftPivot?.children.length).toBe(2)
|
|
140
|
+
expect(rightPivot?.children.length).toBe(2)
|
|
141
|
+
expect(leftPivot?.name).toBe(BOAT_PADDLE_PIVOT_LEFT_NAME)
|
|
142
|
+
expect(rightPivot?.name).toBe(BOAT_PADDLE_PIVOT_RIGHT_NAME)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
function getMeshRootLocalPosition(root: THREE.Object3D, mesh: THREE.Object3D): THREE.Vector3 {
|
|
146
|
+
const rootLocal = new THREE.Vector3()
|
|
147
|
+
let current: THREE.Object3D | null = mesh
|
|
148
|
+
while (current && current !== root) {
|
|
149
|
+
rootLocal.add(current.position)
|
|
150
|
+
current = current.parent
|
|
151
|
+
}
|
|
152
|
+
return rootLocal
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test('setupBoatPaddlePivots uses corrected vanilla attachment positions', () => {
|
|
156
|
+
const root = makeBoatRootWithPaddles()
|
|
157
|
+
const { leftPivot, rightPivot } = setupBoatPaddlePivots(root)
|
|
158
|
+
|
|
159
|
+
expect(leftPivot?.position.toArray()).toEqual(BOAT_PADDLE_LEFT_PIVOT.toArray())
|
|
160
|
+
expect(rightPivot?.position.toArray()).toEqual(BOAT_PADDLE_RIGHT_PIVOT.toArray())
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('setupBoatPaddlePivots shifts root-local paddle geometry by geometry delta', () => {
|
|
164
|
+
const root = makeBoatRootWithPaddles()
|
|
165
|
+
const before = root.children.map(child => child.position.clone())
|
|
166
|
+
setupBoatPaddlePivots(root)
|
|
167
|
+
|
|
168
|
+
const afterPositions: THREE.Vector3[] = []
|
|
169
|
+
root.traverse(child => {
|
|
170
|
+
if (child instanceof THREE.Mesh && (child.name === 'paddle_left' || child.name === 'paddle_right')) {
|
|
171
|
+
afterPositions.push(getMeshRootLocalPosition(root, child))
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
expect(afterPositions.length).toBe(4)
|
|
176
|
+
for (let i = 0; i < before.length; i++) {
|
|
177
|
+
expect(afterPositions[i].x).toBeCloseTo(before[i].x + BOAT_PADDLE_GEOMETRY_DELTA.x, 5)
|
|
178
|
+
expect(afterPositions[i].y).toBeCloseTo(before[i].y + BOAT_PADDLE_GEOMETRY_DELTA.y, 5)
|
|
179
|
+
expect(afterPositions[i].z).toBeCloseTo(before[i].z + BOAT_PADDLE_GEOMETRY_DELTA.z, 5)
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('corrected paddle pivots map to vanilla entity Z with mesh yaw −π/2', () => {
|
|
184
|
+
const left = boatObjLocalToEntitySpace(BOAT_PADDLE_LEFT_PIVOT.x, BOAT_PADDLE_LEFT_PIVOT.y, BOAT_PADDLE_LEFT_PIVOT.z)
|
|
185
|
+
const right = boatObjLocalToEntitySpace(BOAT_PADDLE_RIGHT_PIVOT.x, BOAT_PADDLE_RIGHT_PIVOT.y, BOAT_PADDLE_RIGHT_PIVOT.z)
|
|
186
|
+
|
|
187
|
+
expect(left.z).toBeCloseTo(-3 / 16, 5)
|
|
188
|
+
expect(right.z).toBeCloseTo(-3 / 16, 5)
|
|
189
|
+
expect(left.y).toBeCloseTo(0.6875, 5)
|
|
190
|
+
expect(right.y).toBeCloseTo(0.6875, 5)
|
|
191
|
+
expect(left.x).toBeCloseTo(-9 / 16, 5)
|
|
192
|
+
expect(right.x).toBeCloseTo(9 / 16, 5)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('setupBoatPaddlePivots offsets child geometry from source pivot for rotation', () => {
|
|
196
|
+
const root = makeBoatRootWithPaddles()
|
|
197
|
+
const { leftPivot } = setupBoatPaddlePivots(root)
|
|
198
|
+
expect(leftPivot).toBeDefined()
|
|
199
|
+
|
|
200
|
+
const shaft = leftPivot!.children[0]
|
|
201
|
+
const rootLocalBefore = new THREE.Vector3(0.2, 1.5, 1.0)
|
|
202
|
+
expect(shaft.position.x).toBeCloseTo(rootLocalBefore.x - BOAT_PADDLE_SOURCE_LEFT.x, 5)
|
|
203
|
+
expect(shaft.position.y).toBeCloseTo(rootLocalBefore.y - BOAT_PADDLE_SOURCE_LEFT.y, 5)
|
|
204
|
+
expect(shaft.position.z).toBeCloseTo(rootLocalBefore.z - BOAT_PADDLE_SOURCE_LEFT.z, 5)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('rotating a pivot moves both paddle pieces rigidly', () => {
|
|
208
|
+
const root = makeBoatRootWithPaddles()
|
|
209
|
+
const { leftPivot } = setupBoatPaddlePivots(root)
|
|
210
|
+
expect(leftPivot).toBeDefined()
|
|
211
|
+
|
|
212
|
+
const childOffsetsBefore = leftPivot!.children.map(child => child.position.clone())
|
|
213
|
+
leftPivot!.rotation.y = Math.PI / 4
|
|
214
|
+
leftPivot!.updateMatrixWorld(true)
|
|
215
|
+
|
|
216
|
+
leftPivot!.children.forEach((child, index) => {
|
|
217
|
+
expect(child.position.x).toBeCloseTo(childOffsetsBefore[index].x, 5)
|
|
218
|
+
expect(child.position.y).toBeCloseTo(childOffsetsBefore[index].y, 5)
|
|
219
|
+
expect(child.position.z).toBeCloseTo(childOffsetsBefore[index].z, 5)
|
|
220
|
+
})
|
|
221
|
+
expect(leftPivot!.position.toArray()).toEqual(BOAT_PADDLE_LEFT_PIVOT.toArray())
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('setupBoatMesh is idempotent for paddle pivots and water patch', () => {
|
|
225
|
+
const root = makeBoatRootWithPaddles()
|
|
226
|
+
setupBoatMesh(root)
|
|
227
|
+
const leftPivot = root.userData.boatPaddleLeftPivot as THREE.Object3D
|
|
228
|
+
const waterPatchCountBefore = root.children.filter(child => child.name === BOAT_WATER_PATCH_NAME).length
|
|
229
|
+
|
|
230
|
+
setupBoatMesh(root)
|
|
231
|
+
|
|
232
|
+
expect(root.userData.boatPaddleLeftPivot).toBe(leftPivot)
|
|
233
|
+
expect(root.children.filter(child => child.name === BOAT_WATER_PATCH_NAME).length).toBe(waterPatchCountBefore)
|
|
234
|
+
expect(root.children.filter(child => child.name === BOAT_PADDLE_PIVOT_LEFT_NAME).length).toBe(1)
|
|
235
|
+
expect(root.children.filter(child => child.name === BOAT_PADDLE_PIVOT_RIGHT_NAME).length).toBe(1)
|
|
236
|
+
})
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
import { BOAT_MESH_YAW_OFFSET } from './boatModelRotation'
|
|
4
|
+
|
|
5
|
+
/** Vanilla BoatModel.createWaterPatch() size in blocks (after X rotation). */
|
|
6
|
+
export const BOAT_WATER_PATCH_WIDTH = 28 / 16
|
|
7
|
+
export const BOAT_WATER_PATCH_HEIGHT = 3 / 16
|
|
8
|
+
export const BOAT_WATER_PATCH_DEPTH = 16 / 16
|
|
9
|
+
|
|
10
|
+
/** OBJ boat root offset applied in EntityMesh (single source — import from here, do not duplicate). */
|
|
11
|
+
export const BOAT_OBJ_OFFSET_Y = -1.125
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Vanilla 1.17.1 water patch center in OBJ-local space.
|
|
15
|
+
* Tuned with BOAT_OBJ_OFFSET_Y so entity-space bounds stay Y [0.375, 0.5625].
|
|
16
|
+
*/
|
|
17
|
+
export const BOAT_WATER_PATCH_CENTER_Y = 1.59375
|
|
18
|
+
export const BOAT_WATER_PATCH_CENTER_Z = 0
|
|
19
|
+
|
|
20
|
+
export const BOAT_WATER_PATCH_WORLD_BOUNDS = {
|
|
21
|
+
minX: -0.875,
|
|
22
|
+
maxX: 0.875,
|
|
23
|
+
minY: 0.375,
|
|
24
|
+
maxY: 0.5625,
|
|
25
|
+
minZ: -0.5,
|
|
26
|
+
maxZ: 0.5
|
|
27
|
+
} as const
|
|
28
|
+
|
|
29
|
+
export const BOAT_HULL_RENDER_ORDER = 0
|
|
30
|
+
export const BOAT_WATER_PATCH_RENDER_ORDER = 1
|
|
31
|
+
|
|
32
|
+
export const BOAT_WATER_PATCH_NAME = 'boat_water_patch'
|
|
33
|
+
|
|
34
|
+
export const BOAT_PADDLE_PIVOT_LEFT_NAME = 'boat_paddle_pivot_left'
|
|
35
|
+
export const BOAT_PADDLE_PIVOT_RIGHT_NAME = 'boat_paddle_pivot_right'
|
|
36
|
+
|
|
37
|
+
/** Original handoff pivot (rotation center only — geometry stays aft if used alone). */
|
|
38
|
+
export const BOAT_PADDLE_SOURCE_LEFT = new THREE.Vector3(3 / 16, 28 / 16, 9 / 16)
|
|
39
|
+
export const BOAT_PADDLE_SOURCE_RIGHT = new THREE.Vector3(3 / 16, 28 / 16, -9 / 16)
|
|
40
|
+
|
|
41
|
+
/** Root-local shift applied to paddle mesh together with the pivot move. */
|
|
42
|
+
export const BOAT_PADDLE_GEOMETRY_DELTA = new THREE.Vector3(-6 / 16, 1 / 16, 0)
|
|
43
|
+
|
|
44
|
+
/** Vanilla 1.17.1 oarlock positions in OBJ-local blocks (after geometry delta). */
|
|
45
|
+
export const BOAT_PADDLE_LEFT_PIVOT = new THREE.Vector3(-3 / 16, 29 / 16, 9 / 16)
|
|
46
|
+
export const BOAT_PADDLE_RIGHT_PIVOT = new THREE.Vector3(-3 / 16, 29 / 16, -9 / 16)
|
|
47
|
+
|
|
48
|
+
/** Entity-space mapping for OBJ-local points (mesh `rotation.y = −π/2`, `BOAT_OBJ_OFFSET_Y`). */
|
|
49
|
+
export function boatObjLocalToEntitySpace(objX: number, objY: number, objZ: number): THREE.Vector3 {
|
|
50
|
+
return new THREE.Vector3(-objZ, objY + BOAT_OBJ_OFFSET_Y, objX)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const _rootLocalPos = new THREE.Vector3()
|
|
54
|
+
|
|
55
|
+
function getRootLocalPosition(object: THREE.Object3D, root: THREE.Object3D): THREE.Vector3 {
|
|
56
|
+
_rootLocalPos.set(0, 0, 0)
|
|
57
|
+
let current: THREE.Object3D | null = object
|
|
58
|
+
while (current && current !== root) {
|
|
59
|
+
_rootLocalPos.add(current.position)
|
|
60
|
+
current = current.parent
|
|
61
|
+
}
|
|
62
|
+
return _rootLocalPos
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createBoatPaddlePivot(
|
|
66
|
+
root: THREE.Object3D,
|
|
67
|
+
pivotName: string,
|
|
68
|
+
sourcePivot: THREE.Vector3,
|
|
69
|
+
targetPivot: THREE.Vector3,
|
|
70
|
+
paddleMeshes: THREE.Object3D[]
|
|
71
|
+
): THREE.Object3D | undefined {
|
|
72
|
+
if (paddleMeshes.length === 0) return undefined
|
|
73
|
+
|
|
74
|
+
const pivot = new THREE.Object3D()
|
|
75
|
+
pivot.name = pivotName
|
|
76
|
+
pivot.position.copy(targetPivot)
|
|
77
|
+
root.add(pivot)
|
|
78
|
+
|
|
79
|
+
for (const mesh of paddleMeshes) {
|
|
80
|
+
const parent = mesh.parent
|
|
81
|
+
if (!parent) continue
|
|
82
|
+
const rootLocal = getRootLocalPosition(mesh, root)
|
|
83
|
+
parent.remove(mesh)
|
|
84
|
+
// Offset from source oarlock; pivot sits at target so root-local geometry shifts by delta.
|
|
85
|
+
mesh.position.copy(rootLocal).sub(sourcePivot)
|
|
86
|
+
pivot.add(mesh)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return pivot
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function collectBoatPaddleMeshes(root: THREE.Object3D, sideName: 'paddle_left' | 'paddle_right'): THREE.Object3D[] {
|
|
93
|
+
const meshes: THREE.Object3D[] = []
|
|
94
|
+
root.traverse(child => {
|
|
95
|
+
if (child === root) return
|
|
96
|
+
if (child.name === BOAT_WATER_PATCH_NAME) return
|
|
97
|
+
if (child.name === BOAT_PADDLE_PIVOT_LEFT_NAME || child.name === BOAT_PADDLE_PIVOT_RIGHT_NAME) return
|
|
98
|
+
if (child.name === sideName) meshes.push(child)
|
|
99
|
+
})
|
|
100
|
+
return meshes
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function setupBoatPaddlePivots(root: THREE.Object3D): {
|
|
104
|
+
leftPivot?: THREE.Object3D
|
|
105
|
+
rightPivot?: THREE.Object3D
|
|
106
|
+
} {
|
|
107
|
+
if (root.userData.boatPaddleLeftPivot || root.userData.boatPaddleRightPivot) {
|
|
108
|
+
return {
|
|
109
|
+
leftPivot: root.userData.boatPaddleLeftPivot as THREE.Object3D | undefined,
|
|
110
|
+
rightPivot: root.userData.boatPaddleRightPivot as THREE.Object3D | undefined
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const leftPivot = createBoatPaddlePivot(
|
|
115
|
+
root,
|
|
116
|
+
BOAT_PADDLE_PIVOT_LEFT_NAME,
|
|
117
|
+
BOAT_PADDLE_SOURCE_LEFT,
|
|
118
|
+
BOAT_PADDLE_LEFT_PIVOT,
|
|
119
|
+
collectBoatPaddleMeshes(root, 'paddle_left')
|
|
120
|
+
)
|
|
121
|
+
const rightPivot = createBoatPaddlePivot(
|
|
122
|
+
root,
|
|
123
|
+
BOAT_PADDLE_PIVOT_RIGHT_NAME,
|
|
124
|
+
BOAT_PADDLE_SOURCE_RIGHT,
|
|
125
|
+
BOAT_PADDLE_RIGHT_PIVOT,
|
|
126
|
+
collectBoatPaddleMeshes(root, 'paddle_right')
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if (leftPivot) root.userData.boatPaddleLeftPivot = leftPivot
|
|
130
|
+
if (rightPivot) root.userData.boatPaddleRightPivot = rightPivot
|
|
131
|
+
|
|
132
|
+
return { leftPivot, rightPivot }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getBoatMeshYawOffset(): number {
|
|
136
|
+
return BOAT_MESH_YAW_OFFSET
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createBoatWaterPatchGeometry(): THREE.BoxGeometry {
|
|
140
|
+
return new THREE.BoxGeometry(BOAT_WATER_PATCH_WIDTH, BOAT_WATER_PATCH_HEIGHT, BOAT_WATER_PATCH_DEPTH)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createBoatWaterPatchMaterial(): THREE.MeshBasicMaterial {
|
|
144
|
+
return new THREE.MeshBasicMaterial({
|
|
145
|
+
colorWrite: false,
|
|
146
|
+
depthWrite: true,
|
|
147
|
+
depthTest: true
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function createBoatWaterPatchMesh(): THREE.Mesh {
|
|
152
|
+
const mesh = new THREE.Mesh(createBoatWaterPatchGeometry(), createBoatWaterPatchMaterial())
|
|
153
|
+
mesh.name = BOAT_WATER_PATCH_NAME
|
|
154
|
+
mesh.position.set(0, BOAT_WATER_PATCH_CENTER_Y, BOAT_WATER_PATCH_CENTER_Z)
|
|
155
|
+
mesh.renderOrder = BOAT_WATER_PATCH_RENDER_ORDER
|
|
156
|
+
return mesh
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function applyBoatHullRenderSettings(root: THREE.Object3D): void {
|
|
160
|
+
root.traverse(child => {
|
|
161
|
+
if (!(child instanceof THREE.Mesh)) return
|
|
162
|
+
if (child.name === BOAT_WATER_PATCH_NAME) return
|
|
163
|
+
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
|
164
|
+
for (const material of materials) {
|
|
165
|
+
if (!(material instanceof THREE.Material)) continue
|
|
166
|
+
material.transparent = false
|
|
167
|
+
material.depthWrite = true
|
|
168
|
+
material.depthTest = true
|
|
169
|
+
if ('alphaTest' in material) {
|
|
170
|
+
material.alphaTest = Math.max(material.alphaTest ?? 0, 0.1)
|
|
171
|
+
}
|
|
172
|
+
material.needsUpdate = true
|
|
173
|
+
}
|
|
174
|
+
child.renderOrder = BOAT_HULL_RENDER_ORDER
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function getBoatWaterPatchEntitySpaceBounds(objOffsetY = BOAT_OBJ_OFFSET_Y) {
|
|
179
|
+
const centerY = objOffsetY + BOAT_WATER_PATCH_CENTER_Y
|
|
180
|
+
const halfHeight = BOAT_WATER_PATCH_HEIGHT / 2
|
|
181
|
+
const halfWidth = BOAT_WATER_PATCH_WIDTH / 2
|
|
182
|
+
const halfDepth = BOAT_WATER_PATCH_DEPTH / 2
|
|
183
|
+
return {
|
|
184
|
+
minX: -halfWidth,
|
|
185
|
+
maxX: halfWidth,
|
|
186
|
+
minY: centerY - halfHeight,
|
|
187
|
+
maxY: centerY + halfHeight,
|
|
188
|
+
minZ: BOAT_WATER_PATCH_CENTER_Z - halfDepth,
|
|
189
|
+
maxZ: BOAT_WATER_PATCH_CENTER_Z + halfDepth
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function setupBoatMesh(root: THREE.Object3D): { waterPatch: THREE.Mesh } {
|
|
194
|
+
applyBoatHullRenderSettings(root)
|
|
195
|
+
setupBoatPaddlePivots(root)
|
|
196
|
+
let waterPatch = root.userData.boatWaterPatch as THREE.Mesh | undefined
|
|
197
|
+
if (!waterPatch) {
|
|
198
|
+
waterPatch = createBoatWaterPatchMesh()
|
|
199
|
+
waterPatch.visible = false
|
|
200
|
+
root.add(waterPatch)
|
|
201
|
+
root.userData.boatWaterPatch = waterPatch
|
|
202
|
+
}
|
|
203
|
+
return { waterPatch }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function disposeBoatWaterPatch(root: THREE.Object3D): void {
|
|
207
|
+
const patch = root.userData.boatWaterPatch as THREE.Mesh | undefined
|
|
208
|
+
if (!patch) return
|
|
209
|
+
patch.geometry.dispose()
|
|
210
|
+
if (patch.material instanceof THREE.Material) {
|
|
211
|
+
patch.material.dispose()
|
|
212
|
+
}
|
|
213
|
+
delete root.userData.boatWaterPatch
|
|
214
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import { expect, test } from 'vitest'
|
|
3
|
+
import { resolveEntityHeadPose } from './entityHeadPose'
|
|
4
|
+
|
|
5
|
+
test('uses entity pitch and network head yaw as separate angles', () => {
|
|
6
|
+
expect(resolveEntityHeadPose({ pitch: 0.25, yaw: 1, headYaw: -0.5 })).toEqual({ pitch: 0.25, headYaw: -0.5 })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test('falls back to body yaw when head yaw is unavailable', () => {
|
|
10
|
+
expect(resolveEntityHeadPose({ pitch: -0.2, yaw: 1.25 })).toEqual({ pitch: -0.2, headYaw: 1.25 })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('does not propagate missing or non-finite rotations', () => {
|
|
14
|
+
expect(resolveEntityHeadPose({ pitch: Number.NaN, yaw: Number.POSITIVE_INFINITY, headYaw: undefined })).toEqual({ pitch: 0, headYaw: 0 })
|
|
15
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
export type EntityHeadPoseInput = {
|
|
3
|
+
pitch?: unknown
|
|
4
|
+
yaw?: unknown
|
|
5
|
+
headYaw?: unknown
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type EntityHeadPose = {
|
|
9
|
+
pitch: number
|
|
10
|
+
headYaw: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveEntityHeadPose(entity: EntityHeadPoseInput): EntityHeadPose {
|
|
14
|
+
const pitch = typeof entity.pitch === 'number' && Number.isFinite(entity.pitch) ? entity.pitch : 0
|
|
15
|
+
const headYaw =
|
|
16
|
+
typeof entity.headYaw === 'number' && Number.isFinite(entity.headYaw)
|
|
17
|
+
? entity.headYaw
|
|
18
|
+
: typeof entity.yaw === 'number' && Number.isFinite(entity.yaw)
|
|
19
|
+
? entity.yaw
|
|
20
|
+
: 0
|
|
21
|
+
return { pitch, headYaw }
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import { expect, test } from 'vitest'
|
|
3
|
+
import { VANILLA_117_HORSE_ENTITY_SCALES } from './horseEntityScale'
|
|
4
|
+
|
|
5
|
+
test('vanilla 1.17.1 horse family entity scales', () => {
|
|
6
|
+
expect(VANILLA_117_HORSE_ENTITY_SCALES.horse).toBe(1.1)
|
|
7
|
+
expect(VANILLA_117_HORSE_ENTITY_SCALES.donkey).toBe(0.87)
|
|
8
|
+
expect(VANILLA_117_HORSE_ENTITY_SCALES.mule).toBe(0.92)
|
|
9
|
+
expect(VANILLA_117_HORSE_ENTITY_SCALES.skeleton_horse).toBe(1.0)
|
|
10
|
+
expect(VANILLA_117_HORSE_ENTITY_SCALES.zombie_horse).toBe(1.0)
|
|
11
|
+
})
|