From af704df2b2e3cdb3be3896df00650ad1c3fedbe5 Mon Sep 17 00:00:00 2001 From: zverev Date: Sun, 12 Jul 2026 13:44:26 +0300 Subject: [PATCH 01/20] fix: align boat models with movement heading Apply the boat-specific yaw offset at mesh level so rendered bows follow the physics heading. Add coverage for boat and raft entity variants. --- src/three/entities.ts | 5 +++ src/three/entity/boatModelRotation.test.ts | 38 ++++++++++++++++++++++ src/three/entity/boatModelRotation.ts | 13 ++++++++ 3 files changed, 56 insertions(+) create mode 100644 src/three/entity/boatModelRotation.test.ts create mode 100644 src/three/entity/boatModelRotation.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index b408254..356fcea 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -19,6 +19,7 @@ import { configurePlayerSkinMaterials, PlayerObjectType } from '../lib/createPla import { getBlockMeshFromModel } from './holdingBlock' import { createItemMesh } from './itemMesh' import * as Entity from './entity/EntityMesh' +import { getBoatMeshYawOffset } from './entity/boatModelRotation' import { getMesh } from './entity/EntityMesh' import { WalkingGeneralSwing } from './entity/animations' import { disposeObject, loadNearestFilterTexture, loadTexture, loadThreeJsTextureFromUrl } from './threeJsUtils' @@ -967,6 +968,10 @@ export class Entities { } if (!mesh) return mesh.name = 'mesh' + const boatMeshYawOffset = getBoatMeshYawOffset(entity.name) + if (boatMeshYawOffset != null) { + mesh.rotation.y = boatMeshYawOffset + } // set initial position so there are no weird jumps update after const pos = entity.pos ?? entity.position this.worldRenderer.sceneOrigin.track(group) diff --git a/src/three/entity/boatModelRotation.test.ts b/src/three/entity/boatModelRotation.test.ts new file mode 100644 index 0000000..7dbc495 --- /dev/null +++ b/src/three/entity/boatModelRotation.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'vitest' +import { + BOAT_MESH_YAW_OFFSET, + getBoatMeshYawOffset, + isBoatEntityName, +} from './boatModelRotation' + +test('boat mesh yaw offset is -90°', () => { + expect(BOAT_MESH_YAW_OFFSET).toBe(-Math.PI / 2) +}) + +test.each([ + 'boat', + 'chest_boat', + 'oak_boat', + 'spruce_boat', + 'birch_boat', + 'jungle_boat', + 'acacia_boat', + 'dark_oak_boat', + 'mangrove_boat', + 'cherry_boat', + 'bamboo_raft', +])('detects boat entity %s', (name) => { + expect(isBoatEntityName(name)).toBe(true) + expect(getBoatMeshYawOffset(name)).toBe(BOAT_MESH_YAW_OFFSET) +}) + +test.each([ + 'minecart', + 'horse', + 'player', + 'item', + 'oak_boat_with_chest', +])('ignores non-boat entity %s', (name) => { + expect(isBoatEntityName(name)).toBe(false) + expect(getBoatMeshYawOffset(name)).toBeNull() +}) diff --git a/src/three/entity/boatModelRotation.ts b/src/three/entity/boatModelRotation.ts new file mode 100644 index 0000000..de993d4 --- /dev/null +++ b/src/three/entity/boatModelRotation.ts @@ -0,0 +1,13 @@ +/** OBJ boat models face -X at yaw=0; physics forward is -Z → rotate mesh locally. */ +export const BOAT_MESH_YAW_OFFSET = -Math.PI / 2 + +export function isBoatEntityName (entityName: string | undefined): boolean { + if (!entityName) return false + const name = entityName.toLowerCase() + if (name === 'boat' || name === 'chest_boat') return true + return name.endsWith('_boat') || name.endsWith('_raft') +} + +export function getBoatMeshYawOffset (entityName: string | undefined): number | null { + return isBoatEntityName(entityName) ? BOAT_MESH_YAW_OFFSET : null +} From 082047fd3e390aad38c890310de4199a4525bd76 Mon Sep 17 00:00:00 2001 From: zverev Date: Sun, 12 Jul 2026 18:53:43 +0300 Subject: [PATCH 02/20] fix(render): stabilize local boats and mask water Keep the controlled boat aligned with camera-smoothed movement while preserving its authoritative height. Add a vanilla-shaped depth mask and opaque hull ordering to hide water inside boats. --- src/three/entities.ts | 84 ++++++++++--- src/three/entity/boatRenderSetup.test.ts | 84 +++++++++++++ src/three/entity/boatRenderSetup.ts | 112 ++++++++++++++++++ src/three/entity/interpolationPolicy.test.ts | 54 +++++++++ src/three/entity/interpolationPolicy.ts | 38 ++++++ .../localVehicleRendering.integration.test.ts | 65 ++++++++++ src/three/worldRendererThree.ts | 3 +- 7 files changed, 420 insertions(+), 20 deletions(-) create mode 100644 src/three/entity/boatRenderSetup.test.ts create mode 100644 src/three/entity/boatRenderSetup.ts create mode 100644 src/three/entity/interpolationPolicy.test.ts create mode 100644 src/three/entity/interpolationPolicy.ts create mode 100644 src/three/entity/localVehicleRendering.integration.test.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index 356fcea..e6d06d9 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -19,7 +19,9 @@ import { configurePlayerSkinMaterials, PlayerObjectType } from '../lib/createPla import { getBlockMeshFromModel } from './holdingBlock' import { createItemMesh } from './itemMesh' import * as Entity from './entity/EntityMesh' -import { getBoatMeshYawOffset } from './entity/boatModelRotation' +import { setupBoatMesh, disposeBoatWaterPatch } from './entity/boatRenderSetup' +import { getBoatMeshYawOffset, isBoatEntityName } from './entity/boatModelRotation' +import { ENTITY_TWEEN_DURATION_MS, getEntityTweenDurationMs, getLocalVehicleWorldPosition, type EntityRenderHints, usesCameraSyncedVehiclePosition } from './entity/interpolationPolicy' import { getMesh } from './entity/EntityMesh' import { WalkingGeneralSwing } from './entity/animations' import { disposeObject, loadNearestFilterTexture, loadTexture, loadThreeJsTextureFromUrl } from './threeJsUtils' @@ -41,7 +43,7 @@ type EntityMetadataVersions = { export const steveTexture = loadThreeJsTextureFromUrl(stevePngUrl) -export const TWEEN_DURATION = 120 +export const TWEEN_DURATION = ENTITY_TWEEN_DURATION_MS const degreesToRadians = (degrees: number) => degrees * (Math.PI / 180) @@ -265,7 +267,7 @@ export type SceneEntity = THREE.Object3D & { username?: string uuid?: string additionalCleanup?: () => void - originalEntity: import('prismarine-entity').Entity & { delete?; pos?; name; team?: Team } + originalEntity: import('prismarine-entity').Entity & { delete?; pos?; name; team?: Team; renderHints?: EntityRenderHints } } export class Entities { @@ -462,6 +464,15 @@ export class Entities { this.maybeRenderPlayerSkin(entityIdRaw) } + if (entity.userData.renderHints?.localVehicle) { + const vehicleY = entity.userData._localVehicleY ?? entity.position.y + const worldPos = getLocalVehicleWorldPosition( + this.worldRenderer.cameraWorldPos, + vehicleY, + ) + entity.position.set(worldPos.x, worldPos.y, worldPos.z) + } + if (entity.visible) { this.syncArmorPositions(entity) } @@ -878,6 +889,8 @@ export class Entities { if (!e) return e.userData._posTween?.stop() e.userData._rotTween?.stop() + const boatMesh = e.children.find(c => c.name === 'mesh') + if (boatMesh) disposeBoatWaterPatch(boatMesh) if (e.additionalCleanup) e.additionalCleanup() e.traverse(c => { if (c['additionalCleanup']) c['additionalCleanup']() @@ -971,6 +984,7 @@ export class Entities { const boatMeshYawOffset = getBoatMeshYawOffset(entity.name) if (boatMeshYawOffset != null) { mesh.rotation.y = boatMeshYawOffset + setupBoatMesh(mesh) } // set initial position so there are no weird jumps update after const pos = entity.pos ?? entity.position @@ -1002,8 +1016,13 @@ export class Entities { this.afterAddEntity(entity) } else { mesh = e.children.find(c => c.name === 'mesh') + if (entity.renderHints) { + e.originalEntity.renderHints = entity.renderHints + } } + this.applyEntityRenderHints(e, entity) + // Update equipment this.updateEntityEquipment(e, entity) @@ -1195,26 +1214,53 @@ export class Entities { this.updateEntityPosition(entity, justAdded, overrides) } - updateEntityPosition(entity: import('prismarine-entity').Entity, justAdded: boolean, overrides: { rotation?: { head?: { y: number; x: number } } }) { + applyEntityRenderHints (e: SceneEntity, entity: SceneEntity['originalEntity']) { + if (entity.renderHints) { + e.userData.renderHints = entity.renderHints + } + if (!isBoatEntityName(entity.name)) return + const mesh = e.children.find(c => c.name === 'mesh') + if (!mesh) return + const waterPatch = mesh.userData.boatWaterPatch as THREE.Object3D | undefined + if (waterPatch) { + waterPatch.visible = entity.renderHints?.boatWaterPatchVisible === true + } + } + + updateEntityPosition(entity: SceneEntity['originalEntity'], justAdded: boolean, overrides: { rotation?: { head?: { y: number; x: number } } }) { const e = this.entities[entity.id] if (!e) return - const ANIMATION_DURATION = justAdded ? 0 : TWEEN_DURATION + this.applyEntityRenderHints(e, entity) + const cameraSynced = usesCameraSyncedVehiclePosition(entity) if (entity.position) { - // Initialize tween target from current world position - const currentWorld = this.worldRenderer.sceneOrigin.getWorldPosition(e) ?? { x: entity.position.x, y: entity.position.y, z: entity.position.z } - if (!e.userData._tweenTarget) { - e.userData._tweenTarget = { x: currentWorld.x, y: currentWorld.y, z: currentWorld.z } + if (cameraSynced) { + e.userData._localVehicleY = entity.position.y + e.userData._posTween?.stop() + e.userData._posTween = undefined + const worldPos = getLocalVehicleWorldPosition( + this.worldRenderer.cameraWorldPos, + entity.position.y, + ) + e.position.set(worldPos.x, worldPos.y, worldPos.z) + } else { + const ANIMATION_DURATION = getEntityTweenDurationMs(entity, justAdded) + // Initialize tween target from current world position + const currentWorld = this.worldRenderer.sceneOrigin.getWorldPosition(e) ?? { x: entity.position.x, y: entity.position.y, z: entity.position.z } + if (!e.userData._tweenTarget) { + e.userData._tweenTarget = { x: currentWorld.x, y: currentWorld.y, z: currentWorld.z } + } + // Stop previous position tween to prevent accumulation + e.userData._posTween?.stop() + // Tween a separate target object, apply via proxy on each update + e.userData._posTween = new TWEEN.Tween(e.userData._tweenTarget) + .to({ x: entity.position.x, y: entity.position.y, z: entity.position.z }, ANIMATION_DURATION) + .onUpdate(() => { + e.position.set(e.userData._tweenTarget.x, e.userData._tweenTarget.y, e.userData._tweenTarget.z) + }) + .start() } - // Stop previous position tween to prevent accumulation - e.userData._posTween?.stop() - // Tween a separate target object, apply via proxy on each update - e.userData._posTween = new TWEEN.Tween(e.userData._tweenTarget) - .to({ x: entity.position.x, y: entity.position.y, z: entity.position.z }, ANIMATION_DURATION) - .onUpdate(() => { - e.position.set(e.userData._tweenTarget.x, e.userData._tweenTarget.y, e.userData._tweenTarget.z) - }) - .start() } + const rotationTweenDuration = justAdded ? 0 : ENTITY_TWEEN_DURATION_MS /** World yaw for the whole model: for PlayerObject skins, rotate body to head look dir; head mesh stays yaw-fixed (pitch only). */ let targetYaw: number | undefined if (e.playerObject && overrides?.rotation?.head) { @@ -1230,7 +1276,7 @@ export class Entities { const dy = shortestYawRadians(e.rotation.y, targetYaw) // Stop previous rotation tween to prevent accumulation (mirror _posTween) e.userData._rotTween?.stop() - e.userData._rotTween = new TWEEN.Tween(e.rotation).to({ y: e.rotation.y + dy }, ANIMATION_DURATION).start() + e.userData._rotTween = new TWEEN.Tween(e.rotation).to({ y: e.rotation.y + dy }, rotationTweenDuration).start() } if (e?.playerObject && overrides?.rotation?.head) { diff --git a/src/three/entity/boatRenderSetup.test.ts b/src/three/entity/boatRenderSetup.test.ts new file mode 100644 index 0000000..3e83511 --- /dev/null +++ b/src/three/entity/boatRenderSetup.test.ts @@ -0,0 +1,84 @@ +import * as THREE from 'three' +import { expect, test } from 'vitest' +import { + BOAT_HULL_RENDER_ORDER, + BOAT_OBJ_OFFSET_Y, + BOAT_WATER_PATCH_CENTER_Y, + BOAT_WATER_PATCH_CENTER_Z, + BOAT_WATER_PATCH_DEPTH, + BOAT_WATER_PATCH_HEIGHT, + BOAT_WATER_PATCH_NAME, + BOAT_WATER_PATCH_RENDER_ORDER, + BOAT_WATER_PATCH_WIDTH, + BOAT_WATER_PATCH_WORLD_BOUNDS, + applyBoatHullRenderSettings, + createBoatWaterPatchMesh, + getBoatWaterPatchEntitySpaceBounds, + setupBoatMesh, +} from './boatRenderSetup' + +test('water patch uses vanilla dimensions in blocks', () => { + const patch = createBoatWaterPatchMesh() + const size = new THREE.Vector3() + patch.geometry.computeBoundingBox() + patch.geometry.boundingBox!.getSize(size) + expect(size.x).toBeCloseTo(BOAT_WATER_PATCH_WIDTH, 5) + expect(size.y).toBeCloseTo(BOAT_WATER_PATCH_HEIGHT, 5) + expect(size.z).toBeCloseTo(BOAT_WATER_PATCH_DEPTH, 5) +}) + +test('water patch local center matches vanilla OBJ placement', () => { + const patch = createBoatWaterPatchMesh() + expect(patch.position.x).toBe(0) + expect(patch.position.y).toBeCloseTo(BOAT_WATER_PATCH_CENTER_Y, 5) + expect(patch.position.z).toBeCloseTo(BOAT_WATER_PATCH_CENTER_Z, 5) +}) + +test('water patch entity-space bounds match vanilla world-relative bounds', () => { + const bounds = getBoatWaterPatchEntitySpaceBounds(BOAT_OBJ_OFFSET_Y) + expect(bounds.minX).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.minX, 5) + expect(bounds.maxX).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.maxX, 5) + expect(bounds.minY).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.minY, 5) + expect(bounds.maxY).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.maxY, 5) + expect(bounds.minZ).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.minZ, 5) + expect(bounds.maxZ).toBeCloseTo(BOAT_WATER_PATCH_WORLD_BOUNDS.maxZ, 5) +}) + +test('water patch material is depth-only', () => { + const patch = createBoatWaterPatchMesh() + const material = patch.material as THREE.MeshBasicMaterial + expect(material.colorWrite).toBe(false) + expect(material.depthWrite).toBe(true) + expect(material.depthTest).toBe(true) +}) + +test('boat hull renders in opaque list before depth-only patch', () => { + const root = new THREE.Object3D() + const hull = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.1 }), + ) + hull.name = 'bottom' + root.add(hull) + const { waterPatch } = setupBoatMesh(root) + + expect(hull.renderOrder).toBe(BOAT_HULL_RENDER_ORDER) + expect(waterPatch.renderOrder).toBe(BOAT_WATER_PATCH_RENDER_ORDER) + expect(waterPatch.renderOrder).toBeGreaterThan(hull.renderOrder) + expect(waterPatch.name).toBe(BOAT_WATER_PATCH_NAME) +}) + +test('boat hull material is alpha-tested opaque with depth write', () => { + const root = new THREE.Object3D() + const hull = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.05 }), + ) + root.add(hull) + applyBoatHullRenderSettings(root) + const material = hull.material as THREE.MeshBasicMaterial + expect(material.transparent).toBe(false) + expect(material.alphaTest).toBeGreaterThanOrEqual(0.1) + expect(material.depthWrite).toBe(true) + expect(material.depthTest).toBe(true) +}) diff --git a/src/three/entity/boatRenderSetup.ts b/src/three/entity/boatRenderSetup.ts new file mode 100644 index 0000000..fa1867f --- /dev/null +++ b/src/three/entity/boatRenderSetup.ts @@ -0,0 +1,112 @@ +import * as THREE from 'three' +import { BOAT_MESH_YAW_OFFSET } from './boatModelRotation' + +/** Vanilla BoatModel.createWaterPatch() size in blocks (after X rotation). */ +export const BOAT_WATER_PATCH_WIDTH = 28 / 16 +export const BOAT_WATER_PATCH_HEIGHT = 3 / 16 +export const BOAT_WATER_PATCH_DEPTH = 16 / 16 + +/** OBJ boat root offset applied in EntityMesh. */ +export const BOAT_OBJ_OFFSET_Y = -1 + +/** + * Vanilla 1.17.1 water patch center in OBJ-local space. + * World-relative bounds with BOAT_OBJ_OFFSET_Y: X [-0.875,0.875], Y [0.375,0.5625], Z [-0.5,0.5]. + */ +export const BOAT_WATER_PATCH_CENTER_Y = 1.46875 +export const BOAT_WATER_PATCH_CENTER_Z = 0 + +export const BOAT_WATER_PATCH_WORLD_BOUNDS = { + minX: -0.875, + maxX: 0.875, + minY: 0.375, + maxY: 0.5625, + minZ: -0.5, + maxZ: 0.5, +} as const + +export const BOAT_HULL_RENDER_ORDER = 0 +export const BOAT_WATER_PATCH_RENDER_ORDER = 1 + +export const BOAT_WATER_PATCH_NAME = 'boat_water_patch' + +export function getBoatMeshYawOffset (): number { + return BOAT_MESH_YAW_OFFSET +} + +export function createBoatWaterPatchGeometry (): THREE.BoxGeometry { + return new THREE.BoxGeometry( + BOAT_WATER_PATCH_WIDTH, + BOAT_WATER_PATCH_HEIGHT, + BOAT_WATER_PATCH_DEPTH, + ) +} + +export function createBoatWaterPatchMaterial (): THREE.MeshBasicMaterial { + return new THREE.MeshBasicMaterial({ + colorWrite: false, + depthWrite: true, + depthTest: true, + }) +} + +export function createBoatWaterPatchMesh (): THREE.Mesh { + const mesh = new THREE.Mesh(createBoatWaterPatchGeometry(), createBoatWaterPatchMaterial()) + mesh.name = BOAT_WATER_PATCH_NAME + mesh.position.set(0, BOAT_WATER_PATCH_CENTER_Y, BOAT_WATER_PATCH_CENTER_Z) + mesh.renderOrder = BOAT_WATER_PATCH_RENDER_ORDER + return mesh +} + +export function applyBoatHullRenderSettings (root: THREE.Object3D): void { + root.traverse(child => { + if (!(child instanceof THREE.Mesh)) return + if (child.name === BOAT_WATER_PATCH_NAME) return + const materials = Array.isArray(child.material) ? child.material : [child.material] + for (const material of materials) { + if (!(material instanceof THREE.Material)) continue + material.transparent = false + material.depthWrite = true + material.depthTest = true + if ('alphaTest' in material) { + material.alphaTest = Math.max(material.alphaTest ?? 0, 0.1) + } + material.needsUpdate = true + } + child.renderOrder = BOAT_HULL_RENDER_ORDER + }) +} + +export function getBoatWaterPatchEntitySpaceBounds (objOffsetY = BOAT_OBJ_OFFSET_Y) { + const centerY = objOffsetY + BOAT_WATER_PATCH_CENTER_Y + const halfHeight = BOAT_WATER_PATCH_HEIGHT / 2 + const halfWidth = BOAT_WATER_PATCH_WIDTH / 2 + const halfDepth = BOAT_WATER_PATCH_DEPTH / 2 + return { + minX: -halfWidth, + maxX: halfWidth, + minY: centerY - halfHeight, + maxY: centerY + halfHeight, + minZ: BOAT_WATER_PATCH_CENTER_Z - halfDepth, + maxZ: BOAT_WATER_PATCH_CENTER_Z + halfDepth, + } +} + +export function setupBoatMesh (root: THREE.Object3D): { waterPatch: THREE.Mesh } { + applyBoatHullRenderSettings(root) + const waterPatch = createBoatWaterPatchMesh() + waterPatch.visible = false + root.add(waterPatch) + root.userData.boatWaterPatch = waterPatch + return { waterPatch } +} + +export function disposeBoatWaterPatch (root: THREE.Object3D): void { + const patch = root.userData.boatWaterPatch as THREE.Mesh | undefined + if (!patch) return + patch.geometry.dispose() + if (patch.material instanceof THREE.Material) { + patch.material.dispose() + } + delete root.userData.boatWaterPatch +} diff --git a/src/three/entity/interpolationPolicy.test.ts b/src/three/entity/interpolationPolicy.test.ts new file mode 100644 index 0000000..592dae7 --- /dev/null +++ b/src/three/entity/interpolationPolicy.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from 'vitest' +import { + ENTITY_TWEEN_DURATION_MS, + LOCAL_MOVEMENT_TWEEN_DURATION_MS, + getEntityTweenDurationMs, + getLocalVehicleWorldPosition, + usesCameraSyncedVehiclePosition, +} from './interpolationPolicy' + +test('local movement tween matches camera tween duration', () => { + expect(LOCAL_MOVEMENT_TWEEN_DURATION_MS).toBe(50) +}) + +test('ordinary entity tween stays at 120 ms', () => { + expect(ENTITY_TWEEN_DURATION_MS).toBe(120) +}) + +test('local vehicle skips position tween', () => { + expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true } })).toBe(true) + expect(getEntityTweenDurationMs({ renderHints: { localVehicle: true } }, false)).toBe(0) + expect(getEntityTweenDurationMs({ renderHints: { localVehicle: true } }, true)).toBe(0) +}) + +test('remote entities keep ordinary tween duration', () => { + expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: false } })).toBe(false) + expect(getEntityTweenDurationMs({ renderHints: { localVehicle: false } }, false)).toBe(120) + expect(getEntityTweenDurationMs({}, false)).toBe(120) + expect(getEntityTweenDurationMs(undefined, false)).toBe(120) +}) + +test('local vehicle world position uses camera X/Z and vehicle Y', () => { + const camera = { x: 10, y: 64.5, z: -3 } + expect(getLocalVehicleWorldPosition(camera, 63.2)).toEqual({ + x: 10, + y: 63.2, + z: -3, + }) +}) + +test('intermediate camera frames keep boat X/Z aligned with player', () => { + const vehicleY = 63.2 + const start = getLocalVehicleWorldPosition({ x: 0, y: 64, z: 0 }, vehicleY) + const mid = getLocalVehicleWorldPosition({ x: 0.5, y: 64, z: 0 }, vehicleY) + const end = getLocalVehicleWorldPosition({ x: 1, y: 64, z: 0 }, vehicleY) + + expect(start.x).toBe(0) + expect(mid.x).toBe(0.5) + expect(end.x).toBe(1) + expect(start.z).toBe(mid.z) + expect(mid.z).toBe(end.z) + expect(start.y).toBe(vehicleY) + expect(mid.y).toBe(vehicleY) + expect(end.y).toBe(vehicleY) +}) diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts new file mode 100644 index 0000000..b08bdf8 --- /dev/null +++ b/src/three/entity/interpolationPolicy.ts @@ -0,0 +1,38 @@ +export const LOCAL_MOVEMENT_TWEEN_DURATION_MS = 50 +export const ENTITY_TWEEN_DURATION_MS = 120 + +export type Vec3Like = { x: number; y: number; z: number } + +export type EntityRenderHints = { + localVehicle?: boolean + boatWaterPatchVisible?: boolean +} + +export type EntityWithRenderHints = { + renderHints?: EntityRenderHints +} + +export function usesCameraSyncedVehiclePosition (entity: EntityWithRenderHints | undefined): boolean { + return !!entity?.renderHints?.localVehicle +} + +export function getEntityTweenDurationMs ( + entity: EntityWithRenderHints | undefined, + justAdded: boolean, +): number { + if (justAdded) return 0 + if (usesCameraSyncedVehiclePosition(entity)) return 0 + return ENTITY_TWEEN_DURATION_MS +} + +/** Local vehicle X/Z follow camera tween; Y uses latest vehicle physics height. */ +export function getLocalVehicleWorldPosition ( + cameraWorldPos: Vec3Like, + vehicleY: number, +): Vec3Like { + return { + x: cameraWorldPos.x, + y: vehicleY, + z: cameraWorldPos.z, + } +} diff --git a/src/three/entity/localVehicleRendering.integration.test.ts b/src/three/entity/localVehicleRendering.integration.test.ts new file mode 100644 index 0000000..4149e32 --- /dev/null +++ b/src/three/entity/localVehicleRendering.integration.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from 'vitest' +import { getLocalVehicleWorldPosition } from './interpolationPolicy' + +type Vec3 = { x: number; y: number; z: number } + +function staleOffsetVehiclePosition ( + cameraWorldPos: Vec3, + vehiclePosition: Vec3, + stalePassengerPosition: Vec3, +): Vec3 { + return { + x: cameraWorldPos.x + (vehiclePosition.x - stalePassengerPosition.x), + y: cameraWorldPos.y + (vehiclePosition.y - stalePassengerPosition.y), + z: cameraWorldPos.z + (vehiclePosition.z - stalePassengerPosition.z), + } +} + +test('stale passenger snapshot does not shift rendered boat X/Z relative to camera', () => { + const stalePassenger = { x: 0, y: 63, z: 0 } + const newVehicle = { x: 1, y: 63.2, z: 2 } + const cameraWorldPos = { x: 0.4, y: 63.5, z: 0.8 } + + // 1. Vehicle position updates before passenger sync. + // 2. Renderer receives vehicle snapshot while passenger is still stale. + const broken = staleOffsetVehiclePosition(cameraWorldPos, newVehicle, stalePassenger) + const rendered = getLocalVehicleWorldPosition(cameraWorldPos, newVehicle.y) + + expect(rendered.x).toBe(cameraWorldPos.x) + expect(rendered.z).toBe(cameraWorldPos.z) + expect(rendered.y).toBe(newVehicle.y) + expect(broken.x).not.toBe(cameraWorldPos.x) + expect(broken.z).not.toBe(cameraWorldPos.z) + + // 3. Passenger synchronizes to vehicle. + const syncedPassenger = { ...newVehicle, y: 63 } + const syncedCamera = { x: 0.8, y: 63.5, z: 1.6 } + + // 4. Boat X/Z remain fixed relative to the player/camera on intermediate frames. + const afterSync = getLocalVehicleWorldPosition(syncedCamera, newVehicle.y) + expect(afterSync.x - syncedCamera.x).toBe(0) + expect(afterSync.z - syncedCamera.z).toBe(0) + expect(afterSync.y).toBe(newVehicle.y) + expect(syncedPassenger.x).toBe(newVehicle.x) + expect(syncedPassenger.z).toBe(newVehicle.z) +}) + +test('render frames between press and release keep zero horizontal delta to camera', () => { + const vehicleY = 62.75 + const frames = [ + { x: 0, y: 63.4, z: 0 }, + { x: 0.2, y: 63.4, z: 0 }, + { x: 0.45, y: 63.4, z: 0 }, + { x: 0.7, y: 63.4, z: 0 }, + { x: 0.7, y: 63.4, z: 0 }, + { x: 0.55, y: 63.4, z: 0 }, + ] + + for (const camera of frames) { + const boat = getLocalVehicleWorldPosition(camera, vehicleY) + expect(boat.x - camera.x).toBe(0) + expect(boat.z - camera.z).toBe(0) + expect(boat.y).toBe(vehicleY) + expect(boat.y).not.toBe(camera.y) + } +}) diff --git a/src/three/worldRendererThree.ts b/src/three/worldRendererThree.ts index 9e8480f..0a41d32 100644 --- a/src/three/worldRendererThree.ts +++ b/src/three/worldRendererThree.ts @@ -20,6 +20,7 @@ import { disposeObject, loadThreeJsTextureFromBitmap } from './threeJsUtils' import { CursorBlock } from './world/cursorBlock' import { getItemUv } from './appShared' import { Entities } from './entities' +import { LOCAL_MOVEMENT_TWEEN_DURATION_MS } from './entity/interpolationPolicy' import { ThreeJsSound } from './threeJsSound' import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' @@ -1146,7 +1147,7 @@ export class WorldRendererThree extends WorldRendererCommon { this.currentPosTween?.stop() // Use instant camera updates (0 delay) in playground mode when camera controls are enabled - const tweenDelay = this.displayOptions.inWorldRenderingConfig.instantCameraUpdate ? 0 : this.playerStateUtils.isSpectatingEntity() ? 150 : 50 + const tweenDelay = this.displayOptions.inWorldRenderingConfig.instantCameraUpdate ? 0 : this.playerStateUtils.isSpectatingEntity() ? 150 : LOCAL_MOVEMENT_TWEEN_DURATION_MS this.currentPosTween = new tweenJs.Tween(this.cameraWorldPos) .to({ x: pos.x, y: pos.y, z: pos.z }, tweenDelay) .onUpdate(() => { From a2841140697d235072f79d61c52f2f0c75517b44 Mon Sep 17 00:00:00 2001 From: zverev Date: Sun, 12 Jul 2026 20:15:56 +0300 Subject: [PATCH 03/20] chore: format (prittier) --- src/three/entities.ts | 20 ++++++------- src/three/entity/boatModelRotation.test.ts | 18 +++--------- src/three/entity/boatModelRotation.ts | 4 +-- src/three/entity/boatRenderSetup.test.ts | 12 ++------ src/three/entity/boatRenderSetup.ts | 28 ++++++++----------- src/three/entity/interpolationPolicy.test.ts | 4 +-- src/three/entity/interpolationPolicy.ts | 14 +++------- .../localVehicleRendering.integration.test.ts | 10 ++----- src/three/worldRendererThree.ts | 6 +++- 9 files changed, 45 insertions(+), 71 deletions(-) diff --git a/src/three/entities.ts b/src/three/entities.ts index e6d06d9..be11009 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -21,7 +21,13 @@ import { createItemMesh } from './itemMesh' import * as Entity from './entity/EntityMesh' import { setupBoatMesh, disposeBoatWaterPatch } from './entity/boatRenderSetup' import { getBoatMeshYawOffset, isBoatEntityName } from './entity/boatModelRotation' -import { ENTITY_TWEEN_DURATION_MS, getEntityTweenDurationMs, getLocalVehicleWorldPosition, type EntityRenderHints, usesCameraSyncedVehiclePosition } from './entity/interpolationPolicy' +import { + ENTITY_TWEEN_DURATION_MS, + getEntityTweenDurationMs, + getLocalVehicleWorldPosition, + type EntityRenderHints, + usesCameraSyncedVehiclePosition +} from './entity/interpolationPolicy' import { getMesh } from './entity/EntityMesh' import { WalkingGeneralSwing } from './entity/animations' import { disposeObject, loadNearestFilterTexture, loadTexture, loadThreeJsTextureFromUrl } from './threeJsUtils' @@ -466,10 +472,7 @@ export class Entities { if (entity.userData.renderHints?.localVehicle) { const vehicleY = entity.userData._localVehicleY ?? entity.position.y - const worldPos = getLocalVehicleWorldPosition( - this.worldRenderer.cameraWorldPos, - vehicleY, - ) + const worldPos = getLocalVehicleWorldPosition(this.worldRenderer.cameraWorldPos, vehicleY) entity.position.set(worldPos.x, worldPos.y, worldPos.z) } @@ -1214,7 +1217,7 @@ export class Entities { this.updateEntityPosition(entity, justAdded, overrides) } - applyEntityRenderHints (e: SceneEntity, entity: SceneEntity['originalEntity']) { + applyEntityRenderHints(e: SceneEntity, entity: SceneEntity['originalEntity']) { if (entity.renderHints) { e.userData.renderHints = entity.renderHints } @@ -1237,10 +1240,7 @@ export class Entities { e.userData._localVehicleY = entity.position.y e.userData._posTween?.stop() e.userData._posTween = undefined - const worldPos = getLocalVehicleWorldPosition( - this.worldRenderer.cameraWorldPos, - entity.position.y, - ) + const worldPos = getLocalVehicleWorldPosition(this.worldRenderer.cameraWorldPos, entity.position.y) e.position.set(worldPos.x, worldPos.y, worldPos.z) } else { const ANIMATION_DURATION = getEntityTweenDurationMs(entity, justAdded) diff --git a/src/three/entity/boatModelRotation.test.ts b/src/three/entity/boatModelRotation.test.ts index 7dbc495..500f8c7 100644 --- a/src/three/entity/boatModelRotation.test.ts +++ b/src/three/entity/boatModelRotation.test.ts @@ -1,9 +1,5 @@ import { expect, test } from 'vitest' -import { - BOAT_MESH_YAW_OFFSET, - getBoatMeshYawOffset, - isBoatEntityName, -} from './boatModelRotation' +import { BOAT_MESH_YAW_OFFSET, getBoatMeshYawOffset, isBoatEntityName } from './boatModelRotation' test('boat mesh yaw offset is -90°', () => { expect(BOAT_MESH_YAW_OFFSET).toBe(-Math.PI / 2) @@ -20,19 +16,13 @@ test.each([ 'dark_oak_boat', 'mangrove_boat', 'cherry_boat', - 'bamboo_raft', -])('detects boat entity %s', (name) => { + 'bamboo_raft' +])('detects boat entity %s', name => { expect(isBoatEntityName(name)).toBe(true) expect(getBoatMeshYawOffset(name)).toBe(BOAT_MESH_YAW_OFFSET) }) -test.each([ - 'minecart', - 'horse', - 'player', - 'item', - 'oak_boat_with_chest', -])('ignores non-boat entity %s', (name) => { +test.each(['minecart', 'horse', 'player', 'item', 'oak_boat_with_chest'])('ignores non-boat entity %s', name => { expect(isBoatEntityName(name)).toBe(false) expect(getBoatMeshYawOffset(name)).toBeNull() }) diff --git a/src/three/entity/boatModelRotation.ts b/src/three/entity/boatModelRotation.ts index de993d4..a9db567 100644 --- a/src/three/entity/boatModelRotation.ts +++ b/src/three/entity/boatModelRotation.ts @@ -1,13 +1,13 @@ /** OBJ boat models face -X at yaw=0; physics forward is -Z → rotate mesh locally. */ export const BOAT_MESH_YAW_OFFSET = -Math.PI / 2 -export function isBoatEntityName (entityName: string | undefined): boolean { +export function isBoatEntityName(entityName: string | undefined): boolean { if (!entityName) return false const name = entityName.toLowerCase() if (name === 'boat' || name === 'chest_boat') return true return name.endsWith('_boat') || name.endsWith('_raft') } -export function getBoatMeshYawOffset (entityName: string | undefined): number | null { +export function getBoatMeshYawOffset(entityName: string | undefined): number | null { return isBoatEntityName(entityName) ? BOAT_MESH_YAW_OFFSET : null } diff --git a/src/three/entity/boatRenderSetup.test.ts b/src/three/entity/boatRenderSetup.test.ts index 3e83511..e5cf61a 100644 --- a/src/three/entity/boatRenderSetup.test.ts +++ b/src/three/entity/boatRenderSetup.test.ts @@ -14,7 +14,7 @@ import { applyBoatHullRenderSettings, createBoatWaterPatchMesh, getBoatWaterPatchEntitySpaceBounds, - setupBoatMesh, + setupBoatMesh } from './boatRenderSetup' test('water patch uses vanilla dimensions in blocks', () => { @@ -54,10 +54,7 @@ test('water patch material is depth-only', () => { test('boat hull renders in opaque list before depth-only patch', () => { const root = new THREE.Object3D() - const hull = new THREE.Mesh( - new THREE.BoxGeometry(1, 1, 1), - new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.1 }), - ) + const hull = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.1 })) hull.name = 'bottom' root.add(hull) const { waterPatch } = setupBoatMesh(root) @@ -70,10 +67,7 @@ test('boat hull renders in opaque list before depth-only patch', () => { test('boat hull material is alpha-tested opaque with depth write', () => { const root = new THREE.Object3D() - const hull = new THREE.Mesh( - new THREE.BoxGeometry(1, 1, 1), - new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.05 }), - ) + const hull = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.05 })) root.add(hull) applyBoatHullRenderSettings(root) const material = hull.material as THREE.MeshBasicMaterial diff --git a/src/three/entity/boatRenderSetup.ts b/src/three/entity/boatRenderSetup.ts index fa1867f..728b42f 100644 --- a/src/three/entity/boatRenderSetup.ts +++ b/src/three/entity/boatRenderSetup.ts @@ -22,7 +22,7 @@ export const BOAT_WATER_PATCH_WORLD_BOUNDS = { minY: 0.375, maxY: 0.5625, minZ: -0.5, - maxZ: 0.5, + maxZ: 0.5 } as const export const BOAT_HULL_RENDER_ORDER = 0 @@ -30,27 +30,23 @@ export const BOAT_WATER_PATCH_RENDER_ORDER = 1 export const BOAT_WATER_PATCH_NAME = 'boat_water_patch' -export function getBoatMeshYawOffset (): number { +export function getBoatMeshYawOffset(): number { return BOAT_MESH_YAW_OFFSET } -export function createBoatWaterPatchGeometry (): THREE.BoxGeometry { - return new THREE.BoxGeometry( - BOAT_WATER_PATCH_WIDTH, - BOAT_WATER_PATCH_HEIGHT, - BOAT_WATER_PATCH_DEPTH, - ) +export function createBoatWaterPatchGeometry(): THREE.BoxGeometry { + return new THREE.BoxGeometry(BOAT_WATER_PATCH_WIDTH, BOAT_WATER_PATCH_HEIGHT, BOAT_WATER_PATCH_DEPTH) } -export function createBoatWaterPatchMaterial (): THREE.MeshBasicMaterial { +export function createBoatWaterPatchMaterial(): THREE.MeshBasicMaterial { return new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true, - depthTest: true, + depthTest: true }) } -export function createBoatWaterPatchMesh (): THREE.Mesh { +export function createBoatWaterPatchMesh(): THREE.Mesh { const mesh = new THREE.Mesh(createBoatWaterPatchGeometry(), createBoatWaterPatchMaterial()) mesh.name = BOAT_WATER_PATCH_NAME mesh.position.set(0, BOAT_WATER_PATCH_CENTER_Y, BOAT_WATER_PATCH_CENTER_Z) @@ -58,7 +54,7 @@ export function createBoatWaterPatchMesh (): THREE.Mesh { return mesh } -export function applyBoatHullRenderSettings (root: THREE.Object3D): void { +export function applyBoatHullRenderSettings(root: THREE.Object3D): void { root.traverse(child => { if (!(child instanceof THREE.Mesh)) return if (child.name === BOAT_WATER_PATCH_NAME) return @@ -77,7 +73,7 @@ export function applyBoatHullRenderSettings (root: THREE.Object3D): void { }) } -export function getBoatWaterPatchEntitySpaceBounds (objOffsetY = BOAT_OBJ_OFFSET_Y) { +export function getBoatWaterPatchEntitySpaceBounds(objOffsetY = BOAT_OBJ_OFFSET_Y) { const centerY = objOffsetY + BOAT_WATER_PATCH_CENTER_Y const halfHeight = BOAT_WATER_PATCH_HEIGHT / 2 const halfWidth = BOAT_WATER_PATCH_WIDTH / 2 @@ -88,11 +84,11 @@ export function getBoatWaterPatchEntitySpaceBounds (objOffsetY = BOAT_OBJ_OFFSET minY: centerY - halfHeight, maxY: centerY + halfHeight, minZ: BOAT_WATER_PATCH_CENTER_Z - halfDepth, - maxZ: BOAT_WATER_PATCH_CENTER_Z + halfDepth, + maxZ: BOAT_WATER_PATCH_CENTER_Z + halfDepth } } -export function setupBoatMesh (root: THREE.Object3D): { waterPatch: THREE.Mesh } { +export function setupBoatMesh(root: THREE.Object3D): { waterPatch: THREE.Mesh } { applyBoatHullRenderSettings(root) const waterPatch = createBoatWaterPatchMesh() waterPatch.visible = false @@ -101,7 +97,7 @@ export function setupBoatMesh (root: THREE.Object3D): { waterPatch: THREE.Mesh } return { waterPatch } } -export function disposeBoatWaterPatch (root: THREE.Object3D): void { +export function disposeBoatWaterPatch(root: THREE.Object3D): void { const patch = root.userData.boatWaterPatch as THREE.Mesh | undefined if (!patch) return patch.geometry.dispose() diff --git a/src/three/entity/interpolationPolicy.test.ts b/src/three/entity/interpolationPolicy.test.ts index 592dae7..ea75b8d 100644 --- a/src/three/entity/interpolationPolicy.test.ts +++ b/src/three/entity/interpolationPolicy.test.ts @@ -4,7 +4,7 @@ import { LOCAL_MOVEMENT_TWEEN_DURATION_MS, getEntityTweenDurationMs, getLocalVehicleWorldPosition, - usesCameraSyncedVehiclePosition, + usesCameraSyncedVehiclePosition } from './interpolationPolicy' test('local movement tween matches camera tween duration', () => { @@ -33,7 +33,7 @@ test('local vehicle world position uses camera X/Z and vehicle Y', () => { expect(getLocalVehicleWorldPosition(camera, 63.2)).toEqual({ x: 10, y: 63.2, - z: -3, + z: -3 }) }) diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index b08bdf8..306e4c3 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -12,27 +12,21 @@ export type EntityWithRenderHints = { renderHints?: EntityRenderHints } -export function usesCameraSyncedVehiclePosition (entity: EntityWithRenderHints | undefined): boolean { +export function usesCameraSyncedVehiclePosition(entity: EntityWithRenderHints | undefined): boolean { return !!entity?.renderHints?.localVehicle } -export function getEntityTweenDurationMs ( - entity: EntityWithRenderHints | undefined, - justAdded: boolean, -): number { +export function getEntityTweenDurationMs(entity: EntityWithRenderHints | undefined, justAdded: boolean): number { if (justAdded) return 0 if (usesCameraSyncedVehiclePosition(entity)) return 0 return ENTITY_TWEEN_DURATION_MS } /** Local vehicle X/Z follow camera tween; Y uses latest vehicle physics height. */ -export function getLocalVehicleWorldPosition ( - cameraWorldPos: Vec3Like, - vehicleY: number, -): Vec3Like { +export function getLocalVehicleWorldPosition(cameraWorldPos: Vec3Like, vehicleY: number): Vec3Like { return { x: cameraWorldPos.x, y: vehicleY, - z: cameraWorldPos.z, + z: cameraWorldPos.z } } diff --git a/src/three/entity/localVehicleRendering.integration.test.ts b/src/three/entity/localVehicleRendering.integration.test.ts index 4149e32..d1bd0e5 100644 --- a/src/three/entity/localVehicleRendering.integration.test.ts +++ b/src/three/entity/localVehicleRendering.integration.test.ts @@ -3,15 +3,11 @@ import { getLocalVehicleWorldPosition } from './interpolationPolicy' type Vec3 = { x: number; y: number; z: number } -function staleOffsetVehiclePosition ( - cameraWorldPos: Vec3, - vehiclePosition: Vec3, - stalePassengerPosition: Vec3, -): Vec3 { +function staleOffsetVehiclePosition(cameraWorldPos: Vec3, vehiclePosition: Vec3, stalePassengerPosition: Vec3): Vec3 { return { x: cameraWorldPos.x + (vehiclePosition.x - stalePassengerPosition.x), y: cameraWorldPos.y + (vehiclePosition.y - stalePassengerPosition.y), - z: cameraWorldPos.z + (vehiclePosition.z - stalePassengerPosition.z), + z: cameraWorldPos.z + (vehiclePosition.z - stalePassengerPosition.z) } } @@ -52,7 +48,7 @@ test('render frames between press and release keep zero horizontal delta to came { x: 0.45, y: 63.4, z: 0 }, { x: 0.7, y: 63.4, z: 0 }, { x: 0.7, y: 63.4, z: 0 }, - { x: 0.55, y: 63.4, z: 0 }, + { x: 0.55, y: 63.4, z: 0 } ] for (const camera of frames) { diff --git a/src/three/worldRendererThree.ts b/src/three/worldRendererThree.ts index 0a41d32..82b9916 100644 --- a/src/three/worldRendererThree.ts +++ b/src/three/worldRendererThree.ts @@ -1147,7 +1147,11 @@ export class WorldRendererThree extends WorldRendererCommon { this.currentPosTween?.stop() // Use instant camera updates (0 delay) in playground mode when camera controls are enabled - const tweenDelay = this.displayOptions.inWorldRenderingConfig.instantCameraUpdate ? 0 : this.playerStateUtils.isSpectatingEntity() ? 150 : LOCAL_MOVEMENT_TWEEN_DURATION_MS + const tweenDelay = this.displayOptions.inWorldRenderingConfig.instantCameraUpdate + ? 0 + : this.playerStateUtils.isSpectatingEntity() + ? 150 + : LOCAL_MOVEMENT_TWEEN_DURATION_MS this.currentPosTween = new tweenJs.Tween(this.cameraWorldPos) .to({ x: pos.x, y: pos.y, z: pos.z }, tweenDelay) .onUpdate(() => { From fb910dc4bce4404da08e60edf180dc57406c150d Mon Sep 17 00:00:00 2001 From: zverev Date: Sun, 12 Jul 2026 22:37:34 +0300 Subject: [PATCH 04/20] feat(entities): anchor remote players to boat seats Render remote boat passengers at vanilla seat offsets and keep their positions synchronized with the interpolated boat movement. Release the passenger position when it is detached from the boat. --- src/three/entities.ts | 41 ++++++++++ src/three/entity/boatPassengerRendering.ts | 33 ++++++++ src/three/entity/interpolationPolicy.ts | 19 +++++ .../localVehicleRendering.integration.test.ts | 75 ++++++++++++++++++- 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 src/three/entity/boatPassengerRendering.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index be11009..f118576 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -21,8 +21,10 @@ import { createItemMesh } from './itemMesh' import * as Entity from './entity/EntityMesh' import { setupBoatMesh, disposeBoatWaterPatch } from './entity/boatRenderSetup' import { getBoatMeshYawOffset, isBoatEntityName } from './entity/boatModelRotation' +import { anchorBoatPassengerPosition, releaseBoatPassengerPosition } from './entity/boatPassengerRendering' import { ENTITY_TWEEN_DURATION_MS, + getBoatPassengerWorldPosition, getEntityTweenDurationMs, getLocalVehicleWorldPosition, type EntityRenderHints, @@ -491,6 +493,35 @@ export class Entities { }) } } + + this.updateBoatPassengerPositions() + } + + updateBoatPassengerPositions() { + const attachedPassengers = new Set() + + for (const boat of Object.values(this.entities)) { + if (!isBoatEntityName(boat['realName'] ?? boat.originalEntity.name)) continue + const passengerIds = boat.userData.renderHints?.boatPassengerIds + if (!Array.isArray(passengerIds) || passengerIds.length === 0) continue + const boatWorldPos = this.worldRenderer.sceneOrigin.getWorldPosition(boat) + if (!boatWorldPos) continue + + for (const [passengerIndex, passengerId] of passengerIds.entries()) { + const passenger = this.entities[passengerId] + if (!passenger?.playerObject) continue + const passengerWorldPos = getBoatPassengerWorldPosition(boatWorldPos, boat.rotation.y, passengerIndex, passengerIds.length) + + anchorBoatPassengerPosition(passenger, passengerWorldPos, String(boat.originalEntity.id)) + attachedPassengers.add(passenger) + } + } + + for (const passenger of Object.values(this.entities)) { + const vehicleId = passenger.userData._boatPassengerVehicleId as string | undefined + if (vehicleId === undefined || attachedPassengers.has(passenger)) continue + releaseBoatPassengerPosition(passenger, vehicleId, this.worldRenderer.sceneOrigin.getWorldPosition(passenger)) + } } private syncArmorPositions(entity: SceneEntity) { @@ -1219,6 +1250,16 @@ export class Entities { applyEntityRenderHints(e: SceneEntity, entity: SceneEntity['originalEntity']) { if (entity.renderHints) { + if (isBoatEntityName(entity.name)) { + const nextPassengerIds = new Set(entity.renderHints.boatPassengerIds ?? []) + for (const previousPassengerId of e.userData.renderHints?.boatPassengerIds ?? []) { + if (nextPassengerIds.has(previousPassengerId)) continue + const previousPassenger = this.entities[previousPassengerId] + if (previousPassenger) { + releaseBoatPassengerPosition(previousPassenger, String(entity.id), this.worldRenderer.sceneOrigin.getWorldPosition(previousPassenger)) + } + } + } e.userData.renderHints = entity.renderHints } if (!isBoatEntityName(entity.name)) return diff --git a/src/three/entity/boatPassengerRendering.ts b/src/three/entity/boatPassengerRendering.ts new file mode 100644 index 0000000..4417b8d --- /dev/null +++ b/src/three/entity/boatPassengerRendering.ts @@ -0,0 +1,33 @@ +import type { Vec3Like } from './interpolationPolicy' + +export type BoatPassengerRenderState = { + position: { + set: (x: number, y: number, z: number) => unknown + } + userData: { + _posTween?: { stop: () => unknown } + _tweenTarget?: Vec3Like + _boatPassengerVehicleId?: string + } +} + +export function anchorBoatPassengerPosition(passenger: BoatPassengerRenderState, passengerWorldPos: Vec3Like, vehicleId: string): void { + passenger.userData._posTween?.stop() + passenger.userData._posTween = undefined + passenger.userData._tweenTarget ??= { ...passengerWorldPos } + Object.assign(passenger.userData._tweenTarget, passengerWorldPos) + passenger.position.set(passengerWorldPos.x, passengerWorldPos.y, passengerWorldPos.z) + passenger.userData._boatPassengerVehicleId = vehicleId +} + +export function releaseBoatPassengerPosition(passenger: BoatPassengerRenderState, vehicleId: string, currentWorldPos: Vec3Like | undefined): boolean { + if (passenger.userData._boatPassengerVehicleId !== vehicleId) return false + passenger.userData._posTween?.stop() + passenger.userData._posTween = undefined + if (currentWorldPos) { + passenger.userData._tweenTarget ??= { ...currentWorldPos } + Object.assign(passenger.userData._tweenTarget, currentWorldPos) + } + delete passenger.userData._boatPassengerVehicleId + return true +} diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index 306e4c3..586216c 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -6,6 +6,7 @@ export type Vec3Like = { x: number; y: number; z: number } export type EntityRenderHints = { localVehicle?: boolean boatWaterPatchVisible?: boolean + boatPassengerIds?: number[] } export type EntityWithRenderHints = { @@ -30,3 +31,21 @@ export function getLocalVehicleWorldPosition(cameraWorldPos: Vec3Like, vehicleY: z: cameraWorldPos.z } } + +const BOAT_PASSENGER_RIDING_OFFSET_Y = -0.1 +const PLAYER_RIDING_OFFSET_Y = -0.35 + +export function getBoatPassengerSeatOffset(passengerIndex: number, passengerCount: number): number { + if (passengerCount <= 1) return 0 + return passengerIndex === 0 ? 0.2 : -0.6 +} + +/** Vanilla 1.17.1 Boat#positionRider position, without applying the passenger pose. */ +export function getBoatPassengerWorldPosition(boatWorldPos: Vec3Like, boatYaw: number, passengerIndex: number, passengerCount: number): Vec3Like { + const seatOffset = getBoatPassengerSeatOffset(passengerIndex, passengerCount) + return { + x: boatWorldPos.x - Math.sin(boatYaw) * seatOffset, + y: boatWorldPos.y + BOAT_PASSENGER_RIDING_OFFSET_Y + PLAYER_RIDING_OFFSET_Y, + z: boatWorldPos.z + Math.cos(boatYaw) * seatOffset + } +} diff --git a/src/three/entity/localVehicleRendering.integration.test.ts b/src/three/entity/localVehicleRendering.integration.test.ts index d1bd0e5..f86e299 100644 --- a/src/three/entity/localVehicleRendering.integration.test.ts +++ b/src/three/entity/localVehicleRendering.integration.test.ts @@ -1,5 +1,8 @@ -import { expect, test } from 'vitest' -import { getLocalVehicleWorldPosition } from './interpolationPolicy' +import * as THREE from 'three' +import { expect, test, vi } from 'vitest' +import { SceneOrigin } from '../sceneOrigin' +import { anchorBoatPassengerPosition, releaseBoatPassengerPosition } from './boatPassengerRendering' +import { getBoatPassengerWorldPosition, getLocalVehicleWorldPosition } from './interpolationPolicy' type Vec3 = { x: number; y: number; z: number } @@ -59,3 +62,71 @@ test('render frames between press and release keep zero horizontal delta to came expect(boat.y).not.toBe(camera.y) } }) + +test('boat passenger positions use vanilla 1.17.1 riding and seat offsets', () => { + const boat = { x: 10, y: 64, z: 20 } + + expect(getBoatPassengerWorldPosition(boat, 0, 0, 1)).toEqual({ + x: 10, + y: 63.55, + z: 20 + }) + expect(getBoatPassengerWorldPosition(boat, 0, 0, 2)).toEqual({ + x: 10, + y: 63.55, + z: 20.2 + }) + expect(getBoatPassengerWorldPosition(boat, 0, 1, 2)).toEqual({ + x: 10, + y: 63.55, + z: 19.4 + }) + + const rotated = getBoatPassengerWorldPosition(boat, Math.PI / 2, 0, 2) + expect(rotated.x).toBeCloseTo(9.8) + expect(rotated.y).toBe(63.55) + expect(rotated.z).toBeCloseTo(20) +}) + +test('remote player follows the tracked boat each frame and releases from an empty passenger list', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const boat = new THREE.Group() + sceneOrigin.track(boat) + boat.position.set(100, 64, 200) + + const passenger = new THREE.Group() + const stopPositionTween = vi.fn() + passenger.userData._posTween = { stop: stopPositionTween } + sceneOrigin.track(passenger) + passenger.position.set(90, 64, 190) + + const firstPassengerPosition = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1) + anchorBoatPassengerPosition(passenger, firstPassengerPosition, '10') + + expect(stopPositionTween).toHaveBeenCalledOnce() + expect(sceneOrigin.getWorldPosition(passenger)).toEqual({ x: 100, y: 63.55, z: 200 }) + expect(passenger.userData._tweenTarget).toEqual({ x: 100, y: 63.55, z: 200 }) + + boat.position.set(102, 64.2, 203) + boat.rotation.y = Math.PI / 2 + const movedPassengerPosition = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1) + anchorBoatPassengerPosition(passenger, movedPassengerPosition, '10') + expect(sceneOrigin.getWorldPosition(passenger)?.x).toBe(102) + expect(sceneOrigin.getWorldPosition(passenger)?.y).toBeCloseTo(63.75) + expect(sceneOrigin.getWorldPosition(passenger)?.z).toBe(203) + + sceneOrigin.update(112, 70, 208) + expect(sceneOrigin.getWorldPosition(passenger)?.x).toBe(102) + expect(sceneOrigin.getWorldPosition(passenger)?.y).toBeCloseTo(63.75) + expect(sceneOrigin.getWorldPosition(passenger)?.z).toBe(203) + + const detachedWorldPosition = sceneOrigin.getWorldPosition(passenger) + expect(releaseBoatPassengerPosition(passenger, '10', detachedWorldPosition)).toBe(true) + expect(passenger.userData._boatPassengerVehicleId).toBeUndefined() + expect(passenger.userData._tweenTarget).toEqual(detachedWorldPosition) + + boat.position.set(110, 65, 210) + expect(sceneOrigin.getWorldPosition(passenger)).toEqual(detachedWorldPosition) +}) From 4e472fdc02d15029b0160a5909ca26266e4b6154 Mon Sep 17 00:00:00 2001 From: zverev Date: Mon, 13 Jul 2026 09:44:34 +0300 Subject: [PATCH 05/20] fix(entities): render minecart passengers smoothly Generalize passenger anchoring for boats and minecarts and align local and remote riders with the minecart seat. Smooth server-authoritative camera movement, keep vehicle and camera interpolation synchronized, and preserve remote head pitch across animation frames. --- src/graphicsBackend/appViewer.ts | 7 +- src/graphicsBackend/types.ts | 5 +- src/lib/worldrendererCommon.ts | 4 +- src/three/entities.ts | 80 +++++-- src/three/entity/boatPassengerRendering.ts | 28 +-- src/three/entity/interpolationPolicy.test.ts | 86 ++++++++ src/three/entity/interpolationPolicy.ts | 70 ++++++ .../localVehicleRendering.integration.test.ts | 68 +++++- .../minecartLocalPassengerRendering.test.ts | 200 ++++++++++++++++++ .../entity/networkHeadPitchRendering.test.ts | 83 ++++++++ src/three/entity/networkHeadPitchRendering.ts | 18 ++ src/three/entity/vehiclePassengerRendering.ts | 44 ++++ src/three/graphicsBackendBase.ts | 10 +- src/three/graphicsBackendOffThread.ts | 4 +- src/three/worldRendererThree.ts | 67 +++--- 15 files changed, 690 insertions(+), 84 deletions(-) create mode 100644 src/three/entity/minecartLocalPassengerRendering.test.ts create mode 100644 src/three/entity/networkHeadPitchRendering.test.ts create mode 100644 src/three/entity/networkHeadPitchRendering.ts create mode 100644 src/three/entity/vehiclePassengerRendering.ts diff --git a/src/graphicsBackend/appViewer.ts b/src/graphicsBackend/appViewer.ts index 6288ab3..e1a1757 100644 --- a/src/graphicsBackend/appViewer.ts +++ b/src/graphicsBackend/appViewer.ts @@ -18,7 +18,8 @@ import type { GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState, - NonReactiveState + NonReactiveState, + UpdateCameraOptions } from './types' import { WorldView, WorldProvider, WorldViewWorker } from '../worldView' import { getInitialPlayerState } from './playerState' @@ -273,8 +274,8 @@ export class AppViewer { /** * Update camera position and rotation. */ - updateCamera(pos: Vec3 | null, yaw: number, pitch: number): void { - this.backend?.updateCamera(pos, yaw, pitch) + updateCamera(pos: Vec3 | null, yaw: number, pitch: number, options?: UpdateCameraOptions): void { + this.backend?.updateCamera(pos, yaw, pitch, options) } /** diff --git a/src/graphicsBackend/types.ts b/src/graphicsBackend/types.ts index 8126f5f..7b0ef27 100644 --- a/src/graphicsBackend/types.ts +++ b/src/graphicsBackend/types.ts @@ -9,6 +9,9 @@ import { ResourcesManagerTransferred } from '../resourcesManager' import { WorldViewWorker } from '../worldView' import { Vec3 } from 'vec3' import { WorldRendererConfig } from './config' +import type { UpdateCameraOptions } from '../three/entity/interpolationPolicy' + +export type { CameraMovementMode, UpdateCameraOptions } from '../three/entity/interpolationPolicy' // ============================================================================ // Graphics Backend Configuration @@ -134,7 +137,7 @@ export interface GraphicsBackend { startWorld(options: DisplayWorldOptions): Promise disconnect(): void setRendering(rendering: boolean): void - updateCamera(pos: Vec3 | null, yaw: number, pitch: number): void + updateCamera(pos: Vec3 | null, yaw: number, pitch: number, options?: UpdateCameraOptions): void soundSystem?: any backendMethods?: any getDebugOverlay?(): { entitiesString?: string; left?: Record; right?: Record } diff --git a/src/lib/worldrendererCommon.ts b/src/lib/worldrendererCommon.ts index 0bc0720..4e99559 100644 --- a/src/lib/worldrendererCommon.ts +++ b/src/lib/worldrendererCommon.ts @@ -7,7 +7,7 @@ import { subscribeKey } from 'valtio/utils' import { proxy, subscribe } from 'valtio' import type { ResourcesManagerTransferred } from '../resourcesManager/resourcesManager' import { dynamicMcDataFiles } from './buildSharedConfig.mjs' -import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState, SoundSystem } from '../graphicsBackend/types' +import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState, SoundSystem, UpdateCameraOptions } from '../graphicsBackend/types' import { HighestBlockInfo, CustomBlockModels, @@ -709,7 +709,7 @@ export abstract class WorldRendererCommon abstract handleWorkerMessage(data: WorkerReceive): void - abstract updateCamera(pos: Vec3 | null, yaw: number, pitch: number): void + abstract updateCamera(pos: Vec3 | null, yaw: number, pitch: number, options?: UpdateCameraOptions): void abstract render(): void diff --git a/src/three/entities.ts b/src/three/entities.ts index f118576..e32d81d 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -21,12 +21,15 @@ import { createItemMesh } from './itemMesh' import * as Entity from './entity/EntityMesh' import { setupBoatMesh, disposeBoatWaterPatch } from './entity/boatRenderSetup' import { getBoatMeshYawOffset, isBoatEntityName } from './entity/boatModelRotation' -import { anchorBoatPassengerPosition, releaseBoatPassengerPosition } from './entity/boatPassengerRendering' +import { anchorVehiclePassengerPosition, releaseVehiclePassengerPosition } from './entity/vehiclePassengerRendering' +import { applyNetworkHeadPitch, storeNetworkHeadPitch } from './entity/networkHeadPitchRendering' import { ENTITY_TWEEN_DURATION_MS, getBoatPassengerWorldPosition, getEntityTweenDurationMs, getLocalVehicleWorldPosition, + getMinecartPassengerWorldPosition, + isRideableMinecartEntityName, type EntityRenderHints, usesCameraSyncedVehiclePosition } from './entity/interpolationPolicy' @@ -443,7 +446,8 @@ export class Entities { const thirdPerson = this.worldRenderer.playerStateUtils.isThirdPerson() entity.visible = thirdPerson - if (thirdPerson) { + const isAnchoredPassenger = entity.userData._passengerVehicleId != null || entity.userData._boatPassengerVehicleId != null + if (thirdPerson && !isAnchoredPassenger) { const yOffset = this.worldRenderer.playerStateReactive.eyeHeight entity.position.set(this.worldRenderer.cameraWorldPos.x, this.worldRenderer.cameraWorldPos.y - yOffset, this.worldRenderer.cameraWorldPos.z) } @@ -455,6 +459,10 @@ export class Entities { playerObject.animation.update(playerObject, dt) } + if (!isPlayerEntity && playerObject) { + applyNetworkHeadPitch(playerObject, entity.userData) + } + entity.traverse(child => { if (child instanceof Entity.EntityMesh) { child.update(dt) @@ -494,36 +502,62 @@ export class Entities { } } - this.updateBoatPassengerPositions() + this.updateVehiclePassengerPositions() } - updateBoatPassengerPositions() { + private resolvePassengerEntity(passengerId: number): SceneEntity | undefined { + if (this.playerEntity?.originalEntity.id === passengerId) { + return this.playerEntity + } + return this.entities[passengerId] + } + + updateVehiclePassengerPositions() { const attachedPassengers = new Set() - for (const boat of Object.values(this.entities)) { - if (!isBoatEntityName(boat['realName'] ?? boat.originalEntity.name)) continue - const passengerIds = boat.userData.renderHints?.boatPassengerIds + for (const vehicle of Object.values(this.entities)) { + const renderHints = vehicle.userData.renderHints as EntityRenderHints | undefined + const passengerIds = renderHints?.passengerIds ?? renderHints?.boatPassengerIds if (!Array.isArray(passengerIds) || passengerIds.length === 0) continue - const boatWorldPos = this.worldRenderer.sceneOrigin.getWorldPosition(boat) - if (!boatWorldPos) continue + + const vehicleName = vehicle['realName'] ?? vehicle.originalEntity.name + const layout = renderHints?.passengerLayout ?? (isBoatEntityName(vehicleName) ? 'boat' : undefined) + if (layout !== 'boat' && layout !== 'minecart') continue + if (layout === 'boat' && !isBoatEntityName(vehicleName)) continue + if (layout === 'minecart' && !isRideableMinecartEntityName(vehicleName)) continue + + const vehicleWorldPos = this.worldRenderer.sceneOrigin.getWorldPosition(vehicle) + if (!vehicleWorldPos) continue + const vehicleId = String(vehicle.originalEntity.id) for (const [passengerIndex, passengerId] of passengerIds.entries()) { - const passenger = this.entities[passengerId] + const isLocalPassenger = passengerId === this.playerEntity?.originalEntity.id + const passenger = isLocalPassenger ? this.playerEntity : this.entities[passengerId] if (!passenger?.playerObject) continue - const passengerWorldPos = getBoatPassengerWorldPosition(boatWorldPos, boat.rotation.y, passengerIndex, passengerIds.length) + if (passenger === this.playerEntity && layout !== 'minecart') continue + + const passengerWorldPos = + layout === 'minecart' + ? getMinecartPassengerWorldPosition(vehicleWorldPos) + : getBoatPassengerWorldPosition(vehicleWorldPos, vehicle.rotation.y, passengerIndex, passengerIds.length) - anchorBoatPassengerPosition(passenger, passengerWorldPos, String(boat.originalEntity.id)) + anchorVehiclePassengerPosition(passenger, passengerWorldPos, vehicleId) attachedPassengers.add(passenger) } } - for (const passenger of Object.values(this.entities)) { - const vehicleId = passenger.userData._boatPassengerVehicleId as string | undefined + for (const passenger of [...Object.values(this.entities), ...(this.playerEntity ? [this.playerEntity] : [])]) { + const vehicleId = passenger.userData._passengerVehicleId ?? (passenger.userData._boatPassengerVehicleId as string | undefined) if (vehicleId === undefined || attachedPassengers.has(passenger)) continue - releaseBoatPassengerPosition(passenger, vehicleId, this.worldRenderer.sceneOrigin.getWorldPosition(passenger)) + releaseVehiclePassengerPosition(passenger, vehicleId, this.worldRenderer.sceneOrigin.getWorldPosition(passenger)) } } + /** @deprecated Use updateVehiclePassengerPositions */ + updateBoatPassengerPositions() { + this.updateVehiclePassengerPositions() + } + private syncArmorPositions(entity: SceneEntity) { if (!entity.playerObject) return @@ -1250,13 +1284,16 @@ export class Entities { applyEntityRenderHints(e: SceneEntity, entity: SceneEntity['originalEntity']) { if (entity.renderHints) { - if (isBoatEntityName(entity.name)) { - const nextPassengerIds = new Set(entity.renderHints.boatPassengerIds ?? []) - for (const previousPassengerId of e.userData.renderHints?.boatPassengerIds ?? []) { + const hints = entity.renderHints as EntityRenderHints + const nextPassengerIds = new Set(hints.passengerIds ?? hints.boatPassengerIds ?? []) + const previousPassengerIds = e.userData.renderHints?.passengerIds ?? e.userData.renderHints?.boatPassengerIds ?? [] + const isPassengerVehicle = isBoatEntityName(entity.name) || isRideableMinecartEntityName(entity.name) + if (isPassengerVehicle) { + for (const previousPassengerId of previousPassengerIds) { if (nextPassengerIds.has(previousPassengerId)) continue - const previousPassenger = this.entities[previousPassengerId] + const previousPassenger = this.resolvePassengerEntity(previousPassengerId) if (previousPassenger) { - releaseBoatPassengerPosition(previousPassenger, String(entity.id), this.worldRenderer.sceneOrigin.getWorldPosition(previousPassenger)) + releaseVehiclePassengerPosition(previousPassenger, String(entity.id), this.worldRenderer.sceneOrigin.getWorldPosition(previousPassenger)) } } } @@ -1325,7 +1362,8 @@ export class Entities { playerObject.skin.head.rotation.y = 0 const hp = overrides.rotation.head.x - playerObject.skin.head.rotation.x = typeof hp === 'number' && Number.isFinite(hp) ? -hp : 0 + storeNetworkHeadPitch(e.userData, hp) + applyNetworkHeadPitch(playerObject, e.userData) } } diff --git a/src/three/entity/boatPassengerRendering.ts b/src/three/entity/boatPassengerRendering.ts index 4417b8d..db67d52 100644 --- a/src/three/entity/boatPassengerRendering.ts +++ b/src/three/entity/boatPassengerRendering.ts @@ -1,33 +1,19 @@ import type { Vec3Like } from './interpolationPolicy' -export type BoatPassengerRenderState = { +export type VehiclePassengerRenderState = { position: { set: (x: number, y: number, z: number) => unknown } userData: { _posTween?: { stop: () => unknown } _tweenTarget?: Vec3Like + _passengerVehicleId?: string + /** @deprecated Use _passengerVehicleId */ _boatPassengerVehicleId?: string } } -export function anchorBoatPassengerPosition(passenger: BoatPassengerRenderState, passengerWorldPos: Vec3Like, vehicleId: string): void { - passenger.userData._posTween?.stop() - passenger.userData._posTween = undefined - passenger.userData._tweenTarget ??= { ...passengerWorldPos } - Object.assign(passenger.userData._tweenTarget, passengerWorldPos) - passenger.position.set(passengerWorldPos.x, passengerWorldPos.y, passengerWorldPos.z) - passenger.userData._boatPassengerVehicleId = vehicleId -} - -export function releaseBoatPassengerPosition(passenger: BoatPassengerRenderState, vehicleId: string, currentWorldPos: Vec3Like | undefined): boolean { - if (passenger.userData._boatPassengerVehicleId !== vehicleId) return false - passenger.userData._posTween?.stop() - passenger.userData._posTween = undefined - if (currentWorldPos) { - passenger.userData._tweenTarget ??= { ...currentWorldPos } - Object.assign(passenger.userData._tweenTarget, currentWorldPos) - } - delete passenger.userData._boatPassengerVehicleId - return true -} +export { + anchorVehiclePassengerPosition as anchorBoatPassengerPosition, + releaseVehiclePassengerPosition as releaseBoatPassengerPosition +} from './vehiclePassengerRendering' diff --git a/src/three/entity/interpolationPolicy.test.ts b/src/three/entity/interpolationPolicy.test.ts index ea75b8d..bbd6434 100644 --- a/src/three/entity/interpolationPolicy.test.ts +++ b/src/three/entity/interpolationPolicy.test.ts @@ -2,8 +2,12 @@ import { expect, test } from 'vitest' import { ENTITY_TWEEN_DURATION_MS, LOCAL_MOVEMENT_TWEEN_DURATION_MS, + SPECTATING_CAMERA_TWEEN_DURATION_MS, + getCameraMovementTweenDurationMs, getEntityTweenDurationMs, getLocalVehicleWorldPosition, + samePosition, + shouldRestartCameraPositionTween, usesCameraSyncedVehiclePosition } from './interpolationPolicy' @@ -15,12 +19,94 @@ test('ordinary entity tween stays at 120 ms', () => { expect(ENTITY_TWEEN_DURATION_MS).toBe(120) }) +test('server-vehicle camera uses entity tween duration', () => { + expect(getCameraMovementTweenDurationMs('server-vehicle')).toBe(120) + expect(getCameraMovementTweenDurationMs('local-player')).toBe(50) + expect(getCameraMovementTweenDurationMs('spectating')).toBe(SPECTATING_CAMERA_TWEEN_DURATION_MS) +}) + +test('forced teleport camera update is instant', () => { + expect(getCameraMovementTweenDurationMs('server-vehicle', true)).toBe(0) + expect(getCameraMovementTweenDurationMs('local-player', true)).toBe(0) +}) + +test('same target does not restart camera tween', () => { + const target = { x: 1, y: 63.7, z: 2 } + expect( + shouldRestartCameraPositionTween({ + target, + currentTarget: { ...target }, + movementMode: 'server-vehicle', + previousMovementMode: 'server-vehicle', + instant: false + }) + ).toBe(false) +}) + +test('dismount switches camera tween mode back to local-player', () => { + const target = { x: 1, y: 63.7, z: 2 } + expect( + shouldRestartCameraPositionTween({ + target, + currentTarget: { ...target }, + movementMode: 'local-player', + previousMovementMode: 'server-vehicle', + instant: false + }) + ).toBe(true) + expect(getCameraMovementTweenDurationMs('local-player')).toBe(50) +}) + +test('sequential minecart targets restart tween only when position changes', () => { + const first = { x: 0, y: 63.7, z: 0 } + const second = { x: 1, y: 63.7, z: 0 } + expect( + shouldRestartCameraPositionTween({ + target: first, + currentTarget: null, + movementMode: 'server-vehicle', + previousMovementMode: null, + instant: false + }) + ).toBe(true) + expect( + shouldRestartCameraPositionTween({ + target: first, + currentTarget: first, + movementMode: 'server-vehicle', + previousMovementMode: 'server-vehicle', + instant: false + }) + ).toBe(false) + expect( + shouldRestartCameraPositionTween({ + target: second, + currentTarget: first, + movementMode: 'server-vehicle', + previousMovementMode: 'server-vehicle', + instant: false + }) + ).toBe(true) + expect(second.x).toBeGreaterThan(first.x) +}) + +test('samePosition uses epsilon comparison', () => { + expect(samePosition({ x: 1, y: 2, z: 3 }, { x: 1.00001, y: 2.00001, z: 3.00001 })).toBe(true) + expect(samePosition({ x: 1, y: 2, z: 3 }, { x: 1.01, y: 2, z: 3 })).toBe(false) +}) + test('local vehicle skips position tween', () => { expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true } })).toBe(true) expect(getEntityTweenDurationMs({ renderHints: { localVehicle: true } }, false)).toBe(0) expect(getEntityTweenDurationMs({ renderHints: { localVehicle: true } }, true)).toBe(0) }) +test('boat camera-sync policy stays separate from server-vehicle mode', () => { + expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true } })).toBe(true) + expect(usesCameraSyncedVehiclePosition({ renderHints: { passengerLayout: 'minecart' } })).toBe(false) + expect(getCameraMovementTweenDurationMs('local-player')).toBe(50) +}) + test('remote entities keep ordinary tween duration', () => { expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: false } })).toBe(false) expect(getEntityTweenDurationMs({ renderHints: { localVehicle: false } }, false)).toBe(120) diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index 586216c..1000d44 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -1,11 +1,23 @@ export const LOCAL_MOVEMENT_TWEEN_DURATION_MS = 50 export const ENTITY_TWEEN_DURATION_MS = 120 +export const SPECTATING_CAMERA_TWEEN_DURATION_MS = 150 +export const CAMERA_POSITION_EPSILON = 1e-4 + +export type CameraMovementMode = 'local-player' | 'server-vehicle' | 'spectating' + +export type UpdateCameraOptions = { + movementMode?: CameraMovementMode + instant?: boolean +} export type Vec3Like = { x: number; y: number; z: number } export type EntityRenderHints = { localVehicle?: boolean boatWaterPatchVisible?: boolean + passengerIds?: number[] + passengerLayout?: 'boat' | 'minecart' + /** @deprecated Use passengerIds */ boatPassengerIds?: number[] } @@ -23,6 +35,36 @@ export function getEntityTweenDurationMs(entity: EntityWithRenderHints | undefin return ENTITY_TWEEN_DURATION_MS } +export function samePosition(a: Vec3Like, b: Vec3Like, epsilon = CAMERA_POSITION_EPSILON): boolean { + return Math.abs(a.x - b.x) < epsilon && Math.abs(a.y - b.y) < epsilon && Math.abs(a.z - b.z) < epsilon +} + +export function getCameraMovementTweenDurationMs(mode: CameraMovementMode, instant = false): number { + if (instant) return 0 + switch (mode) { + case 'spectating': + return SPECTATING_CAMERA_TWEEN_DURATION_MS + case 'server-vehicle': + return ENTITY_TWEEN_DURATION_MS + case 'local-player': + default: + return LOCAL_MOVEMENT_TWEEN_DURATION_MS + } +} + +export function shouldRestartCameraPositionTween(args: { + target: Vec3Like + currentTarget: Vec3Like | null + movementMode: CameraMovementMode + previousMovementMode: CameraMovementMode | null + instant: boolean +}): boolean { + if (args.instant) return true + if (args.currentTarget == null) return true + if (args.movementMode !== args.previousMovementMode) return true + return !samePosition(args.target, args.currentTarget) +} + /** Local vehicle X/Z follow camera tween; Y uses latest vehicle physics height. */ export function getLocalVehicleWorldPosition(cameraWorldPos: Vec3Like, vehicleY: number): Vec3Like { return { @@ -34,6 +76,25 @@ export function getLocalVehicleWorldPosition(cameraWorldPos: Vec3Like, vehicleY: const BOAT_PASSENGER_RIDING_OFFSET_Y = -0.1 const PLAYER_RIDING_OFFSET_Y = -0.35 +/** Vanilla 1.17.1 EntityType minecart passengerAttachments(0.1875F) */ +export const MINECART_PASSENGER_ATTACHMENT_Y = 0.1875 +/** Vanilla 1.17.1 Player.DEFAULT_VEHICLE_ATTACHMENT.y */ +export const PLAYER_VEHICLE_ATTACHMENT_Y = 0.6 + +const RIDEABLE_MINECART_ENTITY_NAMES = new Set([ + 'minecart', + 'chest_minecart', + 'furnace_minecart', + 'hopper_minecart', + 'tnt_minecart', + 'spawner_minecart', + 'command_block_minecart' +]) + +export function isRideableMinecartEntityName(name?: string): boolean { + if (!name) return false + return RIDEABLE_MINECART_ENTITY_NAMES.has(name) +} export function getBoatPassengerSeatOffset(passengerIndex: number, passengerCount: number): number { if (passengerCount <= 1) return 0 @@ -49,3 +110,12 @@ export function getBoatPassengerWorldPosition(boatWorldPos: Vec3Like, boatYaw: n z: boatWorldPos.z + Math.cos(boatYaw) * seatOffset } } + +/** Vanilla 1.17.1 minecart positionRider for a centered player passenger. */ +export function getMinecartPassengerWorldPosition(minecartWorldPos: Vec3Like): Vec3Like { + return { + x: minecartWorldPos.x, + y: minecartWorldPos.y + MINECART_PASSENGER_ATTACHMENT_Y - PLAYER_VEHICLE_ATTACHMENT_Y, + z: minecartWorldPos.z + } +} diff --git a/src/three/entity/localVehicleRendering.integration.test.ts b/src/three/entity/localVehicleRendering.integration.test.ts index f86e299..a65d912 100644 --- a/src/three/entity/localVehicleRendering.integration.test.ts +++ b/src/three/entity/localVehicleRendering.integration.test.ts @@ -1,8 +1,13 @@ import * as THREE from 'three' import { expect, test, vi } from 'vitest' import { SceneOrigin } from '../sceneOrigin' -import { anchorBoatPassengerPosition, releaseBoatPassengerPosition } from './boatPassengerRendering' -import { getBoatPassengerWorldPosition, getLocalVehicleWorldPosition } from './interpolationPolicy' +import { anchorVehiclePassengerPosition, releaseVehiclePassengerPosition } from './vehiclePassengerRendering' +import { + getBoatPassengerWorldPosition, + getLocalVehicleWorldPosition, + getMinecartPassengerWorldPosition, + usesCameraSyncedVehiclePosition +} from './interpolationPolicy' type Vec3 = { x: number; y: number; z: number } @@ -103,7 +108,7 @@ test('remote player follows the tracked boat each frame and releases from an emp passenger.position.set(90, 64, 190) const firstPassengerPosition = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1) - anchorBoatPassengerPosition(passenger, firstPassengerPosition, '10') + anchorVehiclePassengerPosition(passenger, firstPassengerPosition, '10') expect(stopPositionTween).toHaveBeenCalledOnce() expect(sceneOrigin.getWorldPosition(passenger)).toEqual({ x: 100, y: 63.55, z: 200 }) @@ -112,7 +117,7 @@ test('remote player follows the tracked boat each frame and releases from an emp boat.position.set(102, 64.2, 203) boat.rotation.y = Math.PI / 2 const movedPassengerPosition = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1) - anchorBoatPassengerPosition(passenger, movedPassengerPosition, '10') + anchorVehiclePassengerPosition(passenger, movedPassengerPosition, '10') expect(sceneOrigin.getWorldPosition(passenger)?.x).toBe(102) expect(sceneOrigin.getWorldPosition(passenger)?.y).toBeCloseTo(63.75) expect(sceneOrigin.getWorldPosition(passenger)?.z).toBe(203) @@ -123,10 +128,63 @@ test('remote player follows the tracked boat each frame and releases from an emp expect(sceneOrigin.getWorldPosition(passenger)?.z).toBe(203) const detachedWorldPosition = sceneOrigin.getWorldPosition(passenger) - expect(releaseBoatPassengerPosition(passenger, '10', detachedWorldPosition)).toBe(true) + expect(releaseVehiclePassengerPosition(passenger, '10', detachedWorldPosition)).toBe(true) + expect(passenger.userData._passengerVehicleId).toBeUndefined() expect(passenger.userData._boatPassengerVehicleId).toBeUndefined() expect(passenger.userData._tweenTarget).toEqual(detachedWorldPosition) boat.position.set(110, 65, 210) expect(sceneOrigin.getWorldPosition(passenger)).toEqual(detachedWorldPosition) }) + +test('minecart passenger uses vanilla 1.17.1 riding Y offset', () => { + const minecart = { x: 10, y: 64, z: 20 } + expect(getMinecartPassengerWorldPosition(minecart)).toEqual({ + x: 10, + y: 63.5875, + z: 20 + }) +}) + +test('minecart passenger anchor follows vehicle world position without inheriting vehicle yaw', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const minecart = new THREE.Group() + sceneOrigin.track(minecart) + minecart.position.set(100, 64, 200) + minecart.rotation.y = Math.PI / 2 + + const passenger = new THREE.Group() + passenger.rotation.y = 1.25 + sceneOrigin.track(passenger) + passenger.position.set(90, 64, 190) + + const firstPosition = getMinecartPassengerWorldPosition(sceneOrigin.getWorldPosition(minecart)!) + anchorVehiclePassengerPosition(passenger, firstPosition, '42') + const anchoredYaw = passenger.rotation.y + + minecart.position.set(103, 64.5, 205) + minecart.rotation.y = Math.PI + const movedPosition = getMinecartPassengerWorldPosition(sceneOrigin.getWorldPosition(minecart)!) + anchorVehiclePassengerPosition(passenger, movedPosition, '42') + + expect(sceneOrigin.getWorldPosition(passenger)).toEqual({ + x: 103, + y: 64.0875, + z: 205 + }) + expect(passenger.rotation.y).toBe(anchoredYaw) + + const detachedWorldPosition = sceneOrigin.getWorldPosition(passenger) + expect(releaseVehiclePassengerPosition(passenger, '42', detachedWorldPosition)).toBe(true) + expect(sceneOrigin.getWorldPosition(passenger)).toEqual(detachedWorldPosition) + + minecart.position.set(120, 66, 220) + expect(sceneOrigin.getWorldPosition(passenger)).toEqual(detachedWorldPosition) +}) + +test('minecart does not use camera-synced vehicle policy', () => { + expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true } })).toBe(true) + expect(usesCameraSyncedVehiclePosition({ renderHints: { passengerLayout: 'minecart' } })).toBe(false) +}) diff --git a/src/three/entity/minecartLocalPassengerRendering.test.ts b/src/three/entity/minecartLocalPassengerRendering.test.ts new file mode 100644 index 0000000..202d661 --- /dev/null +++ b/src/three/entity/minecartLocalPassengerRendering.test.ts @@ -0,0 +1,200 @@ +import * as THREE from 'three' +import { expect, test } from 'vitest' +import { SceneOrigin } from '../sceneOrigin' +import { anchorVehiclePassengerPosition, releaseVehiclePassengerPosition } from './vehiclePassengerRendering' +import { getBoatPassengerWorldPosition, getMinecartPassengerWorldPosition } from './interpolationPolicy' + +const LOCAL_PLAYER_ID = 7 +const REMOTE_PLAYER_ID = 8 +const MINECART_ID = '42' +const EYE_HEIGHT = 1.62 +const PLAYER_HEIGHT = 1.8 + +type PassengerLike = THREE.Group & { + playerObject: Record + originalEntity: { id: number } + userData: Record +} + +function makePassenger(id: number): PassengerLike { + const passenger = new THREE.Group() as PassengerLike + passenger.playerObject = {} + passenger.originalEntity = { id } + passenger.userData = {} + return passenger +} + +function resolveMinecartPassenger( + passengerId: number, + localPlayer: PassengerLike | null, + remotePlayers: Record +): PassengerLike | undefined { + const isLocalPassenger = passengerId === localPlayer?.originalEntity.id + return isLocalPassenger ? (localPlayer ?? undefined) : remotePlayers[passengerId] +} + +function anchorMinecartPassengers( + minecart: THREE.Group, + sceneOrigin: SceneOrigin, + passengerIds: number[], + localPlayer: PassengerLike | null, + remotePlayers: Record +) { + const vehicleWorldPos = sceneOrigin.getWorldPosition(minecart)! + const seatPosition = getMinecartPassengerWorldPosition(vehicleWorldPos) + for (const passengerId of passengerIds) { + const passenger = resolveMinecartPassenger(passengerId, localPlayer, remotePlayers) + if (!passenger?.playerObject) continue + anchorVehiclePassengerPosition(passenger, seatPosition, MINECART_ID) + } + return seatPosition +} + +test('local and remote minecart passengers share the same seat Y', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const minecart = new THREE.Group() + sceneOrigin.track(minecart) + minecart.position.set(100, 64, 200) + + const localPlayer = makePassenger(LOCAL_PLAYER_ID) + const remotePlayer = makePassenger(REMOTE_PLAYER_ID) + sceneOrigin.track(localPlayer) + sceneOrigin.track(remotePlayer) + + const seatPosition = anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID, REMOTE_PLAYER_ID], localPlayer, { [REMOTE_PLAYER_ID]: remotePlayer }) + + const localWorld = sceneOrigin.getWorldPosition(localPlayer) + const remoteWorld = sceneOrigin.getWorldPosition(remotePlayer) + expect(localWorld?.y).toBe(seatPosition.y) + expect(remoteWorld?.y).toBe(seatPosition.y) + expect(localWorld).toEqual(remoteWorld) +}) + +test('local third-person player follows interpolated minecart world position', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const minecart = new THREE.Group() + sceneOrigin.track(minecart) + minecart.position.set(100, 64, 200) + + const localPlayer = makePassenger(LOCAL_PLAYER_ID) + sceneOrigin.track(localPlayer) + + anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {}) + + minecart.position.set(103, 64.5, 205) + sceneOrigin.update(112, 70, 208) + const movedSeat = anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {}) + + expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(movedSeat) + expect(movedSeat.x).toBe(103) + expect(movedSeat.y).toBeCloseTo(64.0875) +}) + +test('minecart seat places player waist-up inside the cart instead of on camera feet', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const minecart = new THREE.Group() + sceneOrigin.track(minecart) + minecart.position.set(100, 64, 200) + + const localPlayer = makePassenger(LOCAL_PLAYER_ID) + sceneOrigin.track(localPlayer) + + const cameraWorldPos = { x: 100, y: 64 + 0.7 + EYE_HEIGHT, z: 200 } + localPlayer.position.set(cameraWorldPos.x, cameraWorldPos.y - EYE_HEIGHT, cameraWorldPos.z) + + const seatPosition = anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {}) + const anchoredWorld = sceneOrigin.getWorldPosition(localPlayer)! + + const minecartWorld = sceneOrigin.getWorldPosition(minecart)! + const cameraFeetY = cameraWorldPos.y - EYE_HEIGHT + + expect(anchoredWorld.y).toBeLessThan(cameraFeetY) + expect(anchoredWorld.y).toBe(seatPosition.y) + const waistY = anchoredWorld.y + PLAYER_HEIGHT * 0.5 + expect(waistY).toBeGreaterThan(minecartWorld.y) + expect(waistY).toBeGreaterThan(minecartWorld.y + 0.3) + expect(waistY).toBeLessThan(minecartWorld.y + 0.85) +}) + +test('first and third person keep the same minecart passenger anchor', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const minecart = new THREE.Group() + sceneOrigin.track(minecart) + minecart.position.set(100, 64, 200) + + const localPlayer = makePassenger(LOCAL_PLAYER_ID) + sceneOrigin.track(localPlayer) + + anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {}) + const thirdPersonAnchor = { ...sceneOrigin.getWorldPosition(localPlayer)! } + + localPlayer.visible = false + anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {}) + + expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(thirdPersonAnchor) + expect(localPlayer.userData._passengerVehicleId).toBe(MINECART_ID) +}) + +test('minecart dismount releases local player anchor without snapping back', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const minecart = new THREE.Group() + sceneOrigin.track(minecart) + minecart.position.set(100, 64, 200) + + const localPlayer = makePassenger(LOCAL_PLAYER_ID) + sceneOrigin.track(localPlayer) + + anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {}) + minecart.position.set(105, 65, 210) + sceneOrigin.update(112, 70, 208) + anchorMinecartPassengers(minecart, sceneOrigin, [LOCAL_PLAYER_ID], localPlayer, {}) + + const detachedWorldPosition = sceneOrigin.getWorldPosition(localPlayer) + expect(releaseVehiclePassengerPosition(localPlayer, MINECART_ID, detachedWorldPosition)).toBe(true) + expect(localPlayer.userData._passengerVehicleId).toBeUndefined() + expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(detachedWorldPosition) + + minecart.position.set(120, 66, 220) + sceneOrigin.update(128, 76, 228) + expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(detachedWorldPosition) +}) + +function shouldAnchorPassenger(passenger: PassengerLike | null | undefined, layout: 'boat' | 'minecart', isLocalPassenger: boolean): boolean { + if (!passenger?.playerObject) return false + if (isLocalPassenger && layout !== 'minecart') return false + return true +} + +test('local player remains excluded from boat passenger anchoring', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const boat = new THREE.Group() + sceneOrigin.track(boat) + boat.position.set(100, 64, 200) + + const localPlayer = makePassenger(LOCAL_PLAYER_ID) + sceneOrigin.track(localPlayer) + localPlayer.position.set(100, 62.38, 200) + + const boatSeat = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1) + const shouldAnchorLocalPlayer = shouldAnchorPassenger(localPlayer, 'boat', true) + + expect(shouldAnchorLocalPlayer).toBe(false) + if (shouldAnchorLocalPlayer) { + anchorVehiclePassengerPosition(localPlayer, boatSeat, '10') + } + + expect(localPlayer.userData._passengerVehicleId).toBeUndefined() + expect(sceneOrigin.getWorldPosition(localPlayer)?.y).toBe(62.38) +}) diff --git a/src/three/entity/networkHeadPitchRendering.test.ts b/src/three/entity/networkHeadPitchRendering.test.ts new file mode 100644 index 0000000..b5a2928 --- /dev/null +++ b/src/three/entity/networkHeadPitchRendering.test.ts @@ -0,0 +1,83 @@ +import * as THREE from 'three' +import { PlayerObject } from 'skinview3d' +import { expect, test } from 'vitest' +import type { PlayerObjectType } from '../../lib/createPlayerObject' +import { WalkingGeneralSwing } from './animations' +import { anchorVehiclePassengerPosition } from './vehiclePassengerRendering' +import { applyNetworkHeadPitch, storeNetworkHeadPitch, type NetworkHeadPitchState } from './networkHeadPitchRendering' +import { getMinecartPassengerWorldPosition } from './interpolationPolicy' + +function makePlayerObject(): PlayerObjectType { + const playerObject = new PlayerObject() as PlayerObjectType + playerObject.skin.head.rotation.set(0, 0, 0) + return playerObject +} + +function simulateAnimationFrames(playerObject: PlayerObjectType, userData: NetworkHeadPitchState, frames: number, dt = 0.05) { + const animation = new WalkingGeneralSwing() + animation._captureDefaults(playerObject) + for (let i = 0; i < frames; i++) { + animation.update(playerObject, dt) + applyNetworkHeadPitch(playerObject, userData) + } +} + +test('remote head pitch persists across animation frames', () => { + const playerObject = makePlayerObject() + const userData: NetworkHeadPitchState = {} + storeNetworkHeadPitch(userData, 0.6) + + simulateAnimationFrames(playerObject, userData, 5) + + expect(playerObject.skin.head.rotation.x).toBeCloseTo(-0.6) + expect(playerObject.skin.head.rotation.y).toBe(0) +}) + +test('remote head pitch updates when network pitch changes', () => { + const playerObject = makePlayerObject() + const userData: NetworkHeadPitchState = {} + storeNetworkHeadPitch(userData, 0.4) + simulateAnimationFrames(playerObject, userData, 2) + expect(playerObject.skin.head.rotation.x).toBeCloseTo(-0.4) + + storeNetworkHeadPitch(userData, -0.2) + simulateAnimationFrames(playerObject, userData, 2) + expect(playerObject.skin.head.rotation.x).toBeCloseTo(0.2) +}) + +test('remote head pitch supports looking straight ahead', () => { + const playerObject = makePlayerObject() + const userData: NetworkHeadPitchState = {} + storeNetworkHeadPitch(userData, 0) + simulateAnimationFrames(playerObject, userData, 3) + expect(playerObject.skin.head.rotation.x).toBeCloseTo(0) +}) + +test('local player keeps camera pitch authoritative', () => { + const playerObject = makePlayerObject() + const userData: NetworkHeadPitchState = {} + storeNetworkHeadPitch(userData, 0.8) + + const cameraPitch = -0.35 + playerObject.skin.head.rotation.x = -cameraPitch + + expect(playerObject.skin.head.rotation.x).toBeCloseTo(0.35) +}) + +test('minecart passenger anchor preserves head rotation', () => { + const playerObject = makePlayerObject() + const userData: NetworkHeadPitchState = {} + const passenger = new THREE.Group() + passenger.userData = userData + + storeNetworkHeadPitch(userData, 0.45) + applyNetworkHeadPitch(playerObject, userData) + + const seat = getMinecartPassengerWorldPosition({ x: 10, y: 64, z: 20 }) + anchorVehiclePassengerPosition(passenger, seat, '42') + simulateAnimationFrames(playerObject, userData, 4) + + expect(passenger.position.x).toBe(10) + expect(passenger.position.y).toBeCloseTo(63.5875) + expect(playerObject.skin.head.rotation.x).toBeCloseTo(-0.45) +}) diff --git a/src/three/entity/networkHeadPitchRendering.ts b/src/three/entity/networkHeadPitchRendering.ts new file mode 100644 index 0000000..4d11c06 --- /dev/null +++ b/src/three/entity/networkHeadPitchRendering.ts @@ -0,0 +1,18 @@ +import type { PlayerObjectType } from '../../lib/createPlayerObject' + +export type NetworkHeadPitchState = { + _networkHeadPitch?: number +} + +export function storeNetworkHeadPitch(userData: NetworkHeadPitchState, pitch: unknown): void { + if (typeof pitch === 'number' && Number.isFinite(pitch)) { + userData._networkHeadPitch = pitch + } +} + +export function applyNetworkHeadPitch(playerObject: PlayerObjectType, userData: NetworkHeadPitchState): void { + const pitch = userData._networkHeadPitch + if (typeof pitch !== 'number' || !Number.isFinite(pitch)) return + playerObject.skin.head.rotation.y = 0 + playerObject.skin.head.rotation.x = -pitch +} diff --git a/src/three/entity/vehiclePassengerRendering.ts b/src/three/entity/vehiclePassengerRendering.ts new file mode 100644 index 0000000..b3913a4 --- /dev/null +++ b/src/three/entity/vehiclePassengerRendering.ts @@ -0,0 +1,44 @@ +import type { Vec3Like } from './interpolationPolicy' + +export type VehiclePassengerRenderState = { + position: { + set: (x: number, y: number, z: number) => unknown + } + userData: { + _posTween?: { stop: () => unknown } + _tweenTarget?: Vec3Like + _passengerVehicleId?: string + /** @deprecated Use _passengerVehicleId */ + _boatPassengerVehicleId?: string + } +} + +export function anchorVehiclePassengerPosition(passenger: VehiclePassengerRenderState, passengerWorldPos: Vec3Like, vehicleId: string): void { + passenger.userData._posTween?.stop() + passenger.userData._posTween = undefined + passenger.userData._tweenTarget ??= { ...passengerWorldPos } + Object.assign(passenger.userData._tweenTarget, passengerWorldPos) + passenger.position.set(passengerWorldPos.x, passengerWorldPos.y, passengerWorldPos.z) + passenger.userData._passengerVehicleId = vehicleId + delete passenger.userData._boatPassengerVehicleId +} + +export function releaseVehiclePassengerPosition(passenger: VehiclePassengerRenderState, vehicleId: string, currentWorldPos: Vec3Like | undefined): boolean { + const anchoredVehicleId = passenger.userData._passengerVehicleId ?? passenger.userData._boatPassengerVehicleId + if (anchoredVehicleId !== vehicleId) return false + passenger.userData._posTween?.stop() + passenger.userData._posTween = undefined + if (currentWorldPos) { + passenger.userData._tweenTarget ??= { ...currentWorldPos } + Object.assign(passenger.userData._tweenTarget, currentWorldPos) + } + delete passenger.userData._passengerVehicleId + delete passenger.userData._boatPassengerVehicleId + return true +} + +/** @deprecated Use anchorVehiclePassengerPosition */ +export const anchorBoatPassengerPosition = anchorVehiclePassengerPosition + +/** @deprecated Use releaseVehiclePassengerPosition */ +export const releaseBoatPassengerPosition = releaseVehiclePassengerPosition diff --git a/src/three/graphicsBackendBase.ts b/src/three/graphicsBackendBase.ts index 7616d1e..437cb87 100644 --- a/src/three/graphicsBackendBase.ts +++ b/src/three/graphicsBackendBase.ts @@ -7,7 +7,7 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' -import type { GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../graphicsBackend' +import type { GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, UpdateCameraOptions } from '../graphicsBackend' import { createWorkerProxy, restoreTransferred } from '../lib/workerProxy' import { ResourcesManager } from '../resourcesManager' import { FrameTimingCollector } from '../lib/frameTimingCollector' @@ -279,10 +279,10 @@ export const createGraphicsBackendBase = () => { } } }), - updateCamera(pos: Vec3 | null, yaw: number, pitch: number) { + updateCamera(pos: Vec3 | null, yaw: number, pitch: number, options?: UpdateCameraOptions) { // Mark camera update event for frame timing visualization frameTimingCollector?.markCameraUpdate(!pos) - worldRenderer?.setFirstPersonCamera(pos, yaw, pitch) + worldRenderer?.setFirstPersonCamera(pos, yaw, pitch, options) }, get soundSystem() { return worldRenderer?.soundSystem @@ -310,10 +310,10 @@ export const createGraphicsBackendBase = () => { startWorld, disconnect, setRendering: backend.setRendering, - updateCamera(pos, yaw, pitch) { + updateCamera(pos, yaw, pitch, options?: UpdateCameraOptions) { const posVec = pos ? new Vec3(pos.x, pos.y, pos.z) : null frameTimingCollector?.markCameraUpdate(!posVec) - backend.updateCamera(posVec, yaw, pitch) + backend.updateCamera(posVec, yaw, pitch, options) }, async callBackendMethod( method: K, diff --git a/src/three/graphicsBackendOffThread.ts b/src/three/graphicsBackendOffThread.ts index 38c384e..8a3e222 100644 --- a/src/three/graphicsBackendOffThread.ts +++ b/src/three/graphicsBackendOffThread.ts @@ -153,8 +153,8 @@ export const createGraphicsBackendOffThread: GraphicsBackendLoader = async initO setRendering(rendering) { proxy.setRendering(rendering) }, - updateCamera(pos, yaw, pitch) { - proxy.updateCamera(pos ? { x: pos.x, y: pos.y, z: pos.z } : null, yaw, pitch) + updateCamera(pos, yaw, pitch, options) { + proxy.updateCamera(pos ? { x: pos.x, y: pos.y, z: pos.z } : null, yaw, pitch, options) }, soundSystem: undefined, backendMethods: backendMethodsProxy diff --git a/src/three/worldRendererThree.ts b/src/three/worldRendererThree.ts index 82b9916..5e9ea7e 100644 --- a/src/three/worldRendererThree.ts +++ b/src/three/worldRendererThree.ts @@ -20,7 +20,12 @@ import { disposeObject, loadThreeJsTextureFromBitmap } from './threeJsUtils' import { CursorBlock } from './world/cursorBlock' import { getItemUv } from './appShared' import { Entities } from './entities' -import { LOCAL_MOVEMENT_TWEEN_DURATION_MS } from './entity/interpolationPolicy' +import { + getCameraMovementTweenDurationMs, + shouldRestartCameraPositionTween, + type CameraMovementMode, + type UpdateCameraOptions +} from './entity/interpolationPolicy' import { ThreeJsSound } from './threeJsSound' import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' @@ -141,6 +146,8 @@ export class WorldRendererThree extends WorldRendererCommon { private currentPosTween?: tweenJs.Tween<{ x: number; y: number; z: number }> private currentRotTween?: tweenJs.Tween<{ pitch: number; yaw: number }> + private currentCameraTarget: { x: number; y: number; z: number } | null = null + private currentCameraMovementMode: CameraMovementMode | null = null // Pre-allocated objects for getThirdPersonCamera (avoid per-frame allocs) private readonly _tpDirection = new THREE.Vector3() @@ -969,10 +976,10 @@ export class WorldRendererThree extends WorldRendererCommon { } } - setFirstPersonCamera(pos: Vec3 | null, yaw: number, pitch: number) { + setFirstPersonCamera(pos: Vec3 | null, yaw: number, pitch: number, options?: UpdateCameraOptions) { const yOffset = this.playerStateReactive.eyeHeight - this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch) + this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch, options) // this.media.tryIntersectMedia() this.updateCameraSectionPos() } @@ -1128,38 +1135,50 @@ export class WorldRendererThree extends WorldRendererCommon { } } - updateCamera(pos: Vec3 | null, yaw: number, pitch: number): void { + updateCamera(pos: Vec3 | null, yaw: number, pitch: number, options?: UpdateCameraOptions): void { // Skip position/rotation updates if cinematic script is running if (this.cinimaticScript.running) { return } - // if (this.freeFlyMode) { - // pos = this.freeFlyState.position - // pitch = this.freeFlyState.pitch - // yaw = this.freeFlyState.yaw - // } - if (pos) { if (this.renderer.xr.isPresenting) { pos.y -= this.camera.position.y // Fix Y position of camera in world } - this.currentPosTween?.stop() - // Use instant camera updates (0 delay) in playground mode when camera controls are enabled - const tweenDelay = this.displayOptions.inWorldRenderingConfig.instantCameraUpdate - ? 0 - : this.playerStateUtils.isSpectatingEntity() - ? 150 - : LOCAL_MOVEMENT_TWEEN_DURATION_MS - this.currentPosTween = new tweenJs.Tween(this.cameraWorldPos) - .to({ x: pos.x, y: pos.y, z: pos.z }, tweenDelay) - .onUpdate(() => { - this.sceneOrigin.update(this.cameraWorldPos.x, this.cameraWorldPos.y, this.cameraWorldPos.z) + const movementMode: CameraMovementMode = this.playerStateUtils.isSpectatingEntity() ? 'spectating' : (options?.movementMode ?? 'local-player') + const instant = options?.instant === true || this.displayOptions.inWorldRenderingConfig.instantCameraUpdate === true + const target = { x: pos.x, y: pos.y, z: pos.z } + const restartTween = shouldRestartCameraPositionTween({ + target, + currentTarget: this.currentCameraTarget, + movementMode, + previousMovementMode: this.currentCameraMovementMode, + instant + }) + const tweenDelay = getCameraMovementTweenDurationMs(movementMode, instant) + + if (restartTween) { + this.currentCameraTarget = { ...target } + this.currentCameraMovementMode = movementMode + this.currentPosTween?.stop() + if (instant || tweenDelay === 0) { + this.cameraWorldPos.x = target.x + this.cameraWorldPos.y = target.y + this.cameraWorldPos.z = target.z + this.sceneOrigin.update(target.x, target.y, target.z) this.cameraObject.position.set(0, 0, 0) - }) - .start() - // this.freeFlyState.position = pos + this.currentPosTween = undefined + } else { + this.currentPosTween = new tweenJs.Tween(this.cameraWorldPos) + .to(target, tweenDelay) + .onUpdate(() => { + this.sceneOrigin.update(this.cameraWorldPos.x, this.cameraWorldPos.y, this.cameraWorldPos.z) + this.cameraObject.position.set(0, 0, 0) + }) + .start() + } + } } if (this.playerStateUtils.isSpectatingEntity()) { From f9d526c8aea0d5cbd9ac8535d4a294d0d8de6973 Mon Sep 17 00:00:00 2001 From: zverev Date: Mon, 13 Jul 2026 10:54:21 +0300 Subject: [PATCH 06/20] fix(entities): camera-sync locally ridden minecarts Keep the driver's minecart aligned with the interpolated camera instead of running an independent position tween. Preserve ordinary interpolation for remote minecarts and keep passenger anchoring synchronized with the rendered vehicle position. --- src/three/entity/interpolationPolicy.test.ts | 3 +- src/three/entity/interpolationPolicy.ts | 2 +- .../localVehicleRendering.integration.test.ts | 64 +++++++++++++++++-- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/three/entity/interpolationPolicy.test.ts b/src/three/entity/interpolationPolicy.test.ts index bbd6434..f575319 100644 --- a/src/three/entity/interpolationPolicy.test.ts +++ b/src/three/entity/interpolationPolicy.test.ts @@ -101,8 +101,9 @@ test('local vehicle skips position tween', () => { expect(getEntityTweenDurationMs({ renderHints: { localVehicle: true } }, true)).toBe(0) }) -test('boat camera-sync policy stays separate from server-vehicle mode', () => { +test('locally ridden vehicle camera-sync policy stays separate from server-vehicle mode', () => { expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true } })).toBe(true) + expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true, passengerLayout: 'minecart' } })).toBe(true) expect(usesCameraSyncedVehiclePosition({ renderHints: { passengerLayout: 'minecart' } })).toBe(false) expect(getCameraMovementTweenDurationMs('local-player')).toBe(50) }) diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index 1000d44..9b1ac76 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -65,7 +65,7 @@ export function shouldRestartCameraPositionTween(args: { return !samePosition(args.target, args.currentTarget) } -/** Local vehicle X/Z follow camera tween; Y uses latest vehicle physics height. */ +/** Locally ridden vehicle X/Z follow camera tween; Y uses latest server vehicle height. */ export function getLocalVehicleWorldPosition(cameraWorldPos: Vec3Like, vehicleY: number): Vec3Like { return { x: cameraWorldPos.x, diff --git a/src/three/entity/localVehicleRendering.integration.test.ts b/src/three/entity/localVehicleRendering.integration.test.ts index a65d912..b0a3c03 100644 --- a/src/three/entity/localVehicleRendering.integration.test.ts +++ b/src/three/entity/localVehicleRendering.integration.test.ts @@ -3,7 +3,9 @@ import { expect, test, vi } from 'vitest' import { SceneOrigin } from '../sceneOrigin' import { anchorVehiclePassengerPosition, releaseVehiclePassengerPosition } from './vehiclePassengerRendering' import { + ENTITY_TWEEN_DURATION_MS, getBoatPassengerWorldPosition, + getEntityTweenDurationMs, getLocalVehicleWorldPosition, getMinecartPassengerWorldPosition, usesCameraSyncedVehiclePosition @@ -19,7 +21,7 @@ function staleOffsetVehiclePosition(cameraWorldPos: Vec3, vehiclePosition: Vec3, } } -test('stale passenger snapshot does not shift rendered boat X/Z relative to camera', () => { +test('stale passenger snapshot does not shift rendered locally ridden vehicle X/Z relative to camera', () => { const stalePassenger = { x: 0, y: 63, z: 0 } const newVehicle = { x: 1, y: 63.2, z: 2 } const cameraWorldPos = { x: 0.4, y: 63.5, z: 0.8 } @@ -48,7 +50,7 @@ test('stale passenger snapshot does not shift rendered boat X/Z relative to came expect(syncedPassenger.z).toBe(newVehicle.z) }) -test('render frames between press and release keep zero horizontal delta to camera', () => { +test('render frames between press and release keep zero horizontal delta to camera for local boat', () => { const vehicleY = 62.75 const frames = [ { x: 0, y: 63.4, z: 0 }, @@ -184,7 +186,61 @@ test('minecart passenger anchor follows vehicle world position without inheritin expect(sceneOrigin.getWorldPosition(passenger)).toEqual(detachedWorldPosition) }) -test('minecart does not use camera-synced vehicle policy', () => { - expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true } })).toBe(true) +test('local minecart with localVehicle uses camera-synced positioning', () => { + expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true, passengerLayout: 'minecart' } })).toBe(true) + expect(getEntityTweenDurationMs({ renderHints: { localVehicle: true, passengerLayout: 'minecart' } }, false)).toBe(0) +}) + +test('remote minecart keeps ordinary entity interpolation', () => { expect(usesCameraSyncedVehiclePosition({ renderHints: { passengerLayout: 'minecart' } })).toBe(false) + expect(getEntityTweenDurationMs({ renderHints: { passengerLayout: 'minecart' } }, false)).toBe(ENTITY_TWEEN_DURATION_MS) +}) + +test('local minecart X/Z stay aligned with camera on intermediate frames between server targets', () => { + const vehicleY = 63.7 + const cameraFrames = [ + { x: 0, y: 64.3, z: 0 }, + { x: 1.2, y: 64.3, z: 0.6 }, + { x: 5, y: 64.3, z: 3 }, + { x: 4.1, y: 64.3, z: 2.5 } + ] + + for (const camera of cameraFrames) { + const minecart = getLocalVehicleWorldPosition(camera, vehicleY) + expect(minecart.x - camera.x).toBeCloseTo(0, 5) + expect(minecart.z - camera.z).toBeCloseTo(0, 5) + expect(minecart.y).toBe(vehicleY) + } +}) + +test('local minecart passenger anchors to camera-synced vehicle position', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const minecart = new THREE.Group() + sceneOrigin.track(minecart) + + const passenger = new THREE.Group() + sceneOrigin.track(passenger) + + const vehicleY = 64 + const cameraFrames = [ + { x: 100, y: 64.7, z: 200 }, + { x: 101.4, y: 64.7, z: 200.8 }, + { x: 103, y: 64.7, z: 202 } + ] + + for (const camera of cameraFrames) { + const syncedMinecart = getLocalVehicleWorldPosition(camera, vehicleY) + minecart.position.set(syncedMinecart.x, syncedMinecart.y, syncedMinecart.z) + const seatPosition = getMinecartPassengerWorldPosition(syncedMinecart) + anchorVehiclePassengerPosition(passenger, seatPosition, '42') + const passengerWorld = sceneOrigin.getWorldPosition(passenger)! + + expect(syncedMinecart.x - camera.x).toBeCloseTo(0, 5) + expect(syncedMinecart.z - camera.z).toBeCloseTo(0, 5) + expect(passengerWorld.x).toBe(syncedMinecart.x) + expect(passengerWorld.z).toBe(syncedMinecart.z) + expect(passengerWorld.y).toBeCloseTo(63.5875) + } }) From 1bbb0d9882fe981e0befcfd8ff38099d585c4fec Mon Sep 17 00:00:00 2001 From: zverev Date: Mon, 13 Jul 2026 12:13:40 +0300 Subject: [PATCH 07/20] feat: render players riding horses Anchor local and remote passengers to interpolated horse positions using variant-specific vanilla seat offsets. Add a riding player pose and release passenger anchors cleanly after dismounting. --- src/three/entities.ts | 25 +++++- src/three/entity/animations.js | 60 +++++++++------ .../entity/horsePassengerRendering.test.ts | 76 +++++++++++++++++++ src/three/entity/interpolationPolicy.ts | 28 ++++++- 4 files changed, 159 insertions(+), 30 deletions(-) create mode 100644 src/three/entity/horsePassengerRendering.test.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index e32d81d..d638cb4 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -27,8 +27,10 @@ import { ENTITY_TWEEN_DURATION_MS, getBoatPassengerWorldPosition, getEntityTweenDurationMs, + getHorsePassengerWorldPosition, getLocalVehicleWorldPosition, getMinecartPassengerWorldPosition, + isRideableHorseEntityName, isRideableMinecartEntityName, type EntityRenderHints, usesCameraSyncedVehiclePosition @@ -522,9 +524,10 @@ export class Entities { const vehicleName = vehicle['realName'] ?? vehicle.originalEntity.name const layout = renderHints?.passengerLayout ?? (isBoatEntityName(vehicleName) ? 'boat' : undefined) - if (layout !== 'boat' && layout !== 'minecart') continue + if (layout !== 'boat' && layout !== 'minecart' && layout !== 'horse') continue if (layout === 'boat' && !isBoatEntityName(vehicleName)) continue if (layout === 'minecart' && !isRideableMinecartEntityName(vehicleName)) continue + if (layout === 'horse' && !isRideableHorseEntityName(vehicleName)) continue const vehicleWorldPos = this.worldRenderer.sceneOrigin.getWorldPosition(vehicle) if (!vehicleWorldPos) continue @@ -539,7 +542,9 @@ export class Entities { const passengerWorldPos = layout === 'minecart' ? getMinecartPassengerWorldPosition(vehicleWorldPos) - : getBoatPassengerWorldPosition(vehicleWorldPos, vehicle.rotation.y, passengerIndex, passengerIds.length) + : layout === 'horse' + ? getHorsePassengerWorldPosition(vehicleWorldPos, vehicleName, vehicle.originalEntity.height ?? 1.6) + : getBoatPassengerWorldPosition(vehicleWorldPos, vehicle.rotation.y, passengerIndex, passengerIds.length) anchorVehiclePassengerPosition(passenger, passengerWorldPos, vehicleId) attachedPassengers.add(passenger) @@ -822,7 +827,10 @@ export class Entities { } } - private applyMovementAnimation(playerObject: PlayerObjectType, animation: 'walking' | 'running' | 'oneSwing' | 'idle' | 'crouch' | 'crouchWalking'): void { + private applyMovementAnimation( + playerObject: PlayerObjectType, + animation: 'walking' | 'running' | 'oneSwing' | 'idle' | 'crouch' | 'crouchWalking' | 'riding' + ): void { const anim = playerObject.animation as WalkingGeneralSwing | undefined if (!anim) return @@ -832,12 +840,21 @@ export class Entities { } anim.switchAnimationCallback = null + if (animation === 'riding') { + anim.isMoving = false + anim.isRunning = false + anim.isCrouched = false + anim.isRiding = true + return + } + + anim.isRiding = false anim.isMoving = animation === 'walking' || animation === 'running' || animation === 'crouchWalking' anim.isRunning = animation === 'running' anim.isCrouched = animation === 'crouch' || animation === 'crouchWalking' } - playAnimation(entityPlayerId, animation: 'walking' | 'running' | 'oneSwing' | 'idle' | 'crouch' | 'crouchWalking') { + playAnimation(entityPlayerId, animation: 'walking' | 'running' | 'oneSwing' | 'idle' | 'crouch' | 'crouchWalking' | 'riding') { const playerObject = entityPlayerId === 'player_entity' ? this.playerEntity?.playerObject : (this.getPlayerObject(entityPlayerId) ?? this.playerEntity?.playerObject) diff --git a/src/three/entity/animations.js b/src/three/entity/animations.js index b5b92cb..f4ffdd3 100644 --- a/src/three/entity/animations.js +++ b/src/three/entity/animations.js @@ -32,6 +32,7 @@ export class WalkingGeneralSwing extends PlayerAnimation { isRunning = false isMoving = true isCrouched = false + isRiding = false _dt = 0 _phase = 0 @@ -127,7 +128,7 @@ export class WalkingGeneralSwing extends PlayerAnimation { if (!this._defaults) this._captureDefaults(player) this._applyDefaults(player) - const targetMove = this.isMoving ? 1 : 0 + const targetMove = this.isRiding ? 0 : this.isMoving ? 1 : 0 const kMove = Math.min(1, dt * 20) this._moveBlend += (targetMove - this._moveBlend) * kMove @@ -139,35 +140,44 @@ export class WalkingGeneralSwing extends PlayerAnimation { applyCrouchPose(player, this.isCrouched ? 1 : 0) - const boundary = this.isRunning ? Math.cos(t) : Math.sin(t) - if (Math.abs(boundary) < 0.02) { - if (this.switchAnimationCallback) { - reset = true + if (this.isRiding) { + player.skin.leftLeg.rotation.x = -Math.PI * 0.55 + player.skin.rightLeg.rotation.x = -Math.PI * 0.55 + player.skin.leftArm.rotation.x = -Math.PI * 0.35 + player.skin.rightArm.rotation.x = -Math.PI * 0.35 + player.skin.leftArm.rotation.z = Math.PI * 0.08 + player.skin.rightArm.rotation.z = -Math.PI * 0.08 + } else { + const boundary = this.isRunning ? Math.cos(t) : Math.sin(t) + if (Math.abs(boundary) < 0.02) { + if (this.switchAnimationCallback) { + reset = true + } } - } - if (this.isRunning) { - player.skin.leftLeg.rotation.x += Math.cos(t + Math.PI) * 1.3 * this._moveBlend - player.skin.rightLeg.rotation.x += Math.cos(t) * 1.3 * this._moveBlend - } else { - player.skin.leftLeg.rotation.x += Math.sin(t) * 0.5 * this._moveBlend - player.skin.rightLeg.rotation.x += Math.sin(t + Math.PI) * 0.5 * this._moveBlend - } + if (this.isRunning) { + player.skin.leftLeg.rotation.x += Math.cos(t + Math.PI) * 1.3 * this._moveBlend + player.skin.rightLeg.rotation.x += Math.cos(t) * 1.3 * this._moveBlend + } else { + player.skin.leftLeg.rotation.x += Math.sin(t) * 0.5 * this._moveBlend + player.skin.rightLeg.rotation.x += Math.sin(t + Math.PI) * 0.5 * this._moveBlend + } - if (this.isRunning) { - player.skin.leftArm.rotation.x += Math.cos(t) * 1.5 * this._moveBlend - player.skin.rightArm.rotation.x += Math.cos(t + Math.PI) * 1.5 * this._moveBlend + if (this.isRunning) { + player.skin.leftArm.rotation.x += Math.cos(t) * 1.5 * this._moveBlend + player.skin.rightArm.rotation.x += Math.cos(t + Math.PI) * 1.5 * this._moveBlend - const basicArmRotationZ = Math.PI * 0.1 - player.skin.leftArm.rotation.z += (Math.cos(t) * 0.1 + basicArmRotationZ) * this._moveBlend - player.skin.rightArm.rotation.z += (Math.cos(t + Math.PI) * 0.1 - basicArmRotationZ) * this._moveBlend - } else { - player.skin.leftArm.rotation.x += Math.sin(t + Math.PI) * 0.5 * this._moveBlend - player.skin.rightArm.rotation.x += Math.sin(t) * 0.5 * this._moveBlend + const basicArmRotationZ = Math.PI * 0.1 + player.skin.leftArm.rotation.z += (Math.cos(t) * 0.1 + basicArmRotationZ) * this._moveBlend + player.skin.rightArm.rotation.z += (Math.cos(t + Math.PI) * 0.1 - basicArmRotationZ) * this._moveBlend + } else { + player.skin.leftArm.rotation.x += Math.sin(t + Math.PI) * 0.5 * this._moveBlend + player.skin.rightArm.rotation.x += Math.sin(t) * 0.5 * this._moveBlend - const basicArmRotationZ = Math.PI * 0.02 - player.skin.leftArm.rotation.z += (Math.cos(t) * 0.03 + basicArmRotationZ) * this._moveBlend - player.skin.rightArm.rotation.z += (Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ) * this._moveBlend + const basicArmRotationZ = Math.PI * 0.02 + player.skin.leftArm.rotation.z += (Math.cos(t) * 0.03 + basicArmRotationZ) * this._moveBlend + player.skin.rightArm.rotation.z += (Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ) * this._moveBlend + } } if (this._swingTime !== null) { diff --git a/src/three/entity/horsePassengerRendering.test.ts b/src/three/entity/horsePassengerRendering.test.ts new file mode 100644 index 0000000..00d0839 --- /dev/null +++ b/src/three/entity/horsePassengerRendering.test.ts @@ -0,0 +1,76 @@ +import * as THREE from 'three' +import { expect, test } from 'vitest' +import { SceneOrigin } from '../sceneOrigin' +import { anchorVehiclePassengerPosition, releaseVehiclePassengerPosition } from './vehiclePassengerRendering' +import { getHorsePassengerWorldPosition } from './interpolationPolicy' + +const LOCAL_PLAYER_ID = 7 +const REMOTE_PLAYER_ID = 8 +const HORSE_ID = '42' +const HORSE_HEIGHT = 1.6 + +type PassengerLike = THREE.Group & { + playerObject: Record + originalEntity: { id: number } + userData: Record +} + +function makePassenger(id: number): PassengerLike { + const passenger = new THREE.Group() as PassengerLike + passenger.playerObject = {} + passenger.originalEntity = { id } + passenger.userData = {} + return passenger +} + +test.each([ + ['horse', 1.6, 0.85], + ['zombie_horse', 1.6, 0.85], + ['donkey', 1.5, 0.525], + ['mule', 1.6, 0.6], + ['skeleton_horse', 1.6, 0.6625] +])('horse variant %s seat Y uses vanilla offset', (name, height, expectedFeetOffset) => { + const seat = getHorsePassengerWorldPosition({ x: 0, y: 64, z: 0 }, name, height) + expect(seat.y).toBeCloseTo(64 + expectedFeetOffset, 5) +}) + +test('local and remote horse passengers share the same anchor', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const horse = new THREE.Group() + sceneOrigin.track(horse) + horse.position.set(100, 64, 200) + + const localPlayer = makePassenger(LOCAL_PLAYER_ID) + const remotePlayer = makePassenger(REMOTE_PLAYER_ID) + sceneOrigin.track(localPlayer) + sceneOrigin.track(remotePlayer) + + const vehicleWorldPos = sceneOrigin.getWorldPosition(horse)! + const seatPosition = getHorsePassengerWorldPosition(vehicleWorldPos, 'horse', HORSE_HEIGHT) + + anchorVehiclePassengerPosition(localPlayer, seatPosition, HORSE_ID) + anchorVehiclePassengerPosition(remotePlayer, seatPosition, HORSE_ID) + + const localWorld = sceneOrigin.getWorldPosition(localPlayer) + const remoteWorld = sceneOrigin.getWorldPosition(remotePlayer) + expect(localWorld).toEqual(remoteWorld) +}) + +test('passenger anchor releases after detach', () => { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(0, 0, 0) + + const horse = new THREE.Group() + sceneOrigin.track(horse) + horse.position.set(0, 64, 0) + + const passenger = makePassenger(LOCAL_PLAYER_ID) + sceneOrigin.track(passenger) + + const seatPosition = getHorsePassengerWorldPosition({ x: 0, y: 64, z: 0 }, 'horse', HORSE_HEIGHT) + anchorVehiclePassengerPosition(passenger, seatPosition, HORSE_ID) + releaseVehiclePassengerPosition(passenger, HORSE_ID, sceneOrigin.getWorldPosition(passenger)) + expect(passenger.userData._passengerVehicleId).toBeUndefined() +}) diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index 9b1ac76..0e1a446 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -16,7 +16,7 @@ export type EntityRenderHints = { localVehicle?: boolean boatWaterPatchVisible?: boolean passengerIds?: number[] - passengerLayout?: 'boat' | 'minecart' + passengerLayout?: 'boat' | 'minecart' | 'horse' /** @deprecated Use passengerIds */ boatPassengerIds?: number[] } @@ -91,6 +91,13 @@ const RIDEABLE_MINECART_ENTITY_NAMES = new Set([ 'command_block_minecart' ]) +const RIDEABLE_HORSE_ENTITY_NAMES = new Set(['horse', 'donkey', 'mule', 'skeleton_horse', 'zombie_horse']) + +export function isRideableHorseEntityName(name?: string): boolean { + if (!name) return false + return RIDEABLE_HORSE_ENTITY_NAMES.has(name) +} + export function isRideableMinecartEntityName(name?: string): boolean { if (!name) return false return RIDEABLE_MINECART_ENTITY_NAMES.has(name) @@ -119,3 +126,22 @@ export function getMinecartPassengerWorldPosition(minecartWorldPos: Vec3Like): V z: minecartWorldPos.z } } + +function getHorsePassengerFeetOffsetY(name: string | undefined, height: number): number { + let variantOffset = 0 + if (name === 'donkey' || name === 'mule') { + variantOffset = 0.25 + } else if (name === 'skeleton_horse') { + variantOffset = 0.1875 + } + return height * 0.75 - variantOffset - 0.35 +} + +/** Vanilla 1.17.1 AbstractHorse positionRider feet Y for a centered player passenger. */ +export function getHorsePassengerWorldPosition(vehicleWorldPos: Vec3Like, name: string | undefined, height = 1.6): Vec3Like { + return { + x: vehicleWorldPos.x, + y: vehicleWorldPos.y + getHorsePassengerFeetOffsetY(name, height), + z: vehicleWorldPos.z + } +} From c5326e27c9806418ac285f75c9e785df60dd798d Mon Sep 17 00:00:00 2001 From: zverev Date: Tue, 14 Jul 2026 20:31:07 +0300 Subject: [PATCH 08/20] fix(entities): keep local horse aligned during jumps Resolve the controlled horse height from the camera position to prevent vertical jitter during jump interpolation. --- src/three/entities.ts | 22 ++++- src/three/entity/interpolationPolicy.test.ts | 93 ++++++++++++++++++++ src/three/entity/interpolationPolicy.ts | 19 ++++ 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/three/entities.ts b/src/three/entities.ts index d638cb4..858d70b 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -28,11 +28,12 @@ import { getBoatPassengerWorldPosition, getEntityTweenDurationMs, getHorsePassengerWorldPosition, - getLocalVehicleWorldPosition, getMinecartPassengerWorldPosition, isRideableHorseEntityName, isRideableMinecartEntityName, + resolveLocalVehicleWorldPosition, type EntityRenderHints, + type Vec3Like, usesCameraSyncedVehiclePosition } from './entity/interpolationPolicy' import { getMesh } from './entity/EntityMesh' @@ -483,8 +484,7 @@ export class Entities { } if (entity.userData.renderHints?.localVehicle) { - const vehicleY = entity.userData._localVehicleY ?? entity.position.y - const worldPos = getLocalVehicleWorldPosition(this.worldRenderer.cameraWorldPos, vehicleY) + const worldPos = this.resolveLocalVehicleRenderWorldPos(entity) entity.position.set(worldPos.x, worldPos.y, worldPos.z) } @@ -514,6 +514,20 @@ export class Entities { return this.entities[passengerId] } + private resolveLocalVehicleRenderWorldPos(sceneEntity: SceneEntity): Vec3Like { + const originalEntity = sceneEntity.originalEntity + const renderHints = sceneEntity.userData.renderHints as EntityRenderHints | undefined + const rawVehicleY = sceneEntity.userData._localVehicleY ?? originalEntity.position?.y ?? sceneEntity.position.y + return resolveLocalVehicleWorldPosition({ + cameraWorldPos: this.worldRenderer.cameraWorldPos, + rawVehicleY, + eyeHeight: this.worldRenderer.playerStateReactive.eyeHeight, + vehicleName: sceneEntity['realName'] ?? originalEntity.name, + vehicleHeight: originalEntity.height ?? 1.6, + verticalCameraLock: renderHints?.localVehicleVerticalCameraLock, + }) + } + updateVehiclePassengerPositions() { const attachedPassengers = new Set() @@ -1335,7 +1349,7 @@ export class Entities { e.userData._localVehicleY = entity.position.y e.userData._posTween?.stop() e.userData._posTween = undefined - const worldPos = getLocalVehicleWorldPosition(this.worldRenderer.cameraWorldPos, entity.position.y) + const worldPos = this.resolveLocalVehicleRenderWorldPos(e) e.position.set(worldPos.x, worldPos.y, worldPos.z) } else { const ANIMATION_DURATION = getEntityTweenDurationMs(entity, justAdded) diff --git a/src/three/entity/interpolationPolicy.test.ts b/src/three/entity/interpolationPolicy.test.ts index f575319..508a40a 100644 --- a/src/three/entity/interpolationPolicy.test.ts +++ b/src/three/entity/interpolationPolicy.test.ts @@ -6,6 +6,7 @@ import { getCameraMovementTweenDurationMs, getEntityTweenDurationMs, getLocalVehicleWorldPosition, + resolveLocalVehicleWorldPosition, samePosition, shouldRestartCameraPositionTween, usesCameraSyncedVehiclePosition @@ -139,3 +140,95 @@ test('intermediate camera frames keep boat X/Z aligned with player', () => { expect(mid.y).toBe(vehicleY) expect(end.y).toBe(vehicleY) }) + +test('horse vertical camera lock keeps constant offset from camera across tween progress', () => { + const eyeHeight = 1.62 + const feetOffset = 0.85 + const expectedGap = -(eyeHeight + feetOffset) + const rawVehicleY = 64 + const cameraStartY = rawVehicleY + feetOffset + eyeHeight + const tweenProgress = [0, 0.25, 0.5, 0.75, 1] + + for (const progress of tweenProgress) { + const cameraY = cameraStartY + progress * 0.8 + const resolved = resolveLocalVehicleWorldPosition({ + cameraWorldPos: { x: 1 + progress, y: cameraY, z: 2 }, + rawVehicleY, + eyeHeight, + vehicleName: 'horse', + vehicleHeight: 1.6, + verticalCameraLock: 'horse', + }) + expect(resolved.y - cameraY).toBeCloseTo(expectedGap, 5) + expect(resolved.x).toBe(1 + progress) + expect(resolved.z).toBe(2) + } +}) + +test('horse vertical camera lock uses vanilla feet offset value', () => { + const eyeHeight = 1.62 + const cameraY = 66.47 + const resolved = resolveLocalVehicleWorldPosition({ + cameraWorldPos: { x: 0, y: cameraY, z: 0 }, + rawVehicleY: 64, + eyeHeight, + vehicleName: 'horse', + vehicleHeight: 1.6, + verticalCameraLock: 'horse', + }) + expect(resolved.y).toBeCloseTo(cameraY - 2.47, 5) +}) + +test('horse vertical camera lock is variant-aware for donkey and skeleton_horse', () => { + const eyeHeight = 1.62 + const cameraY = 65 + + const donkey = resolveLocalVehicleWorldPosition({ + cameraWorldPos: { x: 0, y: cameraY, z: 0 }, + rawVehicleY: 64, + eyeHeight, + vehicleName: 'donkey', + vehicleHeight: 1.6, + verticalCameraLock: 'horse', + }) + expect(donkey.y).toBeCloseTo(cameraY - eyeHeight - 0.6, 5) + + const skeleton = resolveLocalVehicleWorldPosition({ + cameraWorldPos: { x: 0, y: cameraY, z: 0 }, + rawVehicleY: 64, + eyeHeight, + vehicleName: 'skeleton_horse', + vehicleHeight: 1.6, + verticalCameraLock: 'horse', + }) + expect(skeleton.y).toBeCloseTo(cameraY - eyeHeight - 0.6625, 5) +}) + +test('without vertical camera lock resolver keeps raw vehicle Y', () => { + const camera = { x: 10, y: 64.5, z: -3 } + const rawVehicleY = 63.2 + expect( + resolveLocalVehicleWorldPosition({ + cameraWorldPos: camera, + rawVehicleY, + eyeHeight: 1.62, + vehicleName: 'horse', + vehicleHeight: 1.6, + }) + ).toEqual(getLocalVehicleWorldPosition(camera, rawVehicleY)) +}) + +test('horse vertical camera lock falls back to raw Y when result is non-finite', () => { + const camera = { x: 1, y: 64, z: 2 } + const rawVehicleY = 63.5 + expect( + resolveLocalVehicleWorldPosition({ + cameraWorldPos: camera, + rawVehicleY, + eyeHeight: Number.NaN, + vehicleName: 'horse', + vehicleHeight: 1.6, + verticalCameraLock: 'horse', + }) + ).toEqual(getLocalVehicleWorldPosition(camera, rawVehicleY)) +}) diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index 0e1a446..60e037c 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -14,6 +14,7 @@ export type Vec3Like = { x: number; y: number; z: number } export type EntityRenderHints = { localVehicle?: boolean + localVehicleVerticalCameraLock?: 'horse' boatWaterPatchVisible?: boolean passengerIds?: number[] passengerLayout?: 'boat' | 'minecart' | 'horse' @@ -74,6 +75,24 @@ export function getLocalVehicleWorldPosition(cameraWorldPos: Vec3Like, vehicleY: } } +export function resolveLocalVehicleWorldPosition(args: { + cameraWorldPos: Vec3Like + rawVehicleY: number + eyeHeight: number + vehicleName: string | undefined + vehicleHeight: number + verticalCameraLock?: 'horse' +}): Vec3Like { + const { cameraWorldPos, rawVehicleY, eyeHeight, vehicleName, vehicleHeight, verticalCameraLock } = args + if (verticalCameraLock === 'horse') { + const y = cameraWorldPos.y - eyeHeight - getHorsePassengerFeetOffsetY(vehicleName, vehicleHeight) + if (Number.isFinite(y)) { + return { x: cameraWorldPos.x, y, z: cameraWorldPos.z } + } + } + return getLocalVehicleWorldPosition(cameraWorldPos, rawVehicleY) +} + const BOAT_PASSENGER_RIDING_OFFSET_Y = -0.1 const PLAYER_RIDING_OFFSET_Y = -0.35 /** Vanilla 1.17.1 EntityType minecart passengerAttachments(0.1875F) */ From f082f6286227c9e089a6e194e7654bcb22a7bff9 Mon Sep 17 00:00:00 2001 From: zverev Date: Tue, 14 Jul 2026 20:33:13 +0300 Subject: [PATCH 09/20] chore: format (prittier) --- src/three/entities.ts | 2 +- src/three/entity/interpolationPolicy.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/three/entities.ts b/src/three/entities.ts index 858d70b..1773cb5 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -524,7 +524,7 @@ export class Entities { eyeHeight: this.worldRenderer.playerStateReactive.eyeHeight, vehicleName: sceneEntity['realName'] ?? originalEntity.name, vehicleHeight: originalEntity.height ?? 1.6, - verticalCameraLock: renderHints?.localVehicleVerticalCameraLock, + verticalCameraLock: renderHints?.localVehicleVerticalCameraLock }) } diff --git a/src/three/entity/interpolationPolicy.test.ts b/src/three/entity/interpolationPolicy.test.ts index 508a40a..a1407fc 100644 --- a/src/three/entity/interpolationPolicy.test.ts +++ b/src/three/entity/interpolationPolicy.test.ts @@ -157,7 +157,7 @@ test('horse vertical camera lock keeps constant offset from camera across tween eyeHeight, vehicleName: 'horse', vehicleHeight: 1.6, - verticalCameraLock: 'horse', + verticalCameraLock: 'horse' }) expect(resolved.y - cameraY).toBeCloseTo(expectedGap, 5) expect(resolved.x).toBe(1 + progress) @@ -174,7 +174,7 @@ test('horse vertical camera lock uses vanilla feet offset value', () => { eyeHeight, vehicleName: 'horse', vehicleHeight: 1.6, - verticalCameraLock: 'horse', + verticalCameraLock: 'horse' }) expect(resolved.y).toBeCloseTo(cameraY - 2.47, 5) }) @@ -189,7 +189,7 @@ test('horse vertical camera lock is variant-aware for donkey and skeleton_horse' eyeHeight, vehicleName: 'donkey', vehicleHeight: 1.6, - verticalCameraLock: 'horse', + verticalCameraLock: 'horse' }) expect(donkey.y).toBeCloseTo(cameraY - eyeHeight - 0.6, 5) @@ -199,7 +199,7 @@ test('horse vertical camera lock is variant-aware for donkey and skeleton_horse' eyeHeight, vehicleName: 'skeleton_horse', vehicleHeight: 1.6, - verticalCameraLock: 'horse', + verticalCameraLock: 'horse' }) expect(skeleton.y).toBeCloseTo(cameraY - eyeHeight - 0.6625, 5) }) @@ -213,7 +213,7 @@ test('without vertical camera lock resolver keeps raw vehicle Y', () => { rawVehicleY, eyeHeight: 1.62, vehicleName: 'horse', - vehicleHeight: 1.6, + vehicleHeight: 1.6 }) ).toEqual(getLocalVehicleWorldPosition(camera, rawVehicleY)) }) @@ -228,7 +228,7 @@ test('horse vertical camera lock falls back to raw Y when result is non-finite', eyeHeight: Number.NaN, vehicleName: 'horse', vehicleHeight: 1.6, - verticalCameraLock: 'horse', + verticalCameraLock: 'horse' }) ).toEqual(getLocalVehicleWorldPosition(camera, rawVehicleY)) }) From 15f4932e4413a7f46eea74f783093cd80e21cf9f Mon Sep 17 00:00:00 2001 From: zverev Date: Fri, 17 Jul 2026 22:47:08 +0300 Subject: [PATCH 10/20] fix(entities): improve mounted player rendering - Match Minecraft 1.17.1 riding limb rotations and preserve them during arm swings. - Anchor local boat and minecart bodies to rendered seat positions without moving the camera. - Correct the 1.17.1 minecart passenger offset and preserve the existing local horse path. - Add finite-coordinate guards, anchor release handling, and production-path tests. Boat leg clipping and the horse model gap remain visual follow-up work. --- src/three/entities.ts | 57 +---- src/three/entity/animations.js | 16 +- src/three/entity/animations.test.ts | 190 ++++++++++++++++ src/three/entity/interpolationPolicy.test.ts | 17 ++ src/three/entity/interpolationPolicy.ts | 8 +- .../localVehicleRendering.integration.test.ts | 6 +- .../minecartLocalPassengerRendering.test.ts | 29 ++- .../entity/networkHeadPitchRendering.test.ts | 2 +- .../entity/vehiclePassengerProduction.test.ts | 213 ++++++++++++++++++ src/three/entity/vehiclePassengerRendering.ts | 11 + src/three/entity/vehiclePassengerUpdate.ts | 81 +++++++ 11 files changed, 556 insertions(+), 74 deletions(-) create mode 100644 src/three/entity/animations.test.ts create mode 100644 src/three/entity/vehiclePassengerProduction.test.ts create mode 100644 src/three/entity/vehiclePassengerUpdate.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index 1773cb5..67ed48e 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -21,20 +21,17 @@ import { createItemMesh } from './itemMesh' import * as Entity from './entity/EntityMesh' import { setupBoatMesh, disposeBoatWaterPatch } from './entity/boatRenderSetup' import { getBoatMeshYawOffset, isBoatEntityName } from './entity/boatModelRotation' -import { anchorVehiclePassengerPosition, releaseVehiclePassengerPosition } from './entity/vehiclePassengerRendering' +import { releaseVehiclePassengerPosition } from './entity/vehiclePassengerRendering' +import { updateVehiclePassengerPositions as applyVehiclePassengerPositions } from './entity/vehiclePassengerUpdate' import { applyNetworkHeadPitch, storeNetworkHeadPitch } from './entity/networkHeadPitchRendering' import { ENTITY_TWEEN_DURATION_MS, - getBoatPassengerWorldPosition, getEntityTweenDurationMs, - getHorsePassengerWorldPosition, - getMinecartPassengerWorldPosition, - isRideableHorseEntityName, - isRideableMinecartEntityName, resolveLocalVehicleWorldPosition, type EntityRenderHints, type Vec3Like, - usesCameraSyncedVehiclePosition + usesCameraSyncedVehiclePosition, + isRideableMinecartEntityName } from './entity/interpolationPolicy' import { getMesh } from './entity/EntityMesh' import { WalkingGeneralSwing } from './entity/animations' @@ -529,47 +526,11 @@ export class Entities { } updateVehiclePassengerPositions() { - const attachedPassengers = new Set() - - for (const vehicle of Object.values(this.entities)) { - const renderHints = vehicle.userData.renderHints as EntityRenderHints | undefined - const passengerIds = renderHints?.passengerIds ?? renderHints?.boatPassengerIds - if (!Array.isArray(passengerIds) || passengerIds.length === 0) continue - - const vehicleName = vehicle['realName'] ?? vehicle.originalEntity.name - const layout = renderHints?.passengerLayout ?? (isBoatEntityName(vehicleName) ? 'boat' : undefined) - if (layout !== 'boat' && layout !== 'minecart' && layout !== 'horse') continue - if (layout === 'boat' && !isBoatEntityName(vehicleName)) continue - if (layout === 'minecart' && !isRideableMinecartEntityName(vehicleName)) continue - if (layout === 'horse' && !isRideableHorseEntityName(vehicleName)) continue - - const vehicleWorldPos = this.worldRenderer.sceneOrigin.getWorldPosition(vehicle) - if (!vehicleWorldPos) continue - const vehicleId = String(vehicle.originalEntity.id) - - for (const [passengerIndex, passengerId] of passengerIds.entries()) { - const isLocalPassenger = passengerId === this.playerEntity?.originalEntity.id - const passenger = isLocalPassenger ? this.playerEntity : this.entities[passengerId] - if (!passenger?.playerObject) continue - if (passenger === this.playerEntity && layout !== 'minecart') continue - - const passengerWorldPos = - layout === 'minecart' - ? getMinecartPassengerWorldPosition(vehicleWorldPos) - : layout === 'horse' - ? getHorsePassengerWorldPosition(vehicleWorldPos, vehicleName, vehicle.originalEntity.height ?? 1.6) - : getBoatPassengerWorldPosition(vehicleWorldPos, vehicle.rotation.y, passengerIndex, passengerIds.length) - - anchorVehiclePassengerPosition(passenger, passengerWorldPos, vehicleId) - attachedPassengers.add(passenger) - } - } - - for (const passenger of [...Object.values(this.entities), ...(this.playerEntity ? [this.playerEntity] : [])]) { - const vehicleId = passenger.userData._passengerVehicleId ?? (passenger.userData._boatPassengerVehicleId as string | undefined) - if (vehicleId === undefined || attachedPassengers.has(passenger)) continue - releaseVehiclePassengerPosition(passenger, vehicleId, this.worldRenderer.sceneOrigin.getWorldPosition(passenger)) - } + applyVehiclePassengerPositions({ + entities: this.entities, + localPlayer: this.playerEntity, + getWorldPosition: target => this.worldRenderer.sceneOrigin.getWorldPosition(target as SceneEntity) + }) } /** @deprecated Use updateVehiclePassengerPositions */ diff --git a/src/three/entity/animations.js b/src/three/entity/animations.js index f4ffdd3..18af08f 100644 --- a/src/three/entity/animations.js +++ b/src/three/entity/animations.js @@ -141,12 +141,16 @@ export class WalkingGeneralSwing extends PlayerAnimation { applyCrouchPose(player, this.isCrouched ? 1 : 0) if (this.isRiding) { - player.skin.leftLeg.rotation.x = -Math.PI * 0.55 - player.skin.rightLeg.rotation.x = -Math.PI * 0.55 - player.skin.leftArm.rotation.x = -Math.PI * 0.35 - player.skin.rightArm.rotation.x = -Math.PI * 0.35 - player.skin.leftArm.rotation.z = Math.PI * 0.08 - player.skin.rightArm.rotation.z = -Math.PI * 0.08 + player.skin.rightArm.rotation.x -= Math.PI / 5 + player.skin.leftArm.rotation.x -= Math.PI / 5 + + player.skin.rightLeg.rotation.x = -1.4137167 + player.skin.rightLeg.rotation.y = Math.PI / 10 + player.skin.rightLeg.rotation.z = Math.PI / 40 + + player.skin.leftLeg.rotation.x = -1.4137167 + player.skin.leftLeg.rotation.y = -Math.PI / 10 + player.skin.leftLeg.rotation.z = -Math.PI / 40 } else { const boundary = this.isRunning ? Math.cos(t) : Math.sin(t) if (Math.abs(boundary) < 0.02) { diff --git a/src/three/entity/animations.test.ts b/src/three/entity/animations.test.ts new file mode 100644 index 0000000..e69d772 --- /dev/null +++ b/src/three/entity/animations.test.ts @@ -0,0 +1,190 @@ +import * as THREE from 'three' +import { PlayerObject } from 'skinview3d' +import { expect, test } from 'vitest' +import type { PlayerObjectType } from '../../lib/createPlayerObject' +import { WalkingGeneralSwing } from './animations' + +const RIDING_LEG_X = -1.4137167 +const RIDING_LEG_Y = Math.PI / 10 +const RIDING_LEG_Z = Math.PI / 40 +const RIDING_ARM_DELTA = Math.PI / 5 + +function makePlayerObject(): PlayerObjectType { + const playerObject = new PlayerObject() as PlayerObjectType + playerObject.skin.leftLeg.rotation.set(0.1, 0.05, -0.02) + playerObject.skin.rightLeg.rotation.set(-0.08, -0.04, 0.03) + playerObject.skin.leftArm.rotation.set(0.2, 0, 0.01) + playerObject.skin.rightArm.rotation.set(-0.15, 0, -0.01) + return playerObject +} + +function captureRotations(playerObject: PlayerObjectType) { + const { leftArm, rightArm, leftLeg, rightLeg } = playerObject.skin + return { + leftArm: leftArm.rotation.clone(), + rightArm: rightArm.rotation.clone(), + leftLeg: leftLeg.rotation.clone(), + rightLeg: rightLeg.rotation.clone(), + } +} + +function runAnimationFrame(animation: WalkingGeneralSwing, playerObject: PlayerObjectType, dt = 0.05) { + animation.update(playerObject, dt) +} + +function expectRidingLegPose(playerObject: PlayerObjectType) { + expect(playerObject.skin.rightLeg.rotation.x).toBeCloseTo(RIDING_LEG_X) + expect(playerObject.skin.rightLeg.rotation.y).toBeCloseTo(RIDING_LEG_Y) + expect(playerObject.skin.rightLeg.rotation.z).toBeCloseTo(RIDING_LEG_Z) + + expect(playerObject.skin.leftLeg.rotation.x).toBeCloseTo(RIDING_LEG_X) + expect(playerObject.skin.leftLeg.rotation.y).toBeCloseTo(-RIDING_LEG_Y) + expect(playerObject.skin.leftLeg.rotation.z).toBeCloseTo(-RIDING_LEG_Z) +} + +test('riding sets exact leg X/Y/Z rotations', () => { + const playerObject = makePlayerObject() + const animation = new WalkingGeneralSwing() + animation._captureDefaults(playerObject) + animation.isRiding = true + + runAnimationFrame(animation, playerObject) + + expectRidingLegPose(playerObject) +}) + +test('riding applies arm X delta relative to defaults', () => { + const playerObject = makePlayerObject() + const defaults = captureRotations(playerObject) + const animation = new WalkingGeneralSwing() + animation._captureDefaults(playerObject) + animation.isRiding = true + + runAnimationFrame(animation, playerObject) + + expect(playerObject.skin.rightArm.rotation.x).toBeCloseTo(defaults.rightArm.x - RIDING_ARM_DELTA) + expect(playerObject.skin.leftArm.rotation.x).toBeCloseTo(defaults.leftArm.x - RIDING_ARM_DELTA) +}) + +test('riding does not apply old arm Z spread', () => { + const playerObject = makePlayerObject() + const defaults = captureRotations(playerObject) + const animation = new WalkingGeneralSwing() + animation._captureDefaults(playerObject) + animation.isRiding = true + + runAnimationFrame(animation, playerObject) + + expect(playerObject.skin.leftArm.rotation.z).toBeCloseTo(defaults.leftArm.z) + expect(playerObject.skin.rightArm.rotation.z).toBeCloseTo(defaults.rightArm.z) +}) + +test('idle to riding to idle restores limb rotations', () => { + const playerObject = makePlayerObject() + const animation = new WalkingGeneralSwing() + animation._captureDefaults(playerObject) + const defaults = captureRotations(playerObject) + + animation.isMoving = false + runAnimationFrame(animation, playerObject) + const idleRotations = captureRotations(playerObject) + + animation.isRiding = true + runAnimationFrame(animation, playerObject) + expectRidingLegPose(playerObject) + + animation.isRiding = false + animation.isMoving = false + runAnimationFrame(animation, playerObject) + const restored = captureRotations(playerObject) + + expect(restored.leftArm.x).toBeCloseTo(idleRotations.leftArm.x) + expect(restored.leftArm.y).toBeCloseTo(idleRotations.leftArm.y) + expect(restored.leftArm.z).toBeCloseTo(idleRotations.leftArm.z) + expect(restored.rightArm.x).toBeCloseTo(idleRotations.rightArm.x) + expect(restored.rightArm.y).toBeCloseTo(idleRotations.rightArm.y) + expect(restored.rightArm.z).toBeCloseTo(idleRotations.rightArm.z) + expect(restored.leftLeg.x).toBeCloseTo(defaults.leftLeg.x) + expect(restored.leftLeg.y).toBeCloseTo(defaults.leftLeg.y) + expect(restored.leftLeg.z).toBeCloseTo(defaults.leftLeg.z) + expect(restored.rightLeg.x).toBeCloseTo(defaults.rightLeg.x) + expect(restored.rightLeg.y).toBeCloseTo(defaults.rightLeg.y) + expect(restored.rightLeg.z).toBeCloseTo(defaults.rightLeg.z) +}) + +test('multiple riding frames do not accumulate angles', () => { + const playerObject = makePlayerObject() + const animation = new WalkingGeneralSwing() + animation._captureDefaults(playerObject) + animation.isRiding = true + + runAnimationFrame(animation, playerObject) + const firstFrame = captureRotations(playerObject) + + for (let i = 0; i < 10; i++) { + runAnimationFrame(animation, playerObject) + } + const lastFrame = captureRotations(playerObject) + + expect(lastFrame.leftLeg.x).toBeCloseTo(firstFrame.leftLeg.x) + expect(lastFrame.leftLeg.y).toBeCloseTo(firstFrame.leftLeg.y) + expect(lastFrame.leftLeg.z).toBeCloseTo(firstFrame.leftLeg.z) + expect(lastFrame.rightLeg.x).toBeCloseTo(firstFrame.rightLeg.x) + expect(lastFrame.rightLeg.y).toBeCloseTo(firstFrame.rightLeg.y) + expect(lastFrame.rightLeg.z).toBeCloseTo(firstFrame.rightLeg.z) + expect(lastFrame.leftArm.x).toBeCloseTo(firstFrame.leftArm.x) + expect(lastFrame.rightArm.x).toBeCloseTo(firstFrame.rightArm.x) +}) + +test('oneSwing during riding keeps isRiding enabled', () => { + const playerObject = makePlayerObject() + const animation = new WalkingGeneralSwing() + animation._captureDefaults(playerObject) + animation.isRiding = true + + animation.swingArm() + runAnimationFrame(animation, playerObject) + + expect(animation.isRiding).toBe(true) +}) + +test('oneSwing during riding keeps legs in riding pose', () => { + const playerObject = makePlayerObject() + const animation = new WalkingGeneralSwing() + animation._captureDefaults(playerObject) + animation.isRiding = true + + animation.swingArm() + runAnimationFrame(animation, playerObject) + + expectRidingLegPose(playerObject) +}) + +test('leg armor attached to legs follows riding Y/Z rotations', () => { + const playerObject = makePlayerObject() + const leftLegArmor = new THREE.Group() + const rightLegArmor = new THREE.Group() + playerObject.skin.leftLeg.add(leftLegArmor) + playerObject.skin.rightLeg.add(rightLegArmor) + + const animation = new WalkingGeneralSwing() + animation._captureDefaults(playerObject) + animation.isRiding = true + runAnimationFrame(animation, playerObject) + + expectRidingLegPose(playerObject) + + leftLegArmor.updateMatrixWorld(true) + rightLegArmor.updateMatrixWorld(true) + const leftWorldQuat = new THREE.Quaternion() + const rightWorldQuat = new THREE.Quaternion() + leftLegArmor.getWorldQuaternion(leftWorldQuat) + rightLegArmor.getWorldQuaternion(rightWorldQuat) + const leftWorldEuler = new THREE.Euler().setFromQuaternion(leftWorldQuat, 'XYZ') + const rightWorldEuler = new THREE.Euler().setFromQuaternion(rightWorldQuat, 'XYZ') + + expect(leftWorldEuler.y).not.toBeCloseTo(0) + expect(leftWorldEuler.z).not.toBeCloseTo(0) + expect(rightWorldEuler.y).not.toBeCloseTo(0) + expect(rightWorldEuler.z).not.toBeCloseTo(0) +}) diff --git a/src/three/entity/interpolationPolicy.test.ts b/src/three/entity/interpolationPolicy.test.ts index a1407fc..0002225 100644 --- a/src/three/entity/interpolationPolicy.test.ts +++ b/src/three/entity/interpolationPolicy.test.ts @@ -5,7 +5,9 @@ import { SPECTATING_CAMERA_TWEEN_DURATION_MS, getCameraMovementTweenDurationMs, getEntityTweenDurationMs, + getHorsePassengerWorldPosition, getLocalVehicleWorldPosition, + getMinecartPassengerWorldPosition, resolveLocalVehicleWorldPosition, samePosition, shouldRestartCameraPositionTween, @@ -232,3 +234,18 @@ test('horse vertical camera lock falls back to raw Y when result is non-finite', }) ).toEqual(getLocalVehicleWorldPosition(camera, rawVehicleY)) }) + +test('minecart passenger feet Y is vehicleY - 0.35', () => { + expect(getMinecartPassengerWorldPosition({ x: 0, y: 64, z: 0 }).y).toBeCloseTo(63.65, 5) +}) + +test.each([ + ['horse', 1.6, 0.85], + ['zombie_horse', 1.6, 0.85], + ['donkey', 1.5, 0.525], + ['mule', 1.6, 0.6], + ['skeleton_horse', 1.6, 0.6625] +])('horse variant %s feet offset matches vanilla 1.17.1', (name, height, expectedFeetOffset) => { + const seat = getHorsePassengerWorldPosition({ x: 0, y: 64, z: 0 }, name, height) + expect(seat.y).toBeCloseTo(64 + expectedFeetOffset, 5) +}) diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index 60e037c..4b54dcc 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -95,10 +95,8 @@ export function resolveLocalVehicleWorldPosition(args: { const BOAT_PASSENGER_RIDING_OFFSET_Y = -0.1 const PLAYER_RIDING_OFFSET_Y = -0.35 -/** Vanilla 1.17.1 EntityType minecart passengerAttachments(0.1875F) */ -export const MINECART_PASSENGER_ATTACHMENT_Y = 0.1875 -/** Vanilla 1.17.1 Player.DEFAULT_VEHICLE_ATTACHMENT.y */ -export const PLAYER_VEHICLE_ATTACHMENT_Y = 0.6 +/** Vanilla 1.17.1 AbstractMinecart#getPassengersRidingOffset() */ +const MINECART_PASSENGER_RIDING_OFFSET_Y = 0 const RIDEABLE_MINECART_ENTITY_NAMES = new Set([ 'minecart', @@ -141,7 +139,7 @@ export function getBoatPassengerWorldPosition(boatWorldPos: Vec3Like, boatYaw: n export function getMinecartPassengerWorldPosition(minecartWorldPos: Vec3Like): Vec3Like { return { x: minecartWorldPos.x, - y: minecartWorldPos.y + MINECART_PASSENGER_ATTACHMENT_Y - PLAYER_VEHICLE_ATTACHMENT_Y, + y: minecartWorldPos.y + MINECART_PASSENGER_RIDING_OFFSET_Y + PLAYER_RIDING_OFFSET_Y, z: minecartWorldPos.z } } diff --git a/src/three/entity/localVehicleRendering.integration.test.ts b/src/three/entity/localVehicleRendering.integration.test.ts index b0a3c03..9624a61 100644 --- a/src/three/entity/localVehicleRendering.integration.test.ts +++ b/src/three/entity/localVehicleRendering.integration.test.ts @@ -143,7 +143,7 @@ test('minecart passenger uses vanilla 1.17.1 riding Y offset', () => { const minecart = { x: 10, y: 64, z: 20 } expect(getMinecartPassengerWorldPosition(minecart)).toEqual({ x: 10, - y: 63.5875, + y: 63.65, z: 20 }) }) @@ -173,7 +173,7 @@ test('minecart passenger anchor follows vehicle world position without inheritin expect(sceneOrigin.getWorldPosition(passenger)).toEqual({ x: 103, - y: 64.0875, + y: 64.15, z: 205 }) expect(passenger.rotation.y).toBe(anchoredYaw) @@ -241,6 +241,6 @@ test('local minecart passenger anchors to camera-synced vehicle position', () => expect(syncedMinecart.z - camera.z).toBeCloseTo(0, 5) expect(passengerWorld.x).toBe(syncedMinecart.x) expect(passengerWorld.z).toBe(syncedMinecart.z) - expect(passengerWorld.y).toBeCloseTo(63.5875) + expect(passengerWorld.y).toBeCloseTo(63.65) } }) diff --git a/src/three/entity/minecartLocalPassengerRendering.test.ts b/src/three/entity/minecartLocalPassengerRendering.test.ts index 202d661..bd155e4 100644 --- a/src/three/entity/minecartLocalPassengerRendering.test.ts +++ b/src/three/entity/minecartLocalPassengerRendering.test.ts @@ -91,7 +91,7 @@ test('local third-person player follows interpolated minecart world position', ( expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(movedSeat) expect(movedSeat.x).toBe(103) - expect(movedSeat.y).toBeCloseTo(64.0875) + expect(movedSeat.y).toBeCloseTo(64.15) }) test('minecart seat places player waist-up inside the cart instead of on camera feet', () => { @@ -169,13 +169,20 @@ test('minecart dismount releases local player anchor without snapping back', () expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(detachedWorldPosition) }) -function shouldAnchorPassenger(passenger: PassengerLike | null | undefined, layout: 'boat' | 'minecart', isLocalPassenger: boolean): boolean { +function shouldAnchorPassenger(passenger: PassengerLike | null | undefined, layout: 'boat' | 'minecart' | 'horse', isLocalPassenger: boolean): boolean { if (!passenger?.playerObject) return false - if (isLocalPassenger && layout !== 'minecart') return false + if (isLocalPassenger && layout === 'horse') return false return true } -test('local player remains excluded from boat passenger anchoring', () => { +test('local player is anchored for boat and minecart but skipped for horse', () => { + const localPlayer = makePassenger(LOCAL_PLAYER_ID) + expect(shouldAnchorPassenger(localPlayer, 'boat', true)).toBe(true) + expect(shouldAnchorPassenger(localPlayer, 'minecart', true)).toBe(true) + expect(shouldAnchorPassenger(localPlayer, 'horse', true)).toBe(false) +}) + +test('local boat passenger uses the same anchor as remote passenger', () => { const sceneOrigin = new SceneOrigin(new THREE.Scene()) sceneOrigin.update(96, 60, 192) @@ -184,17 +191,17 @@ test('local player remains excluded from boat passenger anchoring', () => { boat.position.set(100, 64, 200) const localPlayer = makePassenger(LOCAL_PLAYER_ID) + const remotePlayer = makePassenger(REMOTE_PLAYER_ID) sceneOrigin.track(localPlayer) - localPlayer.position.set(100, 62.38, 200) + sceneOrigin.track(remotePlayer) const boatSeat = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1) - const shouldAnchorLocalPlayer = shouldAnchorPassenger(localPlayer, 'boat', true) - - expect(shouldAnchorLocalPlayer).toBe(false) - if (shouldAnchorLocalPlayer) { + if (shouldAnchorPassenger(localPlayer, 'boat', true)) { anchorVehiclePassengerPosition(localPlayer, boatSeat, '10') } + anchorVehiclePassengerPosition(remotePlayer, boatSeat, '10') - expect(localPlayer.userData._passengerVehicleId).toBeUndefined() - expect(sceneOrigin.getWorldPosition(localPlayer)?.y).toBe(62.38) + expect(localPlayer.userData._passengerVehicleId).toBe('10') + expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(sceneOrigin.getWorldPosition(remotePlayer)) + expect(sceneOrigin.getWorldPosition(localPlayer)?.y).toBeCloseTo(63.55) }) diff --git a/src/three/entity/networkHeadPitchRendering.test.ts b/src/three/entity/networkHeadPitchRendering.test.ts index b5a2928..b331dd3 100644 --- a/src/three/entity/networkHeadPitchRendering.test.ts +++ b/src/three/entity/networkHeadPitchRendering.test.ts @@ -78,6 +78,6 @@ test('minecart passenger anchor preserves head rotation', () => { simulateAnimationFrames(playerObject, userData, 4) expect(passenger.position.x).toBe(10) - expect(passenger.position.y).toBeCloseTo(63.5875) + expect(passenger.position.y).toBeCloseTo(63.65) expect(playerObject.skin.head.rotation.x).toBeCloseTo(-0.45) }) diff --git a/src/three/entity/vehiclePassengerProduction.test.ts b/src/three/entity/vehiclePassengerProduction.test.ts new file mode 100644 index 0000000..38f349f --- /dev/null +++ b/src/three/entity/vehiclePassengerProduction.test.ts @@ -0,0 +1,213 @@ +import * as THREE from 'three' +import { expect, test } from 'vitest' +import { SceneOrigin } from '../sceneOrigin' +import { getBoatPassengerWorldPosition, getMinecartPassengerWorldPosition } from './interpolationPolicy' +import { anchorVehiclePassengerPosition, shouldSkipLocalPassengerAnchoring } from './vehiclePassengerRendering' +import { updateVehiclePassengerPositions, type VehiclePassengerSceneEntity, type VehiclePassengerVehicle } from './vehiclePassengerUpdate' + +const LOCAL_PLAYER_ID = 7 +const REMOTE_PLAYER_ID = 8 + +type PassengerLike = THREE.Group & VehiclePassengerSceneEntity + +function makePassenger(id: number): PassengerLike { + const passenger = new THREE.Group() as PassengerLike + passenger.playerObject = {} + passenger.originalEntity = { id } + passenger.userData = {} + passenger.rotation.y = 0 + return passenger +} + +function makeVehicle(id: number, name: string, renderHints: Record): VehiclePassengerVehicle & THREE.Group { + const vehicle = new THREE.Group() as VehiclePassengerVehicle & THREE.Group + vehicle.originalEntity = { id, name, height: name === 'donkey' ? 1.5 : 1.6 } + vehicle.userData = { renderHints } + vehicle.realName = name + vehicle.rotation.y = 0 + return vehicle +} + +function createHarness() { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const localPlayer = makePassenger(LOCAL_PLAYER_ID) + sceneOrigin.track(localPlayer) + + const entities: Record = {} + const cameraWorldPos = { x: 100, y: 65.62, z: 200 } + + const runUpdate = () => + updateVehiclePassengerPositions({ + entities, + localPlayer, + getWorldPosition: target => sceneOrigin.getWorldPosition(target as THREE.Object3D) + }) + + return { sceneOrigin, localPlayer, entities, cameraWorldPos, runUpdate } +} + +test('shouldSkipLocalPassengerAnchoring keeps horse on camera path', () => { + expect(shouldSkipLocalPassengerAnchoring('boat')).toBe(false) + expect(shouldSkipLocalPassengerAnchoring('minecart')).toBe(false) + expect(shouldSkipLocalPassengerAnchoring('horse')).toBe(true) +}) + +test('updateVehiclePassengerPositions anchors local boat and minecart passengers', () => { + const { sceneOrigin, localPlayer, entities, runUpdate } = createHarness() + + const boat = makeVehicle(10, 'oak_boat', { passengerLayout: 'boat', passengerIds: [LOCAL_PLAYER_ID] }) + sceneOrigin.track(boat) + boat.position.set(100, 64, 200) + entities['10'] = boat + + runUpdate() + + const expectedBoatSeat = getBoatPassengerWorldPosition(sceneOrigin.getWorldPosition(boat)!, boat.rotation.y, 0, 1) + expect(localPlayer.userData._passengerVehicleId).toBe('10') + expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(expectedBoatSeat) + + const minecart = makeVehicle(42, 'minecart', { passengerLayout: 'minecart', passengerIds: [LOCAL_PLAYER_ID] }) + sceneOrigin.track(minecart) + minecart.position.set(103, 64.5, 205) + entities['42'] = minecart + boat.userData.renderHints = { passengerLayout: 'boat', passengerIds: [] } + + runUpdate() + + const expectedMinecartSeat = getMinecartPassengerWorldPosition(sceneOrigin.getWorldPosition(minecart)!) + expect(localPlayer.userData._passengerVehicleId).toBe('42') + expect(sceneOrigin.getWorldPosition(localPlayer)).toEqual(expectedMinecartSeat) +}) + +test('updateVehiclePassengerPositions keeps local horse on existing skip path', () => { + const { sceneOrigin, localPlayer, entities, cameraWorldPos, runUpdate } = createHarness() + const eyeHeight = 1.62 + localPlayer.position.set(cameraWorldPos.x, cameraWorldPos.y - eyeHeight, cameraWorldPos.z) + + const horse = makeVehicle(42, 'horse', { + passengerLayout: 'horse', + passengerIds: [LOCAL_PLAYER_ID], + localVehicle: true, + localVehicleVerticalCameraLock: 'horse' + }) + sceneOrigin.track(horse) + horse.position.set(100, 64, 200) + entities['42'] = horse + + runUpdate() + + expect(localPlayer.userData._passengerVehicleId).toBeUndefined() + expect(sceneOrigin.getWorldPosition(localPlayer)?.y).toBeCloseTo(cameraWorldPos.y - eyeHeight, 5) +}) + +test('local and remote boat passengers use the same seat algorithm per index', () => { + const { sceneOrigin, localPlayer, entities, runUpdate } = createHarness() + const remotePlayer = makePassenger(REMOTE_PLAYER_ID) + sceneOrigin.track(remotePlayer) + entities[String(REMOTE_PLAYER_ID)] = remotePlayer + + const boat = makeVehicle(10, 'oak_boat', { passengerLayout: 'boat', passengerIds: [LOCAL_PLAYER_ID, REMOTE_PLAYER_ID] }) + sceneOrigin.track(boat) + boat.position.set(100, 64, 200) + entities['10'] = boat + + runUpdate() + + const vehicleWorldPos = sceneOrigin.getWorldPosition(boat)! + const localWorld = sceneOrigin.getWorldPosition(localPlayer)! + const remoteWorld = sceneOrigin.getWorldPosition(remotePlayer)! + expect(localWorld).toEqual(getBoatPassengerWorldPosition(vehicleWorldPos, boat.rotation.y, 0, 2)) + expect(remoteWorld).toEqual(getBoatPassengerWorldPosition(vehicleWorldPos, boat.rotation.y, 1, 2)) + expect(localWorld.y).toBeCloseTo(63.55, 5) + expect(remoteWorld.y).toBeCloseTo(63.55, 5) +}) + +test('vehicle movement updates body anchor without horizontal jitter across frames', () => { + const { sceneOrigin, localPlayer, entities, runUpdate } = createHarness() + + const boat = makeVehicle(10, 'oak_boat', { passengerLayout: 'boat', passengerIds: [LOCAL_PLAYER_ID], localVehicle: true }) + sceneOrigin.track(boat) + entities['10'] = boat + + const xs = [100, 100.4, 100.9, 101.1, 100.8] + const deltas: number[] = [] + let previousX: number | undefined + + for (const x of xs) { + boat.position.set(x, 64, 200) + runUpdate() + const world = sceneOrigin.getWorldPosition(localPlayer)! + if (previousX !== undefined) { + deltas.push(world.x - previousX) + } + previousX = world.x + } + + for (const delta of deltas) { + expect(delta).toBeCloseTo(Math.round(delta * 10) / 10, 5) + } + expect(deltas.map(delta => Math.round(delta * 10) / 10)).toEqual([0.4, 0.5, 0.2, -0.3]) +}) + +test('dismount and vehicle removal release local passenger anchor', () => { + const { sceneOrigin, localPlayer, entities, cameraWorldPos, runUpdate } = createHarness() + const eyeHeight = 1.62 + localPlayer.position.set(cameraWorldPos.x, cameraWorldPos.y - eyeHeight, cameraWorldPos.z) + + const boat = makeVehicle(10, 'oak_boat', { passengerLayout: 'boat', passengerIds: [LOCAL_PLAYER_ID] }) + sceneOrigin.track(boat) + boat.position.set(100, 64, 200) + entities['10'] = boat + + runUpdate() + expect(localPlayer.userData._passengerVehicleId).toBe('10') + + boat.userData.renderHints = { passengerLayout: 'boat', passengerIds: [] } + runUpdate() + expect(localPlayer.userData._passengerVehicleId).toBeUndefined() + + localPlayer.position.set(cameraWorldPos.x, cameraWorldPos.y - eyeHeight, cameraWorldPos.z) + const releasedWorld = sceneOrigin.getWorldPosition(localPlayer)! + expect(releasedWorld.y).toBeCloseTo(cameraWorldPos.y - eyeHeight, 5) + + delete entities['10'] + runUpdate() + expect(localPlayer.userData._passengerVehicleId).toBeUndefined() +}) + +test('scene origin rebase preserves passenger world position', () => { + const { sceneOrigin, localPlayer, entities, runUpdate } = createHarness() + + const boat = makeVehicle(10, 'oak_boat', { passengerLayout: 'boat', passengerIds: [LOCAL_PLAYER_ID] }) + sceneOrigin.track(boat) + boat.position.set(100, 64, 200) + entities['10'] = boat + + runUpdate() + const beforeRebase = { ...sceneOrigin.getWorldPosition(localPlayer)! } + + sceneOrigin.update(112, 70, 208) + runUpdate() + const afterRebase = sceneOrigin.getWorldPosition(localPlayer)! + + expect(afterRebase).toEqual(beforeRebase) +}) + +test('non-finite vehicle position does not create passenger anchor', () => { + const { localPlayer, entities, runUpdate } = createHarness() + + const boat = makeVehicle(10, 'oak_boat', { passengerLayout: 'boat', passengerIds: [LOCAL_PLAYER_ID] }) + boat.position.set(Number.NaN, 64, 200) + entities['10'] = boat + + runUpdate() + expect(localPlayer.userData._passengerVehicleId).toBeUndefined() +}) + +test('anchorVehiclePassengerPosition ignores non-finite coordinates', () => { + const passenger = makePassenger(LOCAL_PLAYER_ID) + anchorVehiclePassengerPosition(passenger, { x: 1, y: Number.NaN, z: 2 }, '10') + expect(passenger.userData._passengerVehicleId).toBeUndefined() +}) diff --git a/src/three/entity/vehiclePassengerRendering.ts b/src/three/entity/vehiclePassengerRendering.ts index b3913a4..57f440a 100644 --- a/src/three/entity/vehiclePassengerRendering.ts +++ b/src/three/entity/vehiclePassengerRendering.ts @@ -1,5 +1,15 @@ import type { Vec3Like } from './interpolationPolicy' +export type PassengerLayout = 'boat' | 'minecart' | 'horse' + +export function shouldSkipLocalPassengerAnchoring(layout: PassengerLayout): boolean { + return layout === 'horse' +} + +export function isFiniteVec3(value: Vec3Like | undefined | null): value is Vec3Like { + return value != null && Number.isFinite(value.x) && Number.isFinite(value.y) && Number.isFinite(value.z) +} + export type VehiclePassengerRenderState = { position: { set: (x: number, y: number, z: number) => unknown @@ -14,6 +24,7 @@ export type VehiclePassengerRenderState = { } export function anchorVehiclePassengerPosition(passenger: VehiclePassengerRenderState, passengerWorldPos: Vec3Like, vehicleId: string): void { + if (!isFiniteVec3(passengerWorldPos)) return passenger.userData._posTween?.stop() passenger.userData._posTween = undefined passenger.userData._tweenTarget ??= { ...passengerWorldPos } diff --git a/src/three/entity/vehiclePassengerUpdate.ts b/src/three/entity/vehiclePassengerUpdate.ts new file mode 100644 index 0000000..e9f5690 --- /dev/null +++ b/src/three/entity/vehiclePassengerUpdate.ts @@ -0,0 +1,81 @@ +import { isBoatEntityName } from './boatModelRotation' +import { + getBoatPassengerWorldPosition, + getHorsePassengerWorldPosition, + getMinecartPassengerWorldPosition, + isRideableHorseEntityName, + isRideableMinecartEntityName, + type EntityRenderHints, + type Vec3Like +} from './interpolationPolicy' +import { + anchorVehiclePassengerPosition, + isFiniteVec3, + releaseVehiclePassengerPosition, + shouldSkipLocalPassengerAnchoring, + type VehiclePassengerRenderState +} from './vehiclePassengerRendering' + +export type VehiclePassengerSceneEntity = VehiclePassengerRenderState & { + playerObject?: unknown + originalEntity: { id: number } +} + +export type VehiclePassengerVehicle = { + originalEntity: { id: number | string; name: string; height?: number } + userData: { renderHints?: EntityRenderHints } + rotation: { y: number } + realName?: string +} + +export function updateVehiclePassengerPositions(args: { + entities: Record + localPlayer: VehiclePassengerSceneEntity | null + getWorldPosition: (target: unknown) => Vec3Like | undefined +}): Set { + const attachedPassengers = new Set() + + for (const entity of Object.values(args.entities)) { + const vehicle = entity as VehiclePassengerVehicle + const renderHints = vehicle.userData.renderHints + const passengerIds = renderHints?.passengerIds ?? renderHints?.boatPassengerIds + if (!Array.isArray(passengerIds) || passengerIds.length === 0) continue + + const vehicleName = vehicle.realName ?? vehicle.originalEntity.name + const layout = renderHints?.passengerLayout ?? (isBoatEntityName(vehicleName) ? 'boat' : undefined) + if (layout !== 'boat' && layout !== 'minecart' && layout !== 'horse') continue + if (layout === 'boat' && !isBoatEntityName(vehicleName)) continue + if (layout === 'minecart' && !isRideableMinecartEntityName(vehicleName)) continue + if (layout === 'horse' && !isRideableHorseEntityName(vehicleName)) continue + + const vehicleWorldPos = args.getWorldPosition(vehicle) + if (!isFiniteVec3(vehicleWorldPos)) continue + const vehicleId = String(vehicle.originalEntity.id) + + for (const [passengerIndex, passengerId] of passengerIds.entries()) { + const isLocalPassenger = passengerId === args.localPlayer?.originalEntity.id + const passenger = isLocalPassenger ? args.localPlayer : (args.entities[passengerId] as VehiclePassengerSceneEntity | undefined) + if (!passenger?.playerObject) continue + if (passenger === args.localPlayer && shouldSkipLocalPassengerAnchoring(layout)) continue + + const passengerWorldPos = + layout === 'minecart' + ? getMinecartPassengerWorldPosition(vehicleWorldPos) + : layout === 'horse' + ? getHorsePassengerWorldPosition(vehicleWorldPos, vehicleName, vehicle.originalEntity.height ?? 1.6) + : getBoatPassengerWorldPosition(vehicleWorldPos, vehicle.rotation.y, passengerIndex, passengerIds.length) + + if (!isFiniteVec3(passengerWorldPos)) continue + anchorVehiclePassengerPosition(passenger, passengerWorldPos, vehicleId) + attachedPassengers.add(passenger) + } + } + + for (const passenger of [...Object.values(args.entities), ...(args.localPlayer ? [args.localPlayer] : [])] as VehiclePassengerSceneEntity[]) { + const vehicleId = passenger.userData._passengerVehicleId ?? (passenger.userData._boatPassengerVehicleId as string | undefined) + if (vehicleId === undefined || attachedPassengers.has(passenger)) continue + releaseVehiclePassengerPosition(passenger, vehicleId, args.getWorldPosition(passenger)) + } + + return attachedPassengers +} From e2acd3077bfb0cd77acc9c22dd6894814fd77767 Mon Sep 17 00:00:00 2001 From: zverev Date: Fri, 17 Jul 2026 23:29:45 +0300 Subject: [PATCH 11/20] fix(entities): align mounted models with vanilla 1.17.1 - Lower the boat OBJ hull by 0.125 while preserving the water patch height. - Apply vanilla entity scales for horse, donkey, mule, skeleton horse, and zombie horse models. - Use vanilla ZYX riding-leg rotations with skinview3d axis conversion. - Resolve shared transforms for normalized entity types and add regression coverage for boat bounds, horse scales, and riding matrices. --- src/three/entity/EntityMesh.ts | 7 ++- src/three/entity/animations.js | 12 ++-- src/three/entity/animations.test.ts | 76 ++++++++++++++++++++--- src/three/entity/boatRenderSetup.test.ts | 28 +++++++++ src/three/entity/boatRenderSetup.ts | 8 +-- src/three/entity/horseEntityScale.test.ts | 10 +++ src/three/entity/horseEntityScale.ts | 8 +++ 7 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 src/three/entity/horseEntityScale.test.ts create mode 100644 src/three/entity/horseEntityScale.ts diff --git a/src/three/entity/EntityMesh.ts b/src/three/entity/EntityMesh.ts index 78bbb38..618e94b 100644 --- a/src/three/entity/EntityMesh.ts +++ b/src/three/entity/EntityMesh.ts @@ -13,6 +13,8 @@ import entities from './entities.json' import { externalModels } from './objModels' import externalTexturesJson from './externalTextures.json' import { createAnimatedObject } from './gltfAnimationUtils' +import { BOAT_OBJ_OFFSET_Y } from './boatRenderSetup' +import { VANILLA_117_HORSE_ENTITY_SCALES } from './horseEntityScale' interface ElemFace { dir: [number, number, number] @@ -440,6 +442,7 @@ const getEntity = (name: string) => { } const scaleEntity: Record = { + ...VANILLA_117_HORSE_ENTITY_SCALES, zombie: 1.85, husk: 1.85, arrow: 0.0025 @@ -448,7 +451,7 @@ const scaleEntity: Record = { const offsetEntity: Record = { zombie: new Vec3(0, 1, 0), husk: new Vec3(0, 1, 0), - boat: new Vec3(0, -1, 0), + boat: new Vec3(0, BOAT_OBJ_OFFSET_Y, 0), arrow: new Vec3(0, -0.9, 0) } @@ -626,7 +629,7 @@ export class EntityMesh { const obj = objLoader.parse(externalModels[type]) const scale = scaleEntity[originalType] || scaleEntity[type] if (scale) obj.scale.set(scale, scale, scale) - const offset = offsetEntity[originalType] + const offset = offsetEntity[originalType] ?? offsetEntity[type] if (offset) obj.position.set(offset.x, offset.y, offset.z) obj.traverse(child => { if (child instanceof THREE.Mesh) { diff --git a/src/three/entity/animations.js b/src/three/entity/animations.js index 18af08f..20317b3 100644 --- a/src/three/entity/animations.js +++ b/src/three/entity/animations.js @@ -144,13 +144,11 @@ export class WalkingGeneralSwing extends PlayerAnimation { player.skin.rightArm.rotation.x -= Math.PI / 5 player.skin.leftArm.rotation.x -= Math.PI / 5 - player.skin.rightLeg.rotation.x = -1.4137167 - player.skin.rightLeg.rotation.y = Math.PI / 10 - player.skin.rightLeg.rotation.z = Math.PI / 40 - - player.skin.leftLeg.rotation.x = -1.4137167 - player.skin.leftLeg.rotation.y = -Math.PI / 10 - player.skin.leftLeg.rotation.z = -Math.PI / 40 + // Vanilla HumanoidModel uses ModelPart Z→Y→X order with yRot/zRot signs as in Java. + // skinview3d Y/Z axes are opposite the Java model, so ZYX plus inverted Y/Z signs + // matches vanilla Rz × Ry × Rx (see BOAT_RIDING_LEG_SPREAD.md). + player.skin.rightLeg.rotation.set(-1.4137167, -Math.PI / 10, -Math.PI / 40, 'ZYX') + player.skin.leftLeg.rotation.set(-1.4137167, Math.PI / 10, Math.PI / 40, 'ZYX') } else { const boundary = this.isRunning ? Math.cos(t) : Math.sin(t) if (Math.abs(boundary) < 0.02) { diff --git a/src/three/entity/animations.test.ts b/src/three/entity/animations.test.ts index e69d772..7632da9 100644 --- a/src/three/entity/animations.test.ts +++ b/src/three/entity/animations.test.ts @@ -5,10 +5,15 @@ import type { PlayerObjectType } from '../../lib/createPlayerObject' import { WalkingGeneralSwing } from './animations' const RIDING_LEG_X = -1.4137167 -const RIDING_LEG_Y = Math.PI / 10 -const RIDING_LEG_Z = Math.PI / 40 +const VANILLA_RIDING_LEG_Y = Math.PI / 10 +const VANILLA_RIDING_LEG_Z = Math.PI / 40 +const SKINVIEW_RIDING_LEG_Y = -VANILLA_RIDING_LEG_Y +const SKINVIEW_RIDING_LEG_Z = -VANILLA_RIDING_LEG_Z +const RIDING_LEG_ORDER = 'ZYX' const RIDING_ARM_DELTA = Math.PI / 5 +const YZ_AXIS_FLIP = new THREE.Matrix4().makeScale(1, -1, -1) + function makePlayerObject(): PlayerObjectType { const playerObject = new PlayerObject() as PlayerObjectType playerObject.skin.leftLeg.rotation.set(0.1, 0.05, -0.02) @@ -24,7 +29,7 @@ function captureRotations(playerObject: PlayerObjectType) { leftArm: leftArm.rotation.clone(), rightArm: rightArm.rotation.clone(), leftLeg: leftLeg.rotation.clone(), - rightLeg: rightLeg.rotation.clone(), + rightLeg: rightLeg.rotation.clone() } } @@ -32,17 +37,37 @@ function runAnimationFrame(animation: WalkingGeneralSwing, playerObject: PlayerO animation.update(playerObject, dt) } +function makeJavaRidingLegMatrix(ySign: 1 | -1): THREE.Matrix4 { + return new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(RIDING_LEG_X, ySign * VANILLA_RIDING_LEG_Y, ySign * VANILLA_RIDING_LEG_Z, RIDING_LEG_ORDER)) +} + +function makeSkinviewRidingLegMatrix(y: number, z: number): THREE.Matrix4 { + return new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(RIDING_LEG_X, y, z, RIDING_LEG_ORDER)) +} + +function conjugateYzAxisFlip(rotationMatrix: THREE.Matrix4): THREE.Matrix4 { + return YZ_AXIS_FLIP.clone().multiply(rotationMatrix).multiply(YZ_AXIS_FLIP.clone()) +} + +function expectMatrixNear(a: THREE.Matrix4, b: THREE.Matrix4) { + for (let i = 0; i < 16; i++) { + expect(a.elements[i]).toBeCloseTo(b.elements[i]) + } +} + function expectRidingLegPose(playerObject: PlayerObjectType) { + expect(playerObject.skin.rightLeg.rotation.order).toBe(RIDING_LEG_ORDER) expect(playerObject.skin.rightLeg.rotation.x).toBeCloseTo(RIDING_LEG_X) - expect(playerObject.skin.rightLeg.rotation.y).toBeCloseTo(RIDING_LEG_Y) - expect(playerObject.skin.rightLeg.rotation.z).toBeCloseTo(RIDING_LEG_Z) + expect(playerObject.skin.rightLeg.rotation.y).toBeCloseTo(SKINVIEW_RIDING_LEG_Y) + expect(playerObject.skin.rightLeg.rotation.z).toBeCloseTo(SKINVIEW_RIDING_LEG_Z) + expect(playerObject.skin.leftLeg.rotation.order).toBe(RIDING_LEG_ORDER) expect(playerObject.skin.leftLeg.rotation.x).toBeCloseTo(RIDING_LEG_X) - expect(playerObject.skin.leftLeg.rotation.y).toBeCloseTo(-RIDING_LEG_Y) - expect(playerObject.skin.leftLeg.rotation.z).toBeCloseTo(-RIDING_LEG_Z) + expect(playerObject.skin.leftLeg.rotation.y).toBeCloseTo(-SKINVIEW_RIDING_LEG_Y) + expect(playerObject.skin.leftLeg.rotation.z).toBeCloseTo(-SKINVIEW_RIDING_LEG_Z) } -test('riding sets exact leg X/Y/Z rotations', () => { +test('riding sets ZYX leg rotations with inverted Y/Z signs for skinview3d axes', () => { const playerObject = makePlayerObject() const animation = new WalkingGeneralSwing() animation._captureDefaults(playerObject) @@ -53,6 +78,33 @@ test('riding sets exact leg X/Y/Z rotations', () => { expectRidingLegPose(playerObject) }) +test('riding leg rotation matrix matches vanilla after Y/Z axis conversion', () => { + const javaRight = makeJavaRidingLegMatrix(1) + const skinviewRight = makeSkinviewRidingLegMatrix(SKINVIEW_RIDING_LEG_Y, SKINVIEW_RIDING_LEG_Z) + expectMatrixNear(conjugateYzAxisFlip(skinviewRight), javaRight) + + const javaLeft = makeJavaRidingLegMatrix(-1) + const skinviewLeft = makeSkinviewRidingLegMatrix(-SKINVIEW_RIDING_LEG_Y, -SKINVIEW_RIDING_LEG_Z) + expectMatrixNear(conjugateYzAxisFlip(skinviewLeft), javaLeft) +}) + +test('animated riding legs match expected rotation matrices', () => { + const playerObject = makePlayerObject() + const animation = new WalkingGeneralSwing() + animation._captureDefaults(playerObject) + animation.isRiding = true + runAnimationFrame(animation, playerObject) + + playerObject.skin.rightLeg.updateMatrix() + playerObject.skin.leftLeg.updateMatrix() + + const rightRot = new THREE.Matrix4().extractRotation(playerObject.skin.rightLeg.matrix) + const leftRot = new THREE.Matrix4().extractRotation(playerObject.skin.leftLeg.matrix) + + expectMatrixNear(rightRot, makeSkinviewRidingLegMatrix(SKINVIEW_RIDING_LEG_Y, SKINVIEW_RIDING_LEG_Z)) + expectMatrixNear(leftRot, makeSkinviewRidingLegMatrix(-SKINVIEW_RIDING_LEG_Y, -SKINVIEW_RIDING_LEG_Z)) +}) + test('riding applies arm X delta relative to defaults', () => { const playerObject = makePlayerObject() const defaults = captureRotations(playerObject) @@ -79,7 +131,7 @@ test('riding does not apply old arm Z spread', () => { expect(playerObject.skin.rightArm.rotation.z).toBeCloseTo(defaults.rightArm.z) }) -test('idle to riding to idle restores limb rotations', () => { +test('idle to riding to idle restores limb rotations including order', () => { const playerObject = makePlayerObject() const animation = new WalkingGeneralSwing() animation._captureDefaults(playerObject) @@ -101,15 +153,19 @@ test('idle to riding to idle restores limb rotations', () => { expect(restored.leftArm.x).toBeCloseTo(idleRotations.leftArm.x) expect(restored.leftArm.y).toBeCloseTo(idleRotations.leftArm.y) expect(restored.leftArm.z).toBeCloseTo(idleRotations.leftArm.z) + expect(restored.leftArm.order).toBe(idleRotations.leftArm.order) expect(restored.rightArm.x).toBeCloseTo(idleRotations.rightArm.x) expect(restored.rightArm.y).toBeCloseTo(idleRotations.rightArm.y) expect(restored.rightArm.z).toBeCloseTo(idleRotations.rightArm.z) + expect(restored.rightArm.order).toBe(idleRotations.rightArm.order) expect(restored.leftLeg.x).toBeCloseTo(defaults.leftLeg.x) expect(restored.leftLeg.y).toBeCloseTo(defaults.leftLeg.y) expect(restored.leftLeg.z).toBeCloseTo(defaults.leftLeg.z) + expect(restored.leftLeg.order).toBe(defaults.leftLeg.order) expect(restored.rightLeg.x).toBeCloseTo(defaults.rightLeg.x) expect(restored.rightLeg.y).toBeCloseTo(defaults.rightLeg.y) expect(restored.rightLeg.z).toBeCloseTo(defaults.rightLeg.z) + expect(restored.rightLeg.order).toBe(defaults.rightLeg.order) }) test('multiple riding frames do not accumulate angles', () => { @@ -129,9 +185,11 @@ test('multiple riding frames do not accumulate angles', () => { expect(lastFrame.leftLeg.x).toBeCloseTo(firstFrame.leftLeg.x) expect(lastFrame.leftLeg.y).toBeCloseTo(firstFrame.leftLeg.y) expect(lastFrame.leftLeg.z).toBeCloseTo(firstFrame.leftLeg.z) + expect(lastFrame.leftLeg.order).toBe(firstFrame.leftLeg.order) expect(lastFrame.rightLeg.x).toBeCloseTo(firstFrame.rightLeg.x) expect(lastFrame.rightLeg.y).toBeCloseTo(firstFrame.rightLeg.y) expect(lastFrame.rightLeg.z).toBeCloseTo(firstFrame.rightLeg.z) + expect(lastFrame.rightLeg.order).toBe(firstFrame.rightLeg.order) expect(lastFrame.leftArm.x).toBeCloseTo(firstFrame.leftArm.x) expect(lastFrame.rightArm.x).toBeCloseTo(firstFrame.rightArm.x) }) diff --git a/src/three/entity/boatRenderSetup.test.ts b/src/three/entity/boatRenderSetup.test.ts index e5cf61a..fff1c79 100644 --- a/src/three/entity/boatRenderSetup.test.ts +++ b/src/three/entity/boatRenderSetup.test.ts @@ -76,3 +76,31 @@ test('boat hull material is alpha-tested opaque with depth write', () => { expect(material.depthWrite).toBe(true) expect(material.depthTest).toBe(true) }) + +test('boat OBJ hull entity-space Y bounds after BOAT_OBJ_OFFSET_Y', () => { + const fs = require('node:fs') + const path = require('node:path') + const objPath = path.join(__dirname, 'models/boat.obj') + const txt = fs.readFileSync(objPath, 'utf8') as string + const groups: Record = {} + let current = '' + for (const line of txt.split('\n')) { + if (line.startsWith('o ')) current = line.slice(2).trim() + if (!line.startsWith('v ')) continue + const y = Number(line.split(/\s+/)[2]) + groups[current] ??= { min: Infinity, max: -Infinity } + groups[current].min = Math.min(groups[current].min, y + BOAT_OBJ_OFFSET_Y) + groups[current].max = Math.max(groups[current].max, y + BOAT_OBJ_OFFSET_Y) + } + + expect(groups.bottom.min).toBeCloseTo(0, 5) + expect(groups.bottom.max).toBeCloseTo(0.1875, 5) + for (const name of ['front', 'back', 'left', 'right'] as const) { + expect(groups[name].min).toBeCloseTo(0.1875, 5) + expect(groups[name].max).toBeCloseTo(0.5625, 5) + } + + const patch = getBoatWaterPatchEntitySpaceBounds(BOAT_OBJ_OFFSET_Y) + expect(patch.minY).toBeCloseTo(0.375, 5) + expect(patch.maxY).toBeCloseTo(0.5625, 5) +}) diff --git a/src/three/entity/boatRenderSetup.ts b/src/three/entity/boatRenderSetup.ts index 728b42f..8e71f16 100644 --- a/src/three/entity/boatRenderSetup.ts +++ b/src/three/entity/boatRenderSetup.ts @@ -6,14 +6,14 @@ export const BOAT_WATER_PATCH_WIDTH = 28 / 16 export const BOAT_WATER_PATCH_HEIGHT = 3 / 16 export const BOAT_WATER_PATCH_DEPTH = 16 / 16 -/** OBJ boat root offset applied in EntityMesh. */ -export const BOAT_OBJ_OFFSET_Y = -1 +/** OBJ boat root offset applied in EntityMesh (single source — import from here, do not duplicate). */ +export const BOAT_OBJ_OFFSET_Y = -1.125 /** * Vanilla 1.17.1 water patch center in OBJ-local space. - * World-relative bounds with BOAT_OBJ_OFFSET_Y: X [-0.875,0.875], Y [0.375,0.5625], Z [-0.5,0.5]. + * Tuned with BOAT_OBJ_OFFSET_Y so entity-space bounds stay Y [0.375, 0.5625]. */ -export const BOAT_WATER_PATCH_CENTER_Y = 1.46875 +export const BOAT_WATER_PATCH_CENTER_Y = 1.59375 export const BOAT_WATER_PATCH_CENTER_Z = 0 export const BOAT_WATER_PATCH_WORLD_BOUNDS = { diff --git a/src/three/entity/horseEntityScale.test.ts b/src/three/entity/horseEntityScale.test.ts new file mode 100644 index 0000000..4e801c0 --- /dev/null +++ b/src/three/entity/horseEntityScale.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from 'vitest' +import { VANILLA_117_HORSE_ENTITY_SCALES } from './horseEntityScale' + +test('vanilla 1.17.1 horse family entity scales', () => { + expect(VANILLA_117_HORSE_ENTITY_SCALES.horse).toBe(1.1) + expect(VANILLA_117_HORSE_ENTITY_SCALES.donkey).toBe(0.87) + expect(VANILLA_117_HORSE_ENTITY_SCALES.mule).toBe(0.92) + expect(VANILLA_117_HORSE_ENTITY_SCALES.skeleton_horse).toBe(1.0) + expect(VANILLA_117_HORSE_ENTITY_SCALES.zombie_horse).toBe(1.0) +}) diff --git a/src/three/entity/horseEntityScale.ts b/src/three/entity/horseEntityScale.ts new file mode 100644 index 0000000..72e92d9 --- /dev/null +++ b/src/three/entity/horseEntityScale.ts @@ -0,0 +1,8 @@ +/** Vanilla 1.17.1 AbstractHorseRenderer entity scales (model OBJ root only). */ +export const VANILLA_117_HORSE_ENTITY_SCALES: Record = { + horse: 1.1, + donkey: 0.87, + mule: 0.92, + skeleton_horse: 1.0, + zombie_horse: 1.0 +} From 857f79fe177706b1cc890d35e8e1c737b9526f38 Mon Sep 17 00:00:00 2001 From: zverev Date: Fri, 17 Jul 2026 23:55:00 +0300 Subject: [PATCH 12/20] fix(entities): align boat passenger body and head rotation --- src/three/entities.ts | 55 +++++-- .../entity/boatPassengerRotation.test.ts | 154 ++++++++++++++++++ src/three/entity/boatPassengerRotation.ts | 68 ++++++++ 3 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 src/three/entity/boatPassengerRotation.test.ts create mode 100644 src/three/entity/boatPassengerRotation.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index 67ed48e..8f8a3f3 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -21,6 +21,7 @@ import { createItemMesh } from './itemMesh' import * as Entity from './entity/EntityMesh' import { setupBoatMesh, disposeBoatWaterPatch } from './entity/boatRenderSetup' import { getBoatMeshYawOffset, isBoatEntityName } from './entity/boatModelRotation' +import { resolveBoatPassengerThirdPersonRotation, shouldApplyBoatPassengerThirdPersonRotation } from './entity/boatPassengerRotation' import { releaseVehiclePassengerPosition } from './entity/vehiclePassengerRendering' import { updateVehiclePassengerPositions as applyVehiclePassengerPositions } from './entity/vehiclePassengerUpdate' import { applyNetworkHeadPitch, storeNetworkHeadPitch } from './entity/networkHeadPitchRendering' @@ -485,23 +486,53 @@ export class Entities { entity.position.set(worldPos.x, worldPos.y, worldPos.z) } - if (entity.visible) { + if (entity.visible && !isPlayerEntity) { this.syncArmorPositions(entity) } - - if (isPlayerEntity && entity.visible) { - const rotation = this.worldRenderer.cameraShake.getBaseRotation() - entity.rotation.set(0, rotation.yaw, 0) - - entity.traverse(c => { - if (c.name === 'head') { - c.rotation.set(-rotation.pitch, 0, 0) - } - }) - } } this.updateVehiclePassengerPositions() + this.applyLocalThirdPersonPlayerRotation() + } + + private applyLocalThirdPersonPlayerRotation() { + const entity = this.playerEntity + if (!entity?.visible || !entity.playerObject) return + + const rotation = this.worldRenderer.cameraShake.getBaseRotation() + const isAnchoredPassenger = entity.userData._passengerVehicleId != null || entity.userData._boatPassengerVehicleId != null + const anchoredVehicleId = entity.userData._passengerVehicleId ?? entity.userData._boatPassengerVehicleId + const vehicle = anchoredVehicleId != null ? this.entities[anchoredVehicleId] : undefined + const vehicleName = vehicle?.['realName'] ?? vehicle?.originalEntity.name + const vehicleYaw = vehicle?.rotation.y + + if ( + shouldApplyBoatPassengerThirdPersonRotation({ + isThirdPerson: true, + isAnchoredPassenger, + vehicleName, + vehicleYaw + }) + ) { + const resolved = resolveBoatPassengerThirdPersonRotation({ + cameraYaw: rotation.yaw, + cameraPitch: rotation.pitch, + vehicleYaw: vehicleYaw! + }) + entity.rotation.set(0, resolved.bodyYaw, 0) + entity.traverse(child => { + if (child.name !== 'head') return + child.rotation.set(resolved.headPitch, resolved.headYaw, 0) + }) + } else { + entity.rotation.set(0, rotation.yaw, 0) + entity.traverse(child => { + if (child.name !== 'head') return + child.rotation.set(-rotation.pitch, 0, 0) + }) + } + + this.syncArmorPositions(entity) } private resolvePassengerEntity(passengerId: number): SceneEntity | undefined { diff --git a/src/three/entity/boatPassengerRotation.test.ts b/src/three/entity/boatPassengerRotation.test.ts new file mode 100644 index 0000000..2872510 --- /dev/null +++ b/src/three/entity/boatPassengerRotation.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from 'vitest' +import { + BOAT_PASSENGER_MAX_HEAD_YAW_RAD, + clampBoatPassengerRelativeHeadYaw, + getClampedBoatPassengerYaw, + normalizeYaw, + normalizeYawDelta, + resolveBoatPassengerThirdPersonRotation, + shouldApplyBoatPassengerThirdPersonRotation +} from './boatPassengerRotation' + +const BOAT_YAW = -0.5645049299419149 + +test('clamp relative head yaw at ±105° boundaries', () => { + expect(clampBoatPassengerRelativeHeadYaw(BOAT_PASSENGER_MAX_HEAD_YAW_RAD)).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD) + expect(clampBoatPassengerRelativeHeadYaw(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD)).toBeCloseTo(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD) +}) + +test('clamp relative head yaw beyond both boundaries', () => { + const over = BOAT_PASSENGER_MAX_HEAD_YAW_RAD + 0.5 + const under = -BOAT_PASSENGER_MAX_HEAD_YAW_RAD - 0.5 + expect(clampBoatPassengerRelativeHeadYaw(over)).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD) + expect(clampBoatPassengerRelativeHeadYaw(under)).toBeCloseTo(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD) +}) + +test('normalizeYawDelta handles π/-π seam', () => { + expect(normalizeYawDelta(Math.PI, -Math.PI)).toBeCloseTo(0) + expect(normalizeYawDelta(-Math.PI, Math.PI)).toBeCloseTo(0) + expect(normalizeYawDelta(0.1, 2 * Math.PI + 0.1)).toBeCloseTo(0) +}) + +test('normalizeYaw wraps 2π to 0', () => { + expect(normalizeYaw(2 * Math.PI)).toBeCloseTo(0) + expect(normalizeYaw(-2 * Math.PI)).toBeCloseTo(0) +}) + +test('getClampedBoatPassengerYaw clamps absolute look beyond 105° from boat', () => { + const requested = BOAT_YAW + 2.5 + const clamped = getClampedBoatPassengerYaw(requested, BOAT_YAW) + expect(normalizeYawDelta(BOAT_YAW, clamped)).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD) +}) + +test('renderer and camera share the same effective yaw', () => { + const cameraYaw = BOAT_YAW + 1.9 + const clampedLook = getClampedBoatPassengerYaw(cameraYaw, BOAT_YAW) + const resolved = resolveBoatPassengerThirdPersonRotation({ + cameraYaw, + cameraPitch: 0.2, + vehicleYaw: BOAT_YAW + }) + expect(resolved.effectiveCameraYaw).toBeCloseTo(clampedLook) + expect(normalizeYaw(BOAT_YAW + resolved.headYaw)).toBeCloseTo(clampedLook) +}) + +test('positive camera delta yields positive local head yaw', () => { + const cameraYaw = BOAT_YAW + 0.5 + const resolved = resolveBoatPassengerThirdPersonRotation({ + cameraYaw, + cameraPitch: 0, + vehicleYaw: BOAT_YAW + }) + expect(resolved.headYaw).toBeGreaterThan(0) + expect(resolved.headYaw).toBeCloseTo(normalizeYawDelta(BOAT_YAW, cameraYaw)) +}) + +test('head yaw sign follows normalize(cameraYaw - vehicleYaw) without inversion', () => { + const positive = resolveBoatPassengerThirdPersonRotation({ + cameraYaw: BOAT_YAW + 0.3, + cameraPitch: 0, + vehicleYaw: BOAT_YAW + }) + const negative = resolveBoatPassengerThirdPersonRotation({ + cameraYaw: BOAT_YAW - 0.3, + cameraPitch: 0, + vehicleYaw: BOAT_YAW + }) + expect(positive.headYaw).toBeCloseTo(0.3) + expect(negative.headYaw).toBeCloseTo(-0.3) +}) + +test('shouldApplyBoatPassengerThirdPersonRotation excludes minecart and horse', () => { + const base = { + isThirdPerson: true, + isAnchoredPassenger: true, + vehicleYaw: BOAT_YAW + } + expect(shouldApplyBoatPassengerThirdPersonRotation({ ...base, vehicleName: 'boat' })).toBe(true) + expect(shouldApplyBoatPassengerThirdPersonRotation({ ...base, vehicleName: 'minecart' })).toBe(false) + expect(shouldApplyBoatPassengerThirdPersonRotation({ ...base, vehicleName: 'horse' })).toBe(false) +}) + +test('first mount frame without anchor uses camera fallback gate', () => { + expect( + shouldApplyBoatPassengerThirdPersonRotation({ + isThirdPerson: true, + isAnchoredPassenger: false, + vehicleName: 'boat', + vehicleYaw: BOAT_YAW + }) + ).toBe(false) +}) + +test('first dismount frame without anchor uses camera fallback gate', () => { + expect( + shouldApplyBoatPassengerThirdPersonRotation({ + isThirdPerson: true, + isAnchoredPassenger: false, + vehicleName: undefined, + vehicleYaw: undefined + }) + ).toBe(false) +}) + +test('non-finite or missing vehicle yaw disables boat branch', () => { + expect( + shouldApplyBoatPassengerThirdPersonRotation({ + isThirdPerson: true, + isAnchoredPassenger: true, + vehicleName: 'boat', + vehicleYaw: Number.NaN + }) + ).toBe(false) + expect(getClampedBoatPassengerYaw(1.2, Number.NaN)).toBe(1.2) + expect(getClampedBoatPassengerYaw(Number.NaN, BOAT_YAW)).toBeNaN() +}) + +test('acceptance identity: headYaw ≈ clamp(normalize(cameraYaw - boatYaw))', () => { + const cameraYaw = BOAT_YAW + 1.1 + const resolved = resolveBoatPassengerThirdPersonRotation({ + cameraYaw, + cameraPitch: -0.4, + vehicleYaw: BOAT_YAW + }) + const expectedHead = clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, cameraYaw)) + expect(resolved.bodyYaw).toBeCloseTo(BOAT_YAW) + expect(resolved.headYaw).toBeCloseTo(expectedHead) + expect(resolved.effectiveCameraYaw).toBeCloseTo(normalizeYaw(BOAT_YAW + expectedHead)) + expect(resolved.headPitch).toBeCloseTo(0.4) +}) + +test('helmet head armor copies skin head yaw after boat passenger rotation', () => { + const resolved = resolveBoatPassengerThirdPersonRotation({ + cameraYaw: BOAT_YAW + 0.45, + cameraPitch: -0.2, + vehicleYaw: BOAT_YAW + }) + const skinHead = { x: resolved.headPitch, y: resolved.headYaw, z: 0 } + const helmet = { x: 0, y: 0, z: 0 } + helmet.x = -skinHead.x + helmet.y = skinHead.y + helmet.z = skinHead.z + expect(helmet.y).toBeCloseTo(resolved.headYaw) + expect(helmet.x).toBeCloseTo(-resolved.headPitch) +}) diff --git a/src/three/entity/boatPassengerRotation.ts b/src/three/entity/boatPassengerRotation.ts new file mode 100644 index 0000000..cd2955b --- /dev/null +++ b/src/three/entity/boatPassengerRotation.ts @@ -0,0 +1,68 @@ +import { isBoatEntityName } from './boatModelRotation' + +/** Vanilla 1.17.1 `Boat.clampRotation` limit (degrees → radians). */ +export const BOAT_PASSENGER_MAX_HEAD_YAW_RAD = (105 * Math.PI) / 180 + +export function normalizeYawDelta(from: number, to: number): number { + let delta = to - from + while (delta > Math.PI) delta -= Math.PI * 2 + while (delta < -Math.PI) delta += Math.PI * 2 + return delta +} + +export function normalizeYaw(yaw: number): number { + return normalizeYawDelta(0, yaw) +} + +export function clampBoatPassengerRelativeHeadYaw(relativeYaw: number): number { + return Math.max(-BOAT_PASSENGER_MAX_HEAD_YAW_RAD, Math.min(BOAT_PASSENGER_MAX_HEAD_YAW_RAD, relativeYaw)) +} + +/** Clamp absolute look yaw for a boat passenger (vanilla `Boat.clampRotation`). */ +export function getClampedBoatPassengerYaw(requestedYaw: number, boatYaw: number): number { + if (!Number.isFinite(requestedYaw) || !Number.isFinite(boatYaw)) return requestedYaw + const relative = normalizeYawDelta(boatYaw, requestedYaw) + const clampedRelative = clampBoatPassengerRelativeHeadYaw(relative) + return normalizeYaw(boatYaw + clampedRelative) +} + +export type BoatPassengerThirdPersonRotation = { + bodyYaw: number + headYaw: number + headPitch: number + effectiveCameraYaw: number +} + +export function resolveBoatPassengerThirdPersonRotation(params: { + cameraYaw: number + cameraPitch: number + vehicleYaw: number +}): BoatPassengerThirdPersonRotation { + const { cameraYaw, cameraPitch, vehicleYaw } = params + const relativeHeadYaw = normalizeYawDelta(vehicleYaw, cameraYaw) + const headYaw = clampBoatPassengerRelativeHeadYaw(relativeHeadYaw) + const effectiveCameraYaw = normalizeYaw(vehicleYaw + headYaw) + return { + bodyYaw: vehicleYaw, + headYaw, + headPitch: -cameraPitch, + effectiveCameraYaw + } +} + +export function shouldApplyBoatPassengerThirdPersonRotation(params: { + isThirdPerson: boolean + isAnchoredPassenger: boolean + vehicleName: string | undefined + vehicleYaw: number | undefined +}): boolean { + return ( + params.isThirdPerson && + params.isAnchoredPassenger && + isBoatEntityName(params.vehicleName) && + typeof params.vehicleYaw === 'number' && + Number.isFinite(params.vehicleYaw) + ) +} + +export { isBoatEntityName } From 46568877fc40d4d2df07e84f7794dff97b05217c Mon Sep 17 00:00:00 2001 From: zverev Date: Sat, 18 Jul 2026 00:35:15 +0300 Subject: [PATCH 13/20] fix(entities): animate and align boat paddles with vanilla Add time-based paddle animation using the vanilla 1.17.1 phase and rotation formulas. Group each paddle under a shared pivot, move the paddle geometry to the corrected oarlock position, and update both sides from render hints each frame. Preserve the static OBJ rest pose and water patch behavior. --- src/three/entities.ts | 40 ++++++ src/three/entity/boatPaddleAnimation.test.ts | 107 +++++++++++++++ src/three/entity/boatPaddleAnimation.ts | 103 +++++++++++++++ src/three/entity/boatRenderSetup.test.ts | 131 ++++++++++++++++++- src/three/entity/boatRenderSetup.ts | 113 +++++++++++++++- src/three/entity/interpolationPolicy.ts | 2 + 6 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 src/three/entity/boatPaddleAnimation.test.ts create mode 100644 src/three/entity/boatPaddleAnimation.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index 8f8a3f3..0ed7f9e 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -22,6 +22,13 @@ import * as Entity from './entity/EntityMesh' import { setupBoatMesh, disposeBoatWaterPatch } from './entity/boatRenderSetup' import { getBoatMeshYawOffset, isBoatEntityName } from './entity/boatModelRotation' import { resolveBoatPassengerThirdPersonRotation, shouldApplyBoatPassengerThirdPersonRotation } from './entity/boatPassengerRotation' +import { + createBoatPaddleAnimationState, + createBoatPaddlePivotScratch, + syncBoatPaddleAnimationTargets, + updateBoatPaddleAnimationState, + type BoatPaddleAnimationState +} from './entity/boatPaddleAnimation' import { releaseVehiclePassengerPosition } from './entity/vehiclePassengerRendering' import { updateVehiclePassengerPositions as applyVehiclePassengerPositions } from './entity/vehiclePassengerUpdate' import { applyNetworkHeadPitch, storeNetworkHeadPitch } from './entity/networkHeadPitchRendering' @@ -295,6 +302,7 @@ export class Entities { cachedMapsImages = {} as Record itemFrameMaps = {} as Record>> pendingModelOverrides = new Map() + private boatPaddleScratch = createBoatPaddlePivotScratch() get entitiesByName(): Record { const byName: Record = {} @@ -493,6 +501,28 @@ export class Entities { this.updateVehiclePassengerPositions() this.applyLocalThirdPersonPlayerRotation() + this.updateBoatPaddleAnimations(dt) + } + + private updateBoatPaddleAnimations(dt: number) { + for (const entity of Object.values(this.entities)) { + if (!entity) continue + if (!isBoatEntityName(entity['realName'] as string | undefined)) continue + + const mesh = entity.children.find(child => child.name === 'mesh') + if (!mesh) continue + + let animState = mesh.userData.boatPaddleAnimation as BoatPaddleAnimationState | undefined + if (!animState) continue + + updateBoatPaddleAnimationState( + animState, + dt, + mesh.userData.boatPaddleLeftPivot as THREE.Object3D | undefined, + mesh.userData.boatPaddleRightPivot as THREE.Object3D | undefined, + this.boatPaddleScratch + ) + } } private applyLocalThirdPersonPlayerRotation() { @@ -1329,6 +1359,16 @@ export class Entities { if (waterPatch) { waterPatch.visible = entity.renderHints?.boatWaterPatchVisible === true } + + const hints = (e.userData.renderHints ?? entity.renderHints) as EntityRenderHints | undefined + const leftActive = hints?.boatPaddleLeft === true + const rightActive = hints?.boatPaddleRight === true + let animState = mesh.userData.boatPaddleAnimation as BoatPaddleAnimationState | undefined + if (!animState) { + animState = createBoatPaddleAnimationState() + mesh.userData.boatPaddleAnimation = animState + } + syncBoatPaddleAnimationTargets(animState, leftActive, rightActive) } updateEntityPosition(entity: SceneEntity['originalEntity'], justAdded: boolean, overrides: { rotation?: { head?: { y: number; x: number } } }) { diff --git a/src/three/entity/boatPaddleAnimation.test.ts b/src/three/entity/boatPaddleAnimation.test.ts new file mode 100644 index 0000000..2d70e6f --- /dev/null +++ b/src/three/entity/boatPaddleAnimation.test.ts @@ -0,0 +1,107 @@ +import * as THREE from 'three' +import { expect, test } from 'vitest' +import { + BOAT_PADDLE_RADIANS_PER_SECOND, + BOAT_PADDLE_STEP, + advanceBoatPaddlePhase, + createBoatPaddlePivotScratch, + getBoatPaddleRelativeQuaternion, + getVanillaBoatPaddleAngles, + syncBoatPaddleAnimationTargets, + updateBoatPaddleAnimationState, + createBoatPaddleAnimationState +} from './boatPaddleAnimation' + +test('50ms active advancement equals pi/8', () => { + expect(advanceBoatPaddlePhase(0, true, 0.05)).toBeCloseTo(BOAT_PADDLE_STEP, 5) +}) + +test('equal elapsed time split across frame sizes gives the same phase', () => { + const total = 0.1 + const single = advanceBoatPaddlePhase(0, true, total) + const split = advanceBoatPaddlePhase(advanceBoatPaddlePhase(0, true, total / 2), true, total / 2) + expect(split).toBeCloseTo(single, 5) +}) + +test('inactive paddle resets phase to zero', () => { + expect(advanceBoatPaddlePhase(Math.PI, false, 0.05)).toBe(0) +}) + +test('active phase remains within [0, 2pi)', () => { + let phase = 0 + for (let i = 0; i < 200; i++) { + phase = advanceBoatPaddlePhase(phase, true, 0.016) + expect(phase).toBeGreaterThanOrEqual(0) + expect(phase).toBeLessThan(Math.PI * 2) + expect(Number.isFinite(phase)).toBe(true) + } +}) + +test('vanilla angles at phase 0, pi/4, and pi/2 match extracted formulas', () => { + const phases = [0, Math.PI / 4, Math.PI / 2] as const + for (const phase of phases) { + const angles = getVanillaBoatPaddleAngles(phase, 0) + const tX = (Math.sin(-phase) + 1) / 2 + const tY = (Math.sin(-phase + 1) + 1) / 2 + expect(angles.xRot).toBeCloseTo(-Math.PI / 3 + (-Math.PI / 12 - -Math.PI / 3) * tX, 5) + expect(angles.yRot).toBeCloseTo(-Math.PI / 4 + (Math.PI / 2) * tY, 5) + expect(angles.zRot).toBeCloseTo(Math.PI / 16, 5) + } +}) + +test('right-side Y is mirrored', () => { + const left = getVanillaBoatPaddleAngles(Math.PI / 3, 0) + const right = getVanillaBoatPaddleAngles(Math.PI / 3, 1) + expect(right.yRot).toBeCloseTo(Math.PI - left.yRot, 5) + expect(right.xRot).toBeCloseTo(left.xRot, 5) +}) + +test('relative quaternion at phase zero is identity', () => { + const scratch = createBoatPaddlePivotScratch() + const quat = getBoatPaddleRelativeQuaternion(0, 0, scratch) + expect(quat.x).toBeCloseTo(0, 5) + expect(quat.y).toBeCloseTo(0, 5) + expect(quat.z).toBeCloseTo(0, 5) + expect(quat.w).toBeCloseTo(1, 5) +}) + +test('returned phases and quaternions are finite', () => { + const scratch = createBoatPaddlePivotScratch() + const phase = advanceBoatPaddlePhase(0, true, 0.05) + const quat = getBoatPaddleRelativeQuaternion(phase, 1, scratch) + expect(Number.isFinite(phase)).toBe(true) + expect(Number.isFinite(quat.x)).toBe(true) + expect(Number.isFinite(quat.y)).toBe(true) + expect(Number.isFinite(quat.z)).toBe(true) + expect(Number.isFinite(quat.w)).toBe(true) +}) + +test('syncBoatPaddleAnimationTargets resets phase when activity changes', () => { + const state = createBoatPaddleAnimationState() + state.leftPhase = 1.2 + state.leftActive = true + + syncBoatPaddleAnimationTargets(state, false, false) + expect(state.leftActive).toBe(false) + expect(state.leftPhase).toBe(0) +}) + +test('updateBoatPaddleAnimationState advances active paddles and rotates pivots', () => { + const state = createBoatPaddleAnimationState() + state.leftActive = true + const leftPivot = new THREE.Object3D() + const scratch = createBoatPaddlePivotScratch() + + updateBoatPaddleAnimationState(state, 0.05, leftPivot, undefined, scratch) + expect(state.leftPhase).toBeCloseTo(BOAT_PADDLE_STEP, 5) + expect(leftPivot.quaternion.w).not.toBeCloseTo(1, 3) +}) + +test('negative or non-finite dt is treated as zero advancement', () => { + expect(advanceBoatPaddlePhase(0.5, true, Number.NaN)).toBe(0.5) + expect(advanceBoatPaddlePhase(0.5, true, -1)).toBe(0.5) +}) + +test('boat paddle radians per second matches vanilla tick rate', () => { + expect(BOAT_PADDLE_RADIANS_PER_SECOND).toBeCloseTo((Math.PI / 8) * 20, 5) +}) diff --git a/src/three/entity/boatPaddleAnimation.ts b/src/three/entity/boatPaddleAnimation.ts new file mode 100644 index 0000000..8cec925 --- /dev/null +++ b/src/three/entity/boatPaddleAnimation.ts @@ -0,0 +1,103 @@ +import * as THREE from 'three' + +export const BOAT_PADDLE_STEP = Math.PI / 8 +export const BOAT_TICKS_PER_SECOND = 20 +export const BOAT_PADDLE_RADIANS_PER_SECOND = BOAT_PADDLE_STEP * BOAT_TICKS_PER_SECOND + +export type BoatPaddleAnimationState = { + leftActive: boolean + rightActive: boolean + leftPhase: number + rightPhase: number +} + +export type BoatPaddlePivotScratch = { + current: THREE.Quaternion + rest: THREE.Quaternion + delta: THREE.Quaternion + euler: THREE.Euler +} + +export function createBoatPaddlePivotScratch(): BoatPaddlePivotScratch { + return { + current: new THREE.Quaternion(), + rest: new THREE.Quaternion(), + delta: new THREE.Quaternion(), + euler: new THREE.Euler(0, 0, 0, 'ZYX') + } +} + +export function createBoatPaddleAnimationState(): BoatPaddleAnimationState { + return { + leftActive: false, + rightActive: false, + leftPhase: 0, + rightPhase: 0 + } +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +export function advanceBoatPaddlePhase(phase: number, active: boolean, dt: number): number { + if (!active) return 0 + const safeDt = Number.isFinite(dt) && dt >= 0 ? dt : 0 + const twoPi = Math.PI * 2 + return (phase + safeDt * BOAT_PADDLE_RADIANS_PER_SECOND) % twoPi +} + +export function getVanillaBoatPaddleAngles(phase: number, side: 0 | 1): { xRot: number; yRot: number; zRot: number } { + const xRot = lerp(-Math.PI / 3, -Math.PI / 12, (Math.sin(-phase) + 1) / 2) + let yRot = lerp(-Math.PI / 4, Math.PI / 4, (Math.sin(-phase + 1) + 1) / 2) + if (side === 1) yRot = Math.PI - yRot + const zRot = Math.PI / 16 + return { xRot, yRot, zRot } +} + +function vanillaAnglesToQuaternion(angles: { xRot: number; yRot: number; zRot: number }, target: THREE.Quaternion, euler: THREE.Euler): THREE.Quaternion { + euler.set(-angles.xRot, angles.yRot, -angles.zRot, 'ZYX') + return target.setFromEuler(euler) +} + +export function getBoatPaddleRelativeQuaternion(phase: number, side: 0 | 1, scratch: BoatPaddlePivotScratch): THREE.Quaternion { + if (phase === 0) { + return scratch.delta.identity() + } + vanillaAnglesToQuaternion(getVanillaBoatPaddleAngles(phase, side), scratch.current, scratch.euler) + vanillaAnglesToQuaternion(getVanillaBoatPaddleAngles(0, side), scratch.rest, scratch.euler) + return scratch.delta.copy(scratch.current).multiply(scratch.rest.invert()) +} + +export function applyBoatPaddlePivotRotation(pivot: THREE.Object3D, phase: number, side: 0 | 1, scratch: BoatPaddlePivotScratch): void { + pivot.quaternion.copy(getBoatPaddleRelativeQuaternion(phase, side, scratch)) +} + +export function syncBoatPaddleAnimationTargets(state: BoatPaddleAnimationState, leftActive: boolean, rightActive: boolean): void { + if (state.leftActive !== leftActive) { + state.leftActive = leftActive + state.leftPhase = 0 + } + if (state.rightActive !== rightActive) { + state.rightActive = rightActive + state.rightPhase = 0 + } +} + +export function updateBoatPaddleAnimationState( + state: BoatPaddleAnimationState, + dt: number, + leftPivot: THREE.Object3D | undefined, + rightPivot: THREE.Object3D | undefined, + scratch: BoatPaddlePivotScratch +): void { + state.leftPhase = advanceBoatPaddlePhase(state.leftPhase, state.leftActive, dt) + state.rightPhase = advanceBoatPaddlePhase(state.rightPhase, state.rightActive, dt) + + if (leftPivot) { + applyBoatPaddlePivotRotation(leftPivot, state.leftPhase, 0, scratch) + } + if (rightPivot) { + applyBoatPaddlePivotRotation(rightPivot, state.rightPhase, 1, scratch) + } +} diff --git a/src/three/entity/boatRenderSetup.test.ts b/src/three/entity/boatRenderSetup.test.ts index fff1c79..7df2650 100644 --- a/src/three/entity/boatRenderSetup.test.ts +++ b/src/three/entity/boatRenderSetup.test.ts @@ -3,6 +3,12 @@ import { expect, test } from 'vitest' import { BOAT_HULL_RENDER_ORDER, BOAT_OBJ_OFFSET_Y, + BOAT_PADDLE_GEOMETRY_DELTA, + BOAT_PADDLE_LEFT_PIVOT, + BOAT_PADDLE_PIVOT_LEFT_NAME, + BOAT_PADDLE_PIVOT_RIGHT_NAME, + BOAT_PADDLE_RIGHT_PIVOT, + BOAT_PADDLE_SOURCE_LEFT, BOAT_WATER_PATCH_CENTER_Y, BOAT_WATER_PATCH_CENTER_Z, BOAT_WATER_PATCH_DEPTH, @@ -12,9 +18,11 @@ import { BOAT_WATER_PATCH_WIDTH, BOAT_WATER_PATCH_WORLD_BOUNDS, applyBoatHullRenderSettings, + boatObjLocalToEntitySpace, createBoatWaterPatchMesh, getBoatWaterPatchEntitySpaceBounds, - setupBoatMesh + setupBoatMesh, + setupBoatPaddlePivots } from './boatRenderSetup' test('water patch uses vanilla dimensions in blocks', () => { @@ -104,3 +112,124 @@ test('boat OBJ hull entity-space Y bounds after BOAT_OBJ_OFFSET_Y', () => { expect(patch.minY).toBeCloseTo(0.375, 5) expect(patch.maxY).toBeCloseTo(0.5625, 5) }) + +function makeBoatRootWithPaddles(): THREE.Object3D { + const root = new THREE.Object3D() + const leftShaft = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.5)) + leftShaft.name = 'paddle_left' + leftShaft.position.set(0.2, 1.5, 1.0) + const leftBlade = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.2)) + leftBlade.name = 'paddle_left' + leftBlade.position.set(0.15, 1.4, 1.2) + const rightShaft = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.5)) + rightShaft.name = 'paddle_right' + rightShaft.position.set(0.2, 1.5, -1.0) + const rightBlade = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.2)) + rightBlade.name = 'paddle_right' + rightBlade.position.set(0.15, 1.4, -1.2) + root.add(leftShaft, leftBlade, rightShaft, rightBlade) + return root +} + +test('setupBoatPaddlePivots groups both paddle pieces by side', () => { + const root = makeBoatRootWithPaddles() + const { leftPivot, rightPivot } = setupBoatPaddlePivots(root) + + expect(leftPivot?.children.length).toBe(2) + expect(rightPivot?.children.length).toBe(2) + expect(leftPivot?.name).toBe(BOAT_PADDLE_PIVOT_LEFT_NAME) + expect(rightPivot?.name).toBe(BOAT_PADDLE_PIVOT_RIGHT_NAME) +}) + +function getMeshRootLocalPosition(root: THREE.Object3D, mesh: THREE.Object3D): THREE.Vector3 { + const rootLocal = new THREE.Vector3() + let current: THREE.Object3D | null = mesh + while (current && current !== root) { + rootLocal.add(current.position) + current = current.parent + } + return rootLocal +} + +test('setupBoatPaddlePivots uses corrected vanilla attachment positions', () => { + const root = makeBoatRootWithPaddles() + const { leftPivot, rightPivot } = setupBoatPaddlePivots(root) + + expect(leftPivot?.position.toArray()).toEqual(BOAT_PADDLE_LEFT_PIVOT.toArray()) + expect(rightPivot?.position.toArray()).toEqual(BOAT_PADDLE_RIGHT_PIVOT.toArray()) +}) + +test('setupBoatPaddlePivots shifts root-local paddle geometry by geometry delta', () => { + const root = makeBoatRootWithPaddles() + const before = root.children.map(child => child.position.clone()) + setupBoatPaddlePivots(root) + + const afterPositions: THREE.Vector3[] = [] + root.traverse(child => { + if (child instanceof THREE.Mesh && (child.name === 'paddle_left' || child.name === 'paddle_right')) { + afterPositions.push(getMeshRootLocalPosition(root, child)) + } + }) + + expect(afterPositions.length).toBe(4) + for (let i = 0; i < before.length; i++) { + expect(afterPositions[i].x).toBeCloseTo(before[i].x + BOAT_PADDLE_GEOMETRY_DELTA.x, 5) + expect(afterPositions[i].y).toBeCloseTo(before[i].y + BOAT_PADDLE_GEOMETRY_DELTA.y, 5) + expect(afterPositions[i].z).toBeCloseTo(before[i].z + BOAT_PADDLE_GEOMETRY_DELTA.z, 5) + } +}) + +test('corrected paddle pivots map to vanilla entity Z with mesh yaw −π/2', () => { + const left = boatObjLocalToEntitySpace(BOAT_PADDLE_LEFT_PIVOT.x, BOAT_PADDLE_LEFT_PIVOT.y, BOAT_PADDLE_LEFT_PIVOT.z) + const right = boatObjLocalToEntitySpace(BOAT_PADDLE_RIGHT_PIVOT.x, BOAT_PADDLE_RIGHT_PIVOT.y, BOAT_PADDLE_RIGHT_PIVOT.z) + + expect(left.z).toBeCloseTo(-3 / 16, 5) + expect(right.z).toBeCloseTo(-3 / 16, 5) + expect(left.y).toBeCloseTo(0.6875, 5) + expect(right.y).toBeCloseTo(0.6875, 5) + expect(left.x).toBeCloseTo(-9 / 16, 5) + expect(right.x).toBeCloseTo(9 / 16, 5) +}) + +test('setupBoatPaddlePivots offsets child geometry from source pivot for rotation', () => { + const root = makeBoatRootWithPaddles() + const { leftPivot } = setupBoatPaddlePivots(root) + expect(leftPivot).toBeDefined() + + const shaft = leftPivot!.children[0] + const rootLocalBefore = new THREE.Vector3(0.2, 1.5, 1.0) + expect(shaft.position.x).toBeCloseTo(rootLocalBefore.x - BOAT_PADDLE_SOURCE_LEFT.x, 5) + expect(shaft.position.y).toBeCloseTo(rootLocalBefore.y - BOAT_PADDLE_SOURCE_LEFT.y, 5) + expect(shaft.position.z).toBeCloseTo(rootLocalBefore.z - BOAT_PADDLE_SOURCE_LEFT.z, 5) +}) + +test('rotating a pivot moves both paddle pieces rigidly', () => { + const root = makeBoatRootWithPaddles() + const { leftPivot } = setupBoatPaddlePivots(root) + expect(leftPivot).toBeDefined() + + const childOffsetsBefore = leftPivot!.children.map(child => child.position.clone()) + leftPivot!.rotation.y = Math.PI / 4 + leftPivot!.updateMatrixWorld(true) + + leftPivot!.children.forEach((child, index) => { + expect(child.position.x).toBeCloseTo(childOffsetsBefore[index].x, 5) + expect(child.position.y).toBeCloseTo(childOffsetsBefore[index].y, 5) + expect(child.position.z).toBeCloseTo(childOffsetsBefore[index].z, 5) + }) + expect(leftPivot!.position.toArray()).toEqual(BOAT_PADDLE_LEFT_PIVOT.toArray()) +}) + +test('setupBoatMesh is idempotent for paddle pivots and water patch', () => { + const root = makeBoatRootWithPaddles() + setupBoatMesh(root) + const leftPivot = root.userData.boatPaddleLeftPivot as THREE.Object3D + const waterPatchCountBefore = root.children.filter(child => child.name === BOAT_WATER_PATCH_NAME).length + + setupBoatMesh(root) + + expect(root.userData.boatPaddleLeftPivot).toBe(leftPivot) + expect(root.children.filter(child => child.name === BOAT_WATER_PATCH_NAME).length).toBe(waterPatchCountBefore) + expect(root.children.filter(child => child.name === BOAT_PADDLE_PIVOT_LEFT_NAME).length).toBe(1) + expect(root.children.filter(child => child.name === BOAT_PADDLE_PIVOT_RIGHT_NAME).length).toBe(1) +}) diff --git a/src/three/entity/boatRenderSetup.ts b/src/three/entity/boatRenderSetup.ts index 8e71f16..2b2d71f 100644 --- a/src/three/entity/boatRenderSetup.ts +++ b/src/three/entity/boatRenderSetup.ts @@ -30,6 +30,107 @@ export const BOAT_WATER_PATCH_RENDER_ORDER = 1 export const BOAT_WATER_PATCH_NAME = 'boat_water_patch' +export const BOAT_PADDLE_PIVOT_LEFT_NAME = 'boat_paddle_pivot_left' +export const BOAT_PADDLE_PIVOT_RIGHT_NAME = 'boat_paddle_pivot_right' + +/** Original handoff pivot (rotation center only — geometry stays aft if used alone). */ +export const BOAT_PADDLE_SOURCE_LEFT = new THREE.Vector3(3 / 16, 28 / 16, 9 / 16) +export const BOAT_PADDLE_SOURCE_RIGHT = new THREE.Vector3(3 / 16, 28 / 16, -9 / 16) + +/** Root-local shift applied to paddle mesh together with the pivot move. */ +export const BOAT_PADDLE_GEOMETRY_DELTA = new THREE.Vector3(-6 / 16, 1 / 16, 0) + +/** Vanilla 1.17.1 oarlock positions in OBJ-local blocks (after geometry delta). */ +export const BOAT_PADDLE_LEFT_PIVOT = new THREE.Vector3(-3 / 16, 29 / 16, 9 / 16) +export const BOAT_PADDLE_RIGHT_PIVOT = new THREE.Vector3(-3 / 16, 29 / 16, -9 / 16) + +/** Entity-space mapping for OBJ-local points (mesh `rotation.y = −π/2`, `BOAT_OBJ_OFFSET_Y`). */ +export function boatObjLocalToEntitySpace(objX: number, objY: number, objZ: number): THREE.Vector3 { + return new THREE.Vector3(-objZ, objY + BOAT_OBJ_OFFSET_Y, objX) +} + +const _rootLocalPos = new THREE.Vector3() + +function getRootLocalPosition(object: THREE.Object3D, root: THREE.Object3D): THREE.Vector3 { + _rootLocalPos.set(0, 0, 0) + let current: THREE.Object3D | null = object + while (current && current !== root) { + _rootLocalPos.add(current.position) + current = current.parent + } + return _rootLocalPos +} + +function createBoatPaddlePivot( + root: THREE.Object3D, + pivotName: string, + sourcePivot: THREE.Vector3, + targetPivot: THREE.Vector3, + paddleMeshes: THREE.Object3D[] +): THREE.Object3D | undefined { + if (paddleMeshes.length === 0) return undefined + + const pivot = new THREE.Object3D() + pivot.name = pivotName + pivot.position.copy(targetPivot) + root.add(pivot) + + for (const mesh of paddleMeshes) { + const parent = mesh.parent + if (!parent) continue + const rootLocal = getRootLocalPosition(mesh, root) + parent.remove(mesh) + // Offset from source oarlock; pivot sits at target so root-local geometry shifts by delta. + mesh.position.copy(rootLocal).sub(sourcePivot) + pivot.add(mesh) + } + + return pivot +} + +function collectBoatPaddleMeshes(root: THREE.Object3D, sideName: 'paddle_left' | 'paddle_right'): THREE.Object3D[] { + const meshes: THREE.Object3D[] = [] + root.traverse(child => { + if (child === root) return + if (child.name === BOAT_WATER_PATCH_NAME) return + if (child.name === BOAT_PADDLE_PIVOT_LEFT_NAME || child.name === BOAT_PADDLE_PIVOT_RIGHT_NAME) return + if (child.name === sideName) meshes.push(child) + }) + return meshes +} + +export function setupBoatPaddlePivots(root: THREE.Object3D): { + leftPivot?: THREE.Object3D + rightPivot?: THREE.Object3D +} { + if (root.userData.boatPaddleLeftPivot || root.userData.boatPaddleRightPivot) { + return { + leftPivot: root.userData.boatPaddleLeftPivot as THREE.Object3D | undefined, + rightPivot: root.userData.boatPaddleRightPivot as THREE.Object3D | undefined + } + } + + const leftPivot = createBoatPaddlePivot( + root, + BOAT_PADDLE_PIVOT_LEFT_NAME, + BOAT_PADDLE_SOURCE_LEFT, + BOAT_PADDLE_LEFT_PIVOT, + collectBoatPaddleMeshes(root, 'paddle_left') + ) + const rightPivot = createBoatPaddlePivot( + root, + BOAT_PADDLE_PIVOT_RIGHT_NAME, + BOAT_PADDLE_SOURCE_RIGHT, + BOAT_PADDLE_RIGHT_PIVOT, + collectBoatPaddleMeshes(root, 'paddle_right') + ) + + if (leftPivot) root.userData.boatPaddleLeftPivot = leftPivot + if (rightPivot) root.userData.boatPaddleRightPivot = rightPivot + + return { leftPivot, rightPivot } +} + export function getBoatMeshYawOffset(): number { return BOAT_MESH_YAW_OFFSET } @@ -90,10 +191,14 @@ export function getBoatWaterPatchEntitySpaceBounds(objOffsetY = BOAT_OBJ_OFFSET_ export function setupBoatMesh(root: THREE.Object3D): { waterPatch: THREE.Mesh } { applyBoatHullRenderSettings(root) - const waterPatch = createBoatWaterPatchMesh() - waterPatch.visible = false - root.add(waterPatch) - root.userData.boatWaterPatch = waterPatch + setupBoatPaddlePivots(root) + let waterPatch = root.userData.boatWaterPatch as THREE.Mesh | undefined + if (!waterPatch) { + waterPatch = createBoatWaterPatchMesh() + waterPatch.visible = false + root.add(waterPatch) + root.userData.boatWaterPatch = waterPatch + } return { waterPatch } } diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index 4b54dcc..65805e5 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -16,6 +16,8 @@ export type EntityRenderHints = { localVehicle?: boolean localVehicleVerticalCameraLock?: 'horse' boatWaterPatchVisible?: boolean + boatPaddleLeft?: boolean + boatPaddleRight?: boolean passengerIds?: number[] passengerLayout?: 'boat' | 'minecart' | 'horse' /** @deprecated Use passengerIds */ From a8e7d4ec9cdbb48ec6619d90c4e394a12f794c53 Mon Sep 17 00:00:00 2001 From: zverev Date: Sat, 18 Jul 2026 10:20:38 +0300 Subject: [PATCH 14/20] fix(entities): align remote boat passenger rotation Render anchored remote players with the boat body yaw and apply the network look direction as relative head rotation. Restore the generic player pose after dismounting. --- src/three/entities.ts | 15 +- .../entity/boatPassengerRotation.test.ts | 11 + src/three/entity/boatPassengerRotation.ts | 13 +- .../entity/networkHeadPitchRendering.test.ts | 32 ++- src/three/entity/networkHeadPitchRendering.ts | 34 ++- ...eBoatPassengerRotation.integration.test.ts | 239 ++++++++++++++++++ .../entity/remoteBoatPassengerRotation.ts | 132 ++++++++++ 7 files changed, 462 insertions(+), 14 deletions(-) create mode 100644 src/three/entity/remoteBoatPassengerRotation.integration.test.ts create mode 100644 src/three/entity/remoteBoatPassengerRotation.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index 0ed7f9e..0cf9f71 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -31,7 +31,8 @@ import { } from './entity/boatPaddleAnimation' import { releaseVehiclePassengerPosition } from './entity/vehiclePassengerRendering' import { updateVehiclePassengerPositions as applyVehiclePassengerPositions } from './entity/vehiclePassengerUpdate' -import { applyNetworkHeadPitch, storeNetworkHeadPitch } from './entity/networkHeadPitchRendering' +import { applyNetworkHeadPitch, storeNetworkHeadPitch, storeNetworkHeadYaw } from './entity/networkHeadPitchRendering' +import { processRemoteBoatPassengerRotations, type RemoteBoatPassengerEntity } from './entity/remoteBoatPassengerRotation' import { ENTITY_TWEEN_DURATION_MS, getEntityTweenDurationMs, @@ -500,10 +501,18 @@ export class Entities { } this.updateVehiclePassengerPositions() + this.applyRemoteBoatPassengerRotations() this.applyLocalThirdPersonPlayerRotation() this.updateBoatPaddleAnimations(dt) } + private applyRemoteBoatPassengerRotations() { + processRemoteBoatPassengerRotations({ + entities: this.entities as Record, + syncArmor: entity => this.syncArmorPositions(entity as SceneEntity) + }) + } + private updateBoatPaddleAnimations(dt: number) { for (const entity of Object.values(this.entities)) { if (!entity) continue @@ -1422,11 +1431,15 @@ export class Entities { if (e?.playerObject && overrides?.rotation?.head) { const { playerObject } = e + const hy = overrides.rotation.head.y + storeNetworkHeadYaw(e.userData, hy, entity.yaw) playerObject.skin.head.rotation.y = 0 const hp = overrides.rotation.head.x storeNetworkHeadPitch(e.userData, hp) applyNetworkHeadPitch(playerObject, e.userData) + } else if (e?.playerObject && typeof entity.yaw === 'number' && Number.isFinite(entity.yaw)) { + storeNetworkHeadYaw(e.userData, undefined, entity.yaw) } } diff --git a/src/three/entity/boatPassengerRotation.test.ts b/src/three/entity/boatPassengerRotation.test.ts index 2872510..3a65664 100644 --- a/src/three/entity/boatPassengerRotation.test.ts +++ b/src/three/entity/boatPassengerRotation.test.ts @@ -6,6 +6,7 @@ import { normalizeYaw, normalizeYawDelta, resolveBoatPassengerThirdPersonRotation, + shouldApplyBoatPassengerRotation, shouldApplyBoatPassengerThirdPersonRotation } from './boatPassengerRotation' @@ -78,6 +79,16 @@ test('head yaw sign follows normalize(cameraYaw - vehicleYaw) without inversion' expect(negative.headYaw).toBeCloseTo(-0.3) }) +test('shouldApplyBoatPassengerRotation excludes minecart and horse without third-person requirement', () => { + const base = { + isAnchoredPassenger: true, + vehicleYaw: BOAT_YAW + } + expect(shouldApplyBoatPassengerRotation({ ...base, vehicleName: 'boat' })).toBe(true) + expect(shouldApplyBoatPassengerRotation({ ...base, vehicleName: 'minecart' })).toBe(false) + expect(shouldApplyBoatPassengerRotation({ ...base, vehicleName: 'horse' })).toBe(false) +}) + test('shouldApplyBoatPassengerThirdPersonRotation excludes minecart and horse', () => { const base = { isThirdPerson: true, diff --git a/src/three/entity/boatPassengerRotation.ts b/src/three/entity/boatPassengerRotation.ts index cd2955b..93bd2ff 100644 --- a/src/three/entity/boatPassengerRotation.ts +++ b/src/three/entity/boatPassengerRotation.ts @@ -50,14 +50,12 @@ export function resolveBoatPassengerThirdPersonRotation(params: { } } -export function shouldApplyBoatPassengerThirdPersonRotation(params: { - isThirdPerson: boolean +export function shouldApplyBoatPassengerRotation(params: { isAnchoredPassenger: boolean vehicleName: string | undefined vehicleYaw: number | undefined }): boolean { return ( - params.isThirdPerson && params.isAnchoredPassenger && isBoatEntityName(params.vehicleName) && typeof params.vehicleYaw === 'number' && @@ -65,4 +63,13 @@ export function shouldApplyBoatPassengerThirdPersonRotation(params: { ) } +export function shouldApplyBoatPassengerThirdPersonRotation(params: { + isThirdPerson: boolean + isAnchoredPassenger: boolean + vehicleName: string | undefined + vehicleYaw: number | undefined +}): boolean { + return params.isThirdPerson && shouldApplyBoatPassengerRotation(params) +} + export { isBoatEntityName } diff --git a/src/three/entity/networkHeadPitchRendering.test.ts b/src/three/entity/networkHeadPitchRendering.test.ts index b331dd3..aec5e99 100644 --- a/src/three/entity/networkHeadPitchRendering.test.ts +++ b/src/three/entity/networkHeadPitchRendering.test.ts @@ -4,7 +4,7 @@ import { expect, test } from 'vitest' import type { PlayerObjectType } from '../../lib/createPlayerObject' import { WalkingGeneralSwing } from './animations' import { anchorVehiclePassengerPosition } from './vehiclePassengerRendering' -import { applyNetworkHeadPitch, storeNetworkHeadPitch, type NetworkHeadPitchState } from './networkHeadPitchRendering' +import { applyNetworkHeadPitch, storeNetworkHeadPitch, storeNetworkHeadYaw, type NetworkHeadRotationState } from './networkHeadPitchRendering' import { getMinecartPassengerWorldPosition } from './interpolationPolicy' function makePlayerObject(): PlayerObjectType { @@ -13,7 +13,7 @@ function makePlayerObject(): PlayerObjectType { return playerObject } -function simulateAnimationFrames(playerObject: PlayerObjectType, userData: NetworkHeadPitchState, frames: number, dt = 0.05) { +function simulateAnimationFrames(playerObject: PlayerObjectType, userData: NetworkHeadRotationState, frames: number, dt = 0.05) { const animation = new WalkingGeneralSwing() animation._captureDefaults(playerObject) for (let i = 0; i < frames; i++) { @@ -24,7 +24,7 @@ function simulateAnimationFrames(playerObject: PlayerObjectType, userData: Netwo test('remote head pitch persists across animation frames', () => { const playerObject = makePlayerObject() - const userData: NetworkHeadPitchState = {} + const userData: NetworkHeadRotationState = {} storeNetworkHeadPitch(userData, 0.6) simulateAnimationFrames(playerObject, userData, 5) @@ -35,7 +35,7 @@ test('remote head pitch persists across animation frames', () => { test('remote head pitch updates when network pitch changes', () => { const playerObject = makePlayerObject() - const userData: NetworkHeadPitchState = {} + const userData: NetworkHeadRotationState = {} storeNetworkHeadPitch(userData, 0.4) simulateAnimationFrames(playerObject, userData, 2) expect(playerObject.skin.head.rotation.x).toBeCloseTo(-0.4) @@ -47,7 +47,7 @@ test('remote head pitch updates when network pitch changes', () => { test('remote head pitch supports looking straight ahead', () => { const playerObject = makePlayerObject() - const userData: NetworkHeadPitchState = {} + const userData: NetworkHeadRotationState = {} storeNetworkHeadPitch(userData, 0) simulateAnimationFrames(playerObject, userData, 3) expect(playerObject.skin.head.rotation.x).toBeCloseTo(0) @@ -55,7 +55,7 @@ test('remote head pitch supports looking straight ahead', () => { test('local player keeps camera pitch authoritative', () => { const playerObject = makePlayerObject() - const userData: NetworkHeadPitchState = {} + const userData: NetworkHeadRotationState = {} storeNetworkHeadPitch(userData, 0.8) const cameraPitch = -0.35 @@ -66,7 +66,7 @@ test('local player keeps camera pitch authoritative', () => { test('minecart passenger anchor preserves head rotation', () => { const playerObject = makePlayerObject() - const userData: NetworkHeadPitchState = {} + const userData: NetworkHeadRotationState = {} const passenger = new THREE.Group() passenger.userData = userData @@ -81,3 +81,21 @@ test('minecart passenger anchor preserves head rotation', () => { expect(passenger.position.y).toBeCloseTo(63.65) expect(playerObject.skin.head.rotation.x).toBeCloseTo(-0.45) }) + +test('storeNetworkHeadYaw keeps last finite head yaw and ignores invalid updates', () => { + const userData: NetworkHeadRotationState = {} + storeNetworkHeadYaw(userData, 1.2) + expect(userData._networkHeadYaw).toBeCloseTo(1.2) + + storeNetworkHeadYaw(userData, Number.NaN, undefined) + expect(userData._networkHeadYaw).toBeCloseTo(1.2) + + storeNetworkHeadYaw(userData, undefined, 0.8) + expect(userData._networkHeadYaw).toBeCloseTo(0.8) +}) + +test('storeNetworkHeadYaw prefers explicit head yaw over fallback', () => { + const userData: NetworkHeadRotationState = {} + storeNetworkHeadYaw(userData, 0.5, 1.5) + expect(userData._networkHeadYaw).toBeCloseTo(0.5) +}) diff --git a/src/three/entity/networkHeadPitchRendering.ts b/src/three/entity/networkHeadPitchRendering.ts index 4d11c06..6fba495 100644 --- a/src/three/entity/networkHeadPitchRendering.ts +++ b/src/three/entity/networkHeadPitchRendering.ts @@ -1,18 +1,46 @@ import type { PlayerObjectType } from '../../lib/createPlayerObject' -export type NetworkHeadPitchState = { +export type NetworkHeadRotationState = { _networkHeadPitch?: number + _networkHeadYaw?: number + _remoteBoatRotationApplied?: boolean } -export function storeNetworkHeadPitch(userData: NetworkHeadPitchState, pitch: unknown): void { +/** @deprecated Use NetworkHeadRotationState */ +export type NetworkHeadPitchState = NetworkHeadRotationState + +export function storeNetworkHeadPitch(userData: NetworkHeadRotationState, pitch: unknown): void { if (typeof pitch === 'number' && Number.isFinite(pitch)) { userData._networkHeadPitch = pitch } } -export function applyNetworkHeadPitch(playerObject: PlayerObjectType, userData: NetworkHeadPitchState): void { +export function storeNetworkHeadYaw(userData: NetworkHeadRotationState, headYaw: unknown, fallbackYaw?: unknown): void { + if (typeof headYaw === 'number' && Number.isFinite(headYaw)) { + userData._networkHeadYaw = headYaw + return + } + if (typeof fallbackYaw === 'number' && Number.isFinite(fallbackYaw)) { + userData._networkHeadYaw = fallbackYaw + } +} + +export function getNetworkHeadPitch(userData: NetworkHeadRotationState): number { + const pitch = userData._networkHeadPitch + return typeof pitch === 'number' && Number.isFinite(pitch) ? pitch : 0 +} + +export function applyNetworkHeadPitch(playerObject: PlayerObjectType, userData: NetworkHeadRotationState): void { const pitch = userData._networkHeadPitch if (typeof pitch !== 'number' || !Number.isFinite(pitch)) return playerObject.skin.head.rotation.y = 0 playerObject.skin.head.rotation.x = -pitch } + +export function restoreGenericRemotePlayerHeadRotation( + playerObject: PlayerObjectType, + userData: NetworkHeadRotationState +): void { + playerObject.skin.head.rotation.y = 0 + applyNetworkHeadPitch(playerObject, userData) +} diff --git a/src/three/entity/remoteBoatPassengerRotation.integration.test.ts b/src/three/entity/remoteBoatPassengerRotation.integration.test.ts new file mode 100644 index 0000000..290dc34 --- /dev/null +++ b/src/three/entity/remoteBoatPassengerRotation.integration.test.ts @@ -0,0 +1,239 @@ +import * as THREE from 'three' +import { PlayerObject } from 'skinview3d' +import { expect, test } from 'vitest' +import type { PlayerObjectType } from '../../lib/createPlayerObject' +import { + BOAT_PASSENGER_MAX_HEAD_YAW_RAD, + clampBoatPassengerRelativeHeadYaw, + normalizeYawDelta +} from './boatPassengerRotation' +import { storeNetworkHeadPitch, storeNetworkHeadYaw } from './networkHeadPitchRendering' +import { + applyRemoteBoatPassengerRotation, + processRemoteBoatPassengerRotation, + processRemoteBoatPassengerRotations, + restoreGenericRemotePassengerRotation, + type RemoteBoatPassengerEntity, + type RemoteBoatPassengerVehicle +} from './remoteBoatPassengerRotation' +import { anchorVehiclePassengerPosition } from './vehiclePassengerRendering' +import { updateVehiclePassengerPositions } from './vehiclePassengerUpdate' +import { SceneOrigin } from '../sceneOrigin' + +const BOAT_YAW = -0.5645049299419149 +const REMOTE_PLAYER_A = 8 +const REMOTE_PLAYER_B = 9 +const BOAT_ID = 10 + +function makePlayerObject(): PlayerObjectType { + return new PlayerObject() as PlayerObjectType +} + +function makeRemotePassenger(id: number, lookYaw = BOAT_YAW) { + const passenger = new THREE.Group() as RemoteBoatPassengerEntity & THREE.Group & { originalEntity: { id: number } } + passenger.playerObject = makePlayerObject() + passenger.originalEntity = { id } + passenger.userData = {} + passenger.visible = true + passenger.rotation.y = lookYaw + storeNetworkHeadYaw(passenger.userData, lookYaw) + return passenger +} + +function makeBoat(passengerIds: number[], yaw = BOAT_YAW) { + const boat = new THREE.Group() as RemoteBoatPassengerVehicle & + THREE.Group & { originalEntity: { id: number; name: string; height: number } } + boat.originalEntity = { id: BOAT_ID, name: 'oak_boat', height: 1.6 } + boat.userData = { + renderHints: { + passengerLayout: 'boat', + passengerIds + } + } + boat.realName = 'oak_boat' + boat.rotation.y = yaw + return boat +} + +function createHarness(passengerIds: number[]) { + const sceneOrigin = new SceneOrigin(new THREE.Scene()) + sceneOrigin.update(96, 60, 192) + + const entities: Record = {} + for (const id of passengerIds) { + const passenger = makeRemotePassenger(id) + sceneOrigin.track(passenger) + entities[String(id)] = passenger + } + + const boat = makeBoat(passengerIds) + sceneOrigin.track(boat) + boat.position.set(100, 64, 200) + entities[String(BOAT_ID)] = boat + + const runPassengerUpdate = () => + updateVehiclePassengerPositions({ + entities: entities as Parameters[0]['entities'], + localPlayer: null, + getWorldPosition: target => sceneOrigin.getWorldPosition(target as THREE.Object3D) + }) + + const runRemoteRotation = () => + processRemoteBoatPassengerRotations({ + entities, + syncArmor: () => undefined + }) + + return { sceneOrigin, entities, boat, runPassengerUpdate, runRemoteRotation } +} + +test('remote anchored boat passenger uses boat yaw for body and relative head yaw', () => { + const lookYaw = BOAT_YAW + 0.6 + const { entities, boat, runPassengerUpdate, runRemoteRotation } = createHarness([REMOTE_PLAYER_A]) + const passenger = entities[String(REMOTE_PLAYER_A)]! + storeNetworkHeadYaw(passenger.userData, lookYaw) + passenger.rotation.y = lookYaw + + runPassengerUpdate() + runRemoteRotation() + + expect(passenger.userData._remoteBoatRotationApplied).toBe(true) + expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW) + expect(passenger.playerObject!.skin.head.rotation.y).toBeCloseTo( + clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookYaw)) + ) + expect(boat.rotation.y).toBeCloseTo(BOAT_YAW) +}) + +test('remote boat passenger head yaw updates when boat turns without new look packet', () => { + const lookYaw = BOAT_YAW + 0.4 + const { entities, boat, runPassengerUpdate, runRemoteRotation } = createHarness([REMOTE_PLAYER_A]) + const passenger = entities[String(REMOTE_PLAYER_A)]! + storeNetworkHeadYaw(passenger.userData, lookYaw) + + runPassengerUpdate() + runRemoteRotation() + expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW) + + boat.rotation.y = BOAT_YAW + 0.7 + runPassengerUpdate() + runRemoteRotation() + + expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW + 0.7) + expect(passenger.playerObject!.skin.head.rotation.y).toBeCloseTo( + clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW + 0.7, lookYaw)) + ) +}) + +test('two remote passengers keep independent head yaw on the same boat', () => { + const lookA = BOAT_YAW + 0.3 + const lookB = BOAT_YAW - 0.5 + const { entities, runPassengerUpdate, runRemoteRotation } = createHarness([REMOTE_PLAYER_A, REMOTE_PLAYER_B]) + const passengerA = entities[String(REMOTE_PLAYER_A)]! + const passengerB = entities[String(REMOTE_PLAYER_B)]! + + storeNetworkHeadYaw(passengerA.userData, lookA) + storeNetworkHeadYaw(passengerB.userData, lookB) + + runPassengerUpdate() + runRemoteRotation() + + expect(passengerA.playerObject!.skin.head.rotation.y).toBeCloseTo( + clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookA)) + ) + expect(passengerB.playerObject!.skin.head.rotation.y).toBeCloseTo( + clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookB)) + ) +}) + +test('dismount restores generic remote body yaw and zero head yaw', () => { + const lookYaw = BOAT_YAW + 0.55 + const { entities, boat, runPassengerUpdate, runRemoteRotation } = createHarness([REMOTE_PLAYER_A]) + const passenger = entities[String(REMOTE_PLAYER_A)]! + storeNetworkHeadYaw(passenger.userData, lookYaw) + storeNetworkHeadPitch(passenger.userData, 0.25) + + runPassengerUpdate() + runRemoteRotation() + expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW) + expect(passenger.userData._remoteBoatRotationApplied).toBe(true) + + boat.userData.renderHints = { passengerLayout: 'boat', passengerIds: [] } + runPassengerUpdate() + runRemoteRotation() + + expect(passenger.userData._remoteBoatRotationApplied).toBe(false) + expect(passenger.rotation.y).toBeCloseTo(lookYaw) + expect(passenger.playerObject!.skin.head.rotation.y).toBe(0) + expect(passenger.playerObject!.skin.head.rotation.x).toBeCloseTo(-0.25) +}) + +test('missing network yaw on first frame keeps generic pose until look arrives', () => { + const passenger = makeRemotePassenger(REMOTE_PLAYER_A, BOAT_YAW + 0.9) + delete passenger.userData._networkHeadYaw + const boat = makeBoat([REMOTE_PLAYER_A]) + anchorVehiclePassengerPosition(passenger, { x: 0, y: 0, z: 0 }, String(BOAT_ID)) + + processRemoteBoatPassengerRotation({ + passenger, + vehicle: boat, + syncArmor: () => undefined + }) + + expect(passenger.userData._remoteBoatRotationApplied).toBeUndefined() + expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW + 0.9) + expect(passenger.playerObject!.skin.head.rotation.y).toBe(0) +}) + +test('restoreGenericRemotePassengerRotation uses stored network yaw', () => { + const lookYaw = BOAT_YAW + 1.1 + const passenger = makeRemotePassenger(REMOTE_PLAYER_A, BOAT_YAW) + storeNetworkHeadYaw(passenger.userData, lookYaw) + storeNetworkHeadPitch(passenger.userData, -0.15) + passenger.userData._remoteBoatRotationApplied = true + + restoreGenericRemotePassengerRotation(passenger) + + expect(passenger.rotation.y).toBeCloseTo(lookYaw) + expect(passenger.playerObject!.skin.head.rotation.y).toBe(0) + expect(passenger.playerObject!.skin.head.rotation.x).toBeCloseTo(0.15) + expect(passenger.userData._remoteBoatRotationApplied).toBe(false) +}) + +test('applyRemoteBoatPassengerRotation stops active rotation tween', () => { + const lookYaw = BOAT_YAW + 0.2 + const passenger = makeRemotePassenger(REMOTE_PLAYER_A, lookYaw) + const boat = makeBoat([REMOTE_PLAYER_A]) + let tweenStopped = false + passenger.userData._rotTween = { + stop: () => { + tweenStopped = true + } + } + + applyRemoteBoatPassengerRotation({ + passenger, + vehicle: boat, + vehicleYaw: BOAT_YAW, + networkHeadYaw: lookYaw + }) + + expect(tweenStopped).toBe(true) + expect(passenger.userData._rotTween).toBeUndefined() +}) + +test('remote boat rotation clamps beyond ±105°', () => { + const lookYaw = BOAT_YAW + 2.0 + const passenger = makeRemotePassenger(REMOTE_PLAYER_A, lookYaw) + const boat = makeBoat([REMOTE_PLAYER_A]) + storeNetworkHeadYaw(passenger.userData, lookYaw) + + applyRemoteBoatPassengerRotation({ + passenger, + vehicle: boat, + vehicleYaw: BOAT_YAW, + networkHeadYaw: lookYaw + }) + + expect(passenger.playerObject!.skin.head.rotation.y).toBeCloseTo(BOAT_PASSENGER_MAX_HEAD_YAW_RAD) +}) diff --git a/src/three/entity/remoteBoatPassengerRotation.ts b/src/three/entity/remoteBoatPassengerRotation.ts new file mode 100644 index 0000000..08fa11d --- /dev/null +++ b/src/three/entity/remoteBoatPassengerRotation.ts @@ -0,0 +1,132 @@ +import type * as THREE from 'three' +import type { PlayerObjectType } from '../../lib/createPlayerObject' +import { resolveBoatPassengerThirdPersonRotation, shouldApplyBoatPassengerRotation } from './boatPassengerRotation' +import { + getNetworkHeadPitch, + restoreGenericRemotePlayerHeadRotation, + type NetworkHeadRotationState +} from './networkHeadPitchRendering' + +export type RemoteBoatPassengerEntity = { + rotation: { y: number; set: (x: number, y: number, z: number) => unknown } + visible: boolean + playerObject?: PlayerObjectType + userData: NetworkHeadRotationState & { + _passengerVehicleId?: string + _boatPassengerVehicleId?: string + _rotTween?: { stop: () => unknown } + } +} + +export type RemoteBoatPassengerVehicle = { + rotation: { y: number } + realName?: string + originalEntity: { name: string } +} + +export function getAnchoredVehicleId(userData: RemoteBoatPassengerEntity['userData']): string | undefined { + return userData._passengerVehicleId ?? userData._boatPassengerVehicleId +} + +export function stopRemotePassengerRotationTween(userData: RemoteBoatPassengerEntity['userData']): void { + userData._rotTween?.stop() + userData._rotTween = undefined +} + +export function applyRemoteBoatPassengerRotation(params: { + passenger: RemoteBoatPassengerEntity + vehicle: RemoteBoatPassengerVehicle + vehicleYaw: number + networkHeadYaw: number +}): void { + const { passenger, networkHeadYaw, vehicleYaw } = params + const playerObject = passenger.playerObject + if (!playerObject) return + + stopRemotePassengerRotationTween(passenger.userData) + + const resolved = resolveBoatPassengerThirdPersonRotation({ + cameraYaw: networkHeadYaw, + cameraPitch: getNetworkHeadPitch(passenger.userData), + vehicleYaw + }) + + passenger.rotation.set(0, resolved.bodyYaw, 0) + playerObject.skin.head.rotation.set(resolved.headPitch, resolved.headYaw, 0) + passenger.userData._remoteBoatRotationApplied = true +} + +export function restoreGenericRemotePassengerRotation(passenger: RemoteBoatPassengerEntity): void { + const playerObject = passenger.playerObject + if (!playerObject) return + + stopRemotePassengerRotationTween(passenger.userData) + + const networkHeadYaw = passenger.userData._networkHeadYaw + if (typeof networkHeadYaw === 'number' && Number.isFinite(networkHeadYaw)) { + passenger.rotation.set(0, networkHeadYaw, 0) + } + + restoreGenericRemotePlayerHeadRotation(playerObject, passenger.userData) + passenger.userData._remoteBoatRotationApplied = false +} + +export function processRemoteBoatPassengerRotation(params: { + passenger: RemoteBoatPassengerEntity + vehicle: RemoteBoatPassengerVehicle | undefined + syncArmor?: () => void +}): void { + const { passenger, vehicle, syncArmor } = params + if (!passenger.playerObject) return + + const userData = passenger.userData + const wasApplied = userData._remoteBoatRotationApplied === true + const anchoredVehicleId = getAnchoredVehicleId(userData) + const vehicleName = vehicle?.realName ?? vehicle?.originalEntity.name + const vehicleYaw = vehicle?.rotation.y + const gatePasses = shouldApplyBoatPassengerRotation({ + isAnchoredPassenger: anchoredVehicleId != null && vehicle != null, + vehicleName, + vehicleYaw + }) + + if (!gatePasses) { + if (wasApplied) { + restoreGenericRemotePassengerRotation(passenger) + syncArmor?.() + } + return + } + + const networkHeadYaw = userData._networkHeadYaw + if (typeof networkHeadYaw !== 'number' || !Number.isFinite(networkHeadYaw)) { + return + } + + applyRemoteBoatPassengerRotation({ + passenger, + vehicle: vehicle!, + vehicleYaw: vehicleYaw!, + networkHeadYaw + }) + syncArmor?.() +} + +export function processRemoteBoatPassengerRotations(params: { + entities: Record + syncArmor: (passenger: RemoteBoatPassengerEntity) => void +}): void { + for (const entity of Object.values(params.entities)) { + if (!entity?.playerObject) continue + + const anchoredVehicleId = getAnchoredVehicleId(entity.userData) + const vehicle = + anchoredVehicleId != null ? (params.entities[anchoredVehicleId] as RemoteBoatPassengerVehicle | undefined) : undefined + + processRemoteBoatPassengerRotation({ + passenger: entity, + vehicle, + syncArmor: entity.visible ? () => params.syncArmor(entity) : undefined + }) + } +} From 109fdf6b4a3123b708793ef4ec29e6092b4afeb8 Mon Sep 17 00:00:00 2001 From: zverev Date: Sat, 18 Jul 2026 10:23:08 +0300 Subject: [PATCH 15/20] chore: format prittier --- src/three/entity/boatPassengerRotation.ts | 7 +----- src/three/entity/networkHeadPitchRendering.ts | 5 +--- ...eBoatPassengerRotation.integration.test.ts | 25 +++++-------------- .../entity/remoteBoatPassengerRotation.ts | 9 ++----- 4 files changed, 10 insertions(+), 36 deletions(-) diff --git a/src/three/entity/boatPassengerRotation.ts b/src/three/entity/boatPassengerRotation.ts index 93bd2ff..f699414 100644 --- a/src/three/entity/boatPassengerRotation.ts +++ b/src/three/entity/boatPassengerRotation.ts @@ -55,12 +55,7 @@ export function shouldApplyBoatPassengerRotation(params: { vehicleName: string | undefined vehicleYaw: number | undefined }): boolean { - return ( - params.isAnchoredPassenger && - isBoatEntityName(params.vehicleName) && - typeof params.vehicleYaw === 'number' && - Number.isFinite(params.vehicleYaw) - ) + return params.isAnchoredPassenger && isBoatEntityName(params.vehicleName) && typeof params.vehicleYaw === 'number' && Number.isFinite(params.vehicleYaw) } export function shouldApplyBoatPassengerThirdPersonRotation(params: { diff --git a/src/three/entity/networkHeadPitchRendering.ts b/src/three/entity/networkHeadPitchRendering.ts index 6fba495..8bd2154 100644 --- a/src/three/entity/networkHeadPitchRendering.ts +++ b/src/three/entity/networkHeadPitchRendering.ts @@ -37,10 +37,7 @@ export function applyNetworkHeadPitch(playerObject: PlayerObjectType, userData: playerObject.skin.head.rotation.x = -pitch } -export function restoreGenericRemotePlayerHeadRotation( - playerObject: PlayerObjectType, - userData: NetworkHeadRotationState -): void { +export function restoreGenericRemotePlayerHeadRotation(playerObject: PlayerObjectType, userData: NetworkHeadRotationState): void { playerObject.skin.head.rotation.y = 0 applyNetworkHeadPitch(playerObject, userData) } diff --git a/src/three/entity/remoteBoatPassengerRotation.integration.test.ts b/src/three/entity/remoteBoatPassengerRotation.integration.test.ts index 290dc34..8be6efa 100644 --- a/src/three/entity/remoteBoatPassengerRotation.integration.test.ts +++ b/src/three/entity/remoteBoatPassengerRotation.integration.test.ts @@ -2,11 +2,7 @@ import * as THREE from 'three' import { PlayerObject } from 'skinview3d' import { expect, test } from 'vitest' import type { PlayerObjectType } from '../../lib/createPlayerObject' -import { - BOAT_PASSENGER_MAX_HEAD_YAW_RAD, - clampBoatPassengerRelativeHeadYaw, - normalizeYawDelta -} from './boatPassengerRotation' +import { BOAT_PASSENGER_MAX_HEAD_YAW_RAD, clampBoatPassengerRelativeHeadYaw, normalizeYawDelta } from './boatPassengerRotation' import { storeNetworkHeadPitch, storeNetworkHeadYaw } from './networkHeadPitchRendering' import { applyRemoteBoatPassengerRotation, @@ -41,8 +37,7 @@ function makeRemotePassenger(id: number, lookYaw = BOAT_YAW) { } function makeBoat(passengerIds: number[], yaw = BOAT_YAW) { - const boat = new THREE.Group() as RemoteBoatPassengerVehicle & - THREE.Group & { originalEntity: { id: number; name: string; height: number } } + const boat = new THREE.Group() as RemoteBoatPassengerVehicle & THREE.Group & { originalEntity: { id: number; name: string; height: number } } boat.originalEntity = { id: BOAT_ID, name: 'oak_boat', height: 1.6 } boat.userData = { renderHints: { @@ -99,9 +94,7 @@ test('remote anchored boat passenger uses boat yaw for body and relative head ya expect(passenger.userData._remoteBoatRotationApplied).toBe(true) expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW) - expect(passenger.playerObject!.skin.head.rotation.y).toBeCloseTo( - clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookYaw)) - ) + expect(passenger.playerObject!.skin.head.rotation.y).toBeCloseTo(clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookYaw))) expect(boat.rotation.y).toBeCloseTo(BOAT_YAW) }) @@ -120,9 +113,7 @@ test('remote boat passenger head yaw updates when boat turns without new look pa runRemoteRotation() expect(passenger.rotation.y).toBeCloseTo(BOAT_YAW + 0.7) - expect(passenger.playerObject!.skin.head.rotation.y).toBeCloseTo( - clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW + 0.7, lookYaw)) - ) + expect(passenger.playerObject!.skin.head.rotation.y).toBeCloseTo(clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW + 0.7, lookYaw))) }) test('two remote passengers keep independent head yaw on the same boat', () => { @@ -138,12 +129,8 @@ test('two remote passengers keep independent head yaw on the same boat', () => { runPassengerUpdate() runRemoteRotation() - expect(passengerA.playerObject!.skin.head.rotation.y).toBeCloseTo( - clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookA)) - ) - expect(passengerB.playerObject!.skin.head.rotation.y).toBeCloseTo( - clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookB)) - ) + expect(passengerA.playerObject!.skin.head.rotation.y).toBeCloseTo(clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookA))) + expect(passengerB.playerObject!.skin.head.rotation.y).toBeCloseTo(clampBoatPassengerRelativeHeadYaw(normalizeYawDelta(BOAT_YAW, lookB))) }) test('dismount restores generic remote body yaw and zero head yaw', () => { diff --git a/src/three/entity/remoteBoatPassengerRotation.ts b/src/three/entity/remoteBoatPassengerRotation.ts index 08fa11d..bd2e0ab 100644 --- a/src/three/entity/remoteBoatPassengerRotation.ts +++ b/src/three/entity/remoteBoatPassengerRotation.ts @@ -1,11 +1,7 @@ import type * as THREE from 'three' import type { PlayerObjectType } from '../../lib/createPlayerObject' import { resolveBoatPassengerThirdPersonRotation, shouldApplyBoatPassengerRotation } from './boatPassengerRotation' -import { - getNetworkHeadPitch, - restoreGenericRemotePlayerHeadRotation, - type NetworkHeadRotationState -} from './networkHeadPitchRendering' +import { getNetworkHeadPitch, restoreGenericRemotePlayerHeadRotation, type NetworkHeadRotationState } from './networkHeadPitchRendering' export type RemoteBoatPassengerEntity = { rotation: { y: number; set: (x: number, y: number, z: number) => unknown } @@ -120,8 +116,7 @@ export function processRemoteBoatPassengerRotations(params: { if (!entity?.playerObject) continue const anchoredVehicleId = getAnchoredVehicleId(entity.userData) - const vehicle = - anchoredVehicleId != null ? (params.entities[anchoredVehicleId] as RemoteBoatPassengerVehicle | undefined) : undefined + const vehicle = anchoredVehicleId != null ? (params.entities[anchoredVehicleId] as RemoteBoatPassengerVehicle | undefined) : undefined processRemoteBoatPassengerRotation({ passenger: entity, From da40fa953be414e191f1a8df7fd910526781dc94 Mon Sep 17 00:00:00 2001 From: Aleksandr Zverev Date: Fri, 31 Jul 2026 11:54:18 +0300 Subject: [PATCH 16/20] fix(entities): remove local vehicle rotation lag --- src/three/entities.ts | 13 ++++++++++--- src/three/entity/interpolationPolicy.test.ts | 7 +++++++ src/three/entity/interpolationPolicy.ts | 5 +++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/three/entities.ts b/src/three/entities.ts index 0cf9f71..52b2ce6 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -36,6 +36,7 @@ import { processRemoteBoatPassengerRotations, type RemoteBoatPassengerEntity } f import { ENTITY_TWEEN_DURATION_MS, getEntityTweenDurationMs, + getEntityRotationTweenDurationMs, resolveLocalVehicleWorldPosition, type EntityRenderHints, type Vec3Like, @@ -1410,7 +1411,7 @@ export class Entities { .start() } } - const rotationTweenDuration = justAdded ? 0 : ENTITY_TWEEN_DURATION_MS + const rotationTweenDuration = getEntityRotationTweenDurationMs(entity, justAdded) /** World yaw for the whole model: for PlayerObject skins, rotate body to head look dir; head mesh stays yaw-fixed (pitch only). */ let targetYaw: number | undefined if (e.playerObject && overrides?.rotation?.head) { @@ -1423,10 +1424,16 @@ export class Entities { targetYaw = entity.yaw } if (typeof targetYaw === 'number' && Number.isFinite(targetYaw)) { - const dy = shortestYawRadians(e.rotation.y, targetYaw) // Stop previous rotation tween to prevent accumulation (mirror _posTween) e.userData._rotTween?.stop() - e.userData._rotTween = new TWEEN.Tween(e.rotation).to({ y: e.rotation.y + dy }, rotationTweenDuration).start() + e.userData._rotTween = undefined + const currentYaw = Number.isFinite(e.rotation.y) ? e.rotation.y : 0 + const dy = shortestYawRadians(currentYaw, targetYaw) + if (rotationTweenDuration === 0) { + e.rotation.y = currentYaw + dy + } else { + e.userData._rotTween = new TWEEN.Tween(e.rotation).to({ y: currentYaw + dy }, rotationTweenDuration).start() + } } if (e?.playerObject && overrides?.rotation?.head) { diff --git a/src/three/entity/interpolationPolicy.test.ts b/src/three/entity/interpolationPolicy.test.ts index 0002225..69e2545 100644 --- a/src/three/entity/interpolationPolicy.test.ts +++ b/src/three/entity/interpolationPolicy.test.ts @@ -4,6 +4,7 @@ import { LOCAL_MOVEMENT_TWEEN_DURATION_MS, SPECTATING_CAMERA_TWEEN_DURATION_MS, getCameraMovementTweenDurationMs, + getEntityRotationTweenDurationMs, getEntityTweenDurationMs, getHorsePassengerWorldPosition, getLocalVehicleWorldPosition, @@ -104,6 +105,12 @@ test('local vehicle skips position tween', () => { expect(getEntityTweenDurationMs({ renderHints: { localVehicle: true } }, true)).toBe(0) }) +test('local vehicle skips rotation tween while remote vehicles keep it', () => { + expect(getEntityRotationTweenDurationMs({ renderHints: { localVehicle: true } }, false)).toBe(0) + expect(getEntityRotationTweenDurationMs({ renderHints: { localVehicle: false } }, false)).toBe(ENTITY_TWEEN_DURATION_MS) + expect(getEntityRotationTweenDurationMs(undefined, true)).toBe(0) +}) + test('locally ridden vehicle camera-sync policy stays separate from server-vehicle mode', () => { expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true } })).toBe(true) expect(usesCameraSyncedVehiclePosition({ renderHints: { localVehicle: true, passengerLayout: 'minecart' } })).toBe(true) diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index 65805e5..31bf30f 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -38,6 +38,11 @@ export function getEntityTweenDurationMs(entity: EntityWithRenderHints | undefin return ENTITY_TWEEN_DURATION_MS } +/** Rotation follows the same instant-vs-remote policy as entity position. */ +export function getEntityRotationTweenDurationMs(entity: EntityWithRenderHints | undefined, justAdded: boolean): number { + return getEntityTweenDurationMs(entity, justAdded) +} + export function samePosition(a: Vec3Like, b: Vec3Like, epsilon = CAMERA_POSITION_EPSILON): boolean { return Math.abs(a.x - b.x) < epsilon && Math.abs(a.y - b.y) < epsilon && Math.abs(a.z - b.z) < epsilon } From c21e4da4dc30f3ab0074ba722e23e4d38e6af33d Mon Sep 17 00:00:00 2001 From: Aleksandr Zverev Date: Fri, 31 Jul 2026 11:56:56 +0300 Subject: [PATCH 17/20] feat(entities): animate horse head with vanilla gait --- src/three/entities.ts | 42 ++++++ src/three/entity/EntityMesh.ts | 4 +- src/three/entity/horseHeadAnimation.test.ts | 97 ++++++++++++++ src/three/entity/horseHeadAnimation.ts | 134 ++++++++++++++++++++ 4 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 src/three/entity/horseHeadAnimation.test.ts create mode 100644 src/three/entity/horseHeadAnimation.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index 52b2ce6..753314e 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -44,6 +44,17 @@ import { isRideableMinecartEntityName } from './entity/interpolationPolicy' import { getMesh } from './entity/EntityMesh' +import { + HORSE_HEAD_ANIMATION_USER_DATA_KEY, + HORSE_HEAD_RIG_USER_DATA_KEY, + advanceHorseHeadAnimation, + applyHorseHeadPose, + calculateHorseHeadPose, + createHorseHeadAnimationState, + updateHorseHeadAnimationFrame, + type HorseHeadAnimationState, + type HorseHeadRig +} from './entity/horseHeadAnimation' import { WalkingGeneralSwing } from './entity/animations' import { disposeObject, loadNearestFilterTexture, loadTexture, loadThreeJsTextureFromUrl } from './threeJsUtils' import { armorModel, armorTextures, elytraTexture } from './entity/armorModels' @@ -470,6 +481,20 @@ export class Entities { playerObject.animation.update(playerObject, dt) } + const horseHeadRig = entity.userData[HORSE_HEAD_RIG_USER_DATA_KEY] as HorseHeadRig | undefined + const horseAnimation = entity.userData[HORSE_HEAD_ANIMATION_USER_DATA_KEY] as HorseHeadAnimationState | undefined + if (horseHeadRig && horseAnimation) { + const animation = updateHorseHeadAnimationFrame(horseAnimation, dt) + const pose = calculateHorseHeadPose({ + entityPitch: entity.userData._horseHeadPitch ?? 0, + headYaw: entity.userData._horseHeadYaw ?? entity.userData._horseBodyYaw ?? 0, + bodyYaw: entity.userData._horseBodyYaw ?? 0, + limbSwing: animation.limbSwing, + limbSwingAmount: animation.limbSwingAmount + }) + applyHorseHeadPose(horseHeadRig, pose) + } + if (!isPlayerEntity && playerObject) { applyNetworkHeadPitch(playerObject, entity.userData) } @@ -1020,6 +1045,8 @@ export class Entities { if (!e) return e.userData._posTween?.stop() e.userData._rotTween?.stop() + e.userData[HORSE_HEAD_ANIMATION_USER_DATA_KEY] = undefined + e.userData[HORSE_HEAD_RIG_USER_DATA_KEY] = undefined const boatMesh = e.children.find(c => c.name === 'mesh') if (boatMesh) disposeBoatWaterPatch(boatMesh) if (e.additionalCleanup) e.additionalCleanup() @@ -1145,6 +1172,11 @@ export class Entities { this.setRendering(this.currentlyRendering, group) this.afterAddEntity(entity) + const horseHeadRig = mesh.userData[HORSE_HEAD_RIG_USER_DATA_KEY] as HorseHeadRig | undefined + if (horseHeadRig) { + e.userData[HORSE_HEAD_RIG_USER_DATA_KEY] = horseHeadRig + e.userData[HORSE_HEAD_ANIMATION_USER_DATA_KEY] = createHorseHeadAnimationState() + } } else { mesh = e.children.find(c => c.name === 'mesh') if (entity.renderHints) { @@ -1385,6 +1417,12 @@ export class Entities { const e = this.entities[entity.id] if (!e) return this.applyEntityRenderHints(e, entity) + if (e.userData[HORSE_HEAD_RIG_USER_DATA_KEY]) { + e.userData._horseHeadPitch = typeof entity.pitch === 'number' && Number.isFinite(entity.pitch) ? entity.pitch : 0 + const headYaw = (entity as any).headYaw + e.userData._horseHeadYaw = typeof headYaw === 'number' && Number.isFinite(headYaw) ? headYaw : entity.yaw + e.userData._horseBodyYaw = typeof entity.yaw === 'number' && Number.isFinite(entity.yaw) ? entity.yaw : 0 + } const cameraSynced = usesCameraSyncedVehiclePosition(entity) if (entity.position) { if (cameraSynced) { @@ -1410,6 +1448,10 @@ export class Entities { }) .start() } + const horseAnimation = e.userData[HORSE_HEAD_ANIMATION_USER_DATA_KEY] as HorseHeadAnimationState | undefined + if (horseAnimation && Number.isFinite(entity.position.x) && Number.isFinite(entity.position.z)) { + advanceHorseHeadAnimation(horseAnimation, entity.position) + } } const rotationTweenDuration = getEntityRotationTweenDurationMs(entity, justAdded) /** World yaw for the whole model: for PlayerObject skins, rotate body to head look dir; head mesh stays yaw-fixed (pitch only). */ diff --git a/src/three/entity/EntityMesh.ts b/src/three/entity/EntityMesh.ts index 618e94b..0e62723 100644 --- a/src/three/entity/EntityMesh.ts +++ b/src/three/entity/EntityMesh.ts @@ -15,6 +15,7 @@ import externalTexturesJson from './externalTextures.json' import { createAnimatedObject } from './gltfAnimationUtils' import { BOAT_OBJ_OFFSET_Y } from './boatRenderSetup' import { VANILLA_117_HORSE_ENTITY_SCALES } from './horseEntityScale' +import { createHorseHeadRig } from './horseHeadAnimation' interface ElemFace { dir: [number, number, number] @@ -636,7 +637,7 @@ export class EntityMesh { child.material = material // todo if (child.name === 'Head layer') child.visible = false - if (child.name === 'Head' && overrides.rotation?.head) { + if (type !== 'horse' && child.name === 'Head' && overrides.rotation?.head) { // todo child.rotation.x -= ((overrides.rotation.head.x ?? 0) * Math.PI) / 180 child.rotation.y -= ((overrides.rotation.head.y ?? 0) * Math.PI) / 180 @@ -644,6 +645,7 @@ export class EntityMesh { } } }) + if (type === 'horse') createHorseHeadRig(obj) this.mesh = obj debugFlags.type = 'obj' return diff --git a/src/three/entity/horseHeadAnimation.test.ts b/src/three/entity/horseHeadAnimation.test.ts new file mode 100644 index 0000000..b2bc317 --- /dev/null +++ b/src/three/entity/horseHeadAnimation.test.ts @@ -0,0 +1,97 @@ +import * as THREE from 'three' +import { expect, test } from 'vitest' +import { + HORSE_HEAD_MAX_YAW, + advanceHorseHeadAnimation, + applyHorseHeadPose, + calculateHorseHeadPose, + createHorseHeadAnimationState, + createHorseHeadRig, + getInterpolatedHorseHeadAnimation, +} from './horseHeadAnimation' + +test('head rig keeps world bounds and includes duplicate head parts', () => { + const root = new THREE.Group() + const body = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + body.name = 'Body' + const head = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + head.name = 'Head' + head.position.set(0, 2, -1) + const headSaddleA = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + headSaddleA.name = 'HeadSaddle' + headSaddleA.position.set(0, 1.5, -1) + const headSaddleB = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + headSaddleB.name = 'HeadSaddle' + headSaddleB.position.set(0, 1.8, -1) + root.add(body, head, headSaddleA, headSaddleB) + root.updateMatrixWorld(true) + const before = new THREE.Box3().setFromObject(root) + + const rig = createHorseHeadRig(root) + root.updateMatrixWorld(true) + const after = new THREE.Box3().setFromObject(root) + + expect(rig.userData.horseHeadParts).toHaveLength(3) + expect(rig.userData.horseHeadParts).toContain(head) + expect(rig.userData.horseHeadParts.filter(part => part.name === 'HeadSaddle')).toHaveLength(2) + expect(after.min.toArray()).toEqual(before.min.toArray()) + expect(after.max.toArray()).toEqual(before.max.toArray()) + expect(root.children.filter(child => child.name === 'horse_head_rig')).toHaveLength(1) + expect(createHorseHeadRig(root)).toBe(rig) +}) + +test('horse head pitch and gait use radians', () => { + const pose = calculateHorseHeadPose({ + entityPitch: Math.PI / 6, + headYaw: 0, + bodyYaw: 0, + limbSwing: 0, + limbSwingAmount: 1, + }) + expect(pose.pitch).toBeCloseTo(Math.PI / 6 + 0.15) + expect(pose.yaw).toBe(0) +}) + +test('horse gait is disabled for a small limb amount', () => { + expect(calculateHorseHeadPose({ + entityPitch: 0.3, + headYaw: 0, + bodyYaw: 0, + limbSwing: Math.PI, + limbSwingAmount: 0.2, + }).pitch).toBeCloseTo(0.3) +}) + +test('relative head yaw is shortest-path and limited to twenty degrees', () => { + expect(calculateHorseHeadPose({ + entityPitch: 0, + headYaw: -Math.PI + 0.1, + bodyYaw: Math.PI - 0.1, + limbSwing: 0, + limbSwingAmount: 0, + }).yaw).toBeCloseTo(0.2) + expect(calculateHorseHeadPose({ + entityPitch: 0, + headYaw: Math.PI / 2, + bodyYaw: 0, + limbSwing: 0, + limbSwingAmount: 0, + }).yaw).toBeCloseTo(HORSE_HEAD_MAX_YAW) +}) + +test('animation phase uses authoritative horizontal distance and interpolates between ticks', () => { + const state = createHorseHeadAnimationState() + advanceHorseHeadAnimation(state, { x: 10, z: 10 }) + advanceHorseHeadAnimation(state, { x: 10.1, z: 10 }) + expect(state.limbSwingAmount).toBeCloseTo(0.16) + expect(getInterpolatedHorseHeadAnimation(state, 0).limbSwingAmount).toBe(0) + expect(getInterpolatedHorseHeadAnimation(state, 1).limbSwingAmount).toBeCloseTo(0.16) +}) + +test('applying a pose changes only the runtime rig', () => { + const rig = new THREE.Group() + applyHorseHeadPose(rig, { pitch: 0.4, yaw: -0.2 }) + expect(rig.rotation.x).toBeCloseTo(0.4) + expect(rig.rotation.y).toBeCloseTo(-0.2) + expect(rig.rotation.z).toBe(0) +}) diff --git a/src/three/entity/horseHeadAnimation.ts b/src/three/entity/horseHeadAnimation.ts new file mode 100644 index 0000000..5161aab --- /dev/null +++ b/src/three/entity/horseHeadAnimation.ts @@ -0,0 +1,134 @@ +import * as THREE from 'three' + +export const HORSE_HEAD_PIVOT = { x: 0, y: 1.25, z: -0.75 } as const +export const HORSE_HEAD_MAX_YAW = THREE.MathUtils.degToRad(20) +export const HORSE_HEAD_TICK_SECONDS = 0.05 + +const HORSE_HEAD_PART_NAMES = new Set([ + 'Head', + 'UMouth', + 'Ear1', + 'Ear2', + 'MuleEarL', + 'MuleEarR', + 'Neck', + 'Mane', + 'SaddleMouthL', + 'SaddleMouthR', + 'SaddleMouthLine', + 'SaddleMouthLineR', + 'HeadSaddle', +]) + +export type HorseHeadRig = THREE.Group & { + userData: THREE.Group['userData'] & { horseHeadParts: THREE.Object3D[] } +} + +export const HORSE_HEAD_RIG_USER_DATA_KEY = '_horseHeadRig' +export const HORSE_HEAD_ANIMATION_USER_DATA_KEY = '_horseHeadAnimation' + +/** + * Reparents every horse head-related OBJ object under the vanilla head_parts + * pivot while retaining each object's world transform. + */ +export function createHorseHeadRig (root: THREE.Object3D): HorseHeadRig { + const existing = root.userData[HORSE_HEAD_RIG_USER_DATA_KEY] as HorseHeadRig | undefined + if (existing) return existing + + const parts: THREE.Object3D[] = [] + root.traverse(child => { + if (child !== root && HORSE_HEAD_PART_NAMES.has(child.name)) parts.push(child) + }) + + const rig = new THREE.Group() as HorseHeadRig + rig.name = 'horse_head_rig' + rig.position.set(HORSE_HEAD_PIVOT.x, HORSE_HEAD_PIVOT.y, HORSE_HEAD_PIVOT.z) + rig.userData.horseHeadParts = parts + root.add(rig) + + for (const part of parts) rig.attach(part) + + root.userData[HORSE_HEAD_RIG_USER_DATA_KEY] = rig + return rig +} + +export type HorseHeadPoseInput = { + entityPitch: number + headYaw: number + bodyYaw: number + limbSwing: number + limbSwingAmount: number +} + +export type HorseHeadPose = { + pitch: number + yaw: number +} + +export function normalizeYawDelta (fromYaw: number, toYaw: number): number { + const tau = Math.PI * 2 + const delta = ((toYaw - fromYaw + Math.PI) % tau + tau) % tau - Math.PI + return delta === -Math.PI ? Math.PI : delta +} + +export function calculateHorseHeadPose (input: HorseHeadPoseInput): HorseHeadPose { + const { entityPitch, headYaw, bodyYaw, limbSwing, limbSwingAmount } = input + const safeAmount = Number.isFinite(limbSwingAmount) ? Math.max(0, limbSwingAmount) : 0 + const gait = safeAmount > 0.2 ? Math.cos(limbSwing * 0.4) * 0.15 * safeAmount : 0 + const relativeYaw = THREE.MathUtils.clamp(normalizeYawDelta(bodyYaw, headYaw), -HORSE_HEAD_MAX_YAW, HORSE_HEAD_MAX_YAW) + return { + pitch: (Number.isFinite(entityPitch) ? entityPitch : 0) + gait, + yaw: relativeYaw, + } +} + +export type HorseHeadAnimationState = { + lastPosition?: { x: number, z: number } + prevLimbSwing: number + limbSwing: number + prevLimbSwingAmount: number + limbSwingAmount: number + elapsedSeconds: number +} + +export function createHorseHeadAnimationState (): HorseHeadAnimationState { + return { + prevLimbSwing: 0, + limbSwing: 0, + prevLimbSwingAmount: 0, + limbSwingAmount: 0, + elapsedSeconds: HORSE_HEAD_TICK_SECONDS, + } +} + +/** Advance using authoritative entity coordinates, before camera transforms. */ +export function advanceHorseHeadAnimation (state: HorseHeadAnimationState, position: { x: number, z: number }): void { + state.prevLimbSwing = state.limbSwing + state.prevLimbSwingAmount = state.limbSwingAmount + + const distance = state.lastPosition == null + ? 0 + : Math.hypot(position.x - state.lastPosition.x, position.z - state.lastPosition.z) + const targetAmount = Math.min(1, distance * 4) + state.limbSwingAmount += (targetAmount - state.limbSwingAmount) * 0.4 + state.limbSwing += state.limbSwingAmount + state.lastPosition = { x: position.x, z: position.z } + state.elapsedSeconds = 0 +} + +export function getInterpolatedHorseHeadAnimation (state: HorseHeadAnimationState, partialTick: number): { limbSwing: number, limbSwingAmount: number } { + const alpha = THREE.MathUtils.clamp(partialTick, 0, 1) + return { + limbSwing: state.prevLimbSwing + (state.limbSwing - state.prevLimbSwing) * alpha, + limbSwingAmount: state.prevLimbSwingAmount + (state.limbSwingAmount - state.prevLimbSwingAmount) * alpha, + } +} + +export function updateHorseHeadAnimationFrame (state: HorseHeadAnimationState, deltaSeconds: number): { limbSwing: number, limbSwingAmount: number } { + state.elapsedSeconds = Math.min(HORSE_HEAD_TICK_SECONDS, state.elapsedSeconds + Math.max(0, deltaSeconds)) + return getInterpolatedHorseHeadAnimation(state, state.elapsedSeconds / HORSE_HEAD_TICK_SECONDS) +} + +export function applyHorseHeadPose (rig: THREE.Object3D, pose: HorseHeadPose): void { + rig.rotation.set(pose.pitch, pose.yaw, 0) +} From 8d73b952be6f3e1158d5a633deecd38e6e4de3dd Mon Sep 17 00:00:00 2001 From: Aleksandr Zverev Date: Fri, 31 Jul 2026 11:58:27 +0300 Subject: [PATCH 18/20] fix(horse): correct head pose pitch sign for OBJ rig Mineflayer notch pitch and vanilla gait now map to Three.js rotation.x with the same sign the horse OBJ expects: look-down stays negative and gait subtracts from pitch. --- src/three/entities.ts | 13 ++- src/three/entity/entityHeadPose.test.ts | 14 +++ src/three/entity/entityHeadPose.ts | 21 ++++ src/three/entity/horseHeadAnimation.test.ts | 115 +++++++++++++++----- src/three/entity/horseHeadAnimation.ts | 54 +++++---- src/three/worldRendererThree.ts | 8 +- 6 files changed, 172 insertions(+), 53 deletions(-) create mode 100644 src/three/entity/entityHeadPose.test.ts create mode 100644 src/three/entity/entityHeadPose.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index 753314e..58ff0ed 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -47,10 +47,10 @@ import { getMesh } from './entity/EntityMesh' import { HORSE_HEAD_ANIMATION_USER_DATA_KEY, HORSE_HEAD_RIG_USER_DATA_KEY, - advanceHorseHeadAnimation, applyHorseHeadPose, calculateHorseHeadPose, createHorseHeadAnimationState, + setHorseHeadAnimationPosition, updateHorseHeadAnimationFrame, type HorseHeadAnimationState, type HorseHeadRig @@ -1413,7 +1413,12 @@ export class Entities { syncBoatPaddleAnimationTargets(animState, leftActive, rightActive) } - updateEntityPosition(entity: SceneEntity['originalEntity'], justAdded: boolean, overrides: { rotation?: { head?: { y: number; x: number } } }) { + updateEntityPosition( + entity: SceneEntity['originalEntity'], + justAdded: boolean, + overrides: { rotation?: { head?: { y: number; x: number } } }, + headRotationOnly = false + ) { const e = this.entities[entity.id] if (!e) return this.applyEntityRenderHints(e, entity) @@ -1449,8 +1454,8 @@ export class Entities { .start() } const horseAnimation = e.userData[HORSE_HEAD_ANIMATION_USER_DATA_KEY] as HorseHeadAnimationState | undefined - if (horseAnimation && Number.isFinite(entity.position.x) && Number.isFinite(entity.position.z)) { - advanceHorseHeadAnimation(horseAnimation, entity.position) + if (!headRotationOnly && horseAnimation && Number.isFinite(entity.position.x) && Number.isFinite(entity.position.z)) { + setHorseHeadAnimationPosition(horseAnimation, entity.position) } } const rotationTweenDuration = getEntityRotationTweenDurationMs(entity, justAdded) diff --git a/src/three/entity/entityHeadPose.test.ts b/src/three/entity/entityHeadPose.test.ts new file mode 100644 index 0000000..89417d2 --- /dev/null +++ b/src/three/entity/entityHeadPose.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest' +import { resolveEntityHeadPose } from './entityHeadPose' + +test('uses entity pitch and network head yaw as separate angles', () => { + expect(resolveEntityHeadPose({ pitch: 0.25, yaw: 1, headYaw: -0.5 })).toEqual({ pitch: 0.25, headYaw: -0.5 }) +}) + +test('falls back to body yaw when head yaw is unavailable', () => { + expect(resolveEntityHeadPose({ pitch: -0.2, yaw: 1.25 })).toEqual({ pitch: -0.2, headYaw: 1.25 }) +}) + +test('does not propagate missing or non-finite rotations', () => { + expect(resolveEntityHeadPose({ pitch: Number.NaN, yaw: Number.POSITIVE_INFINITY, headYaw: undefined })).toEqual({ pitch: 0, headYaw: 0 }) +}) diff --git a/src/three/entity/entityHeadPose.ts b/src/three/entity/entityHeadPose.ts new file mode 100644 index 0000000..d8172ba --- /dev/null +++ b/src/three/entity/entityHeadPose.ts @@ -0,0 +1,21 @@ +export type EntityHeadPoseInput = { + pitch?: unknown + yaw?: unknown + headYaw?: unknown +} + +export type EntityHeadPose = { + pitch: number + headYaw: number +} + +export function resolveEntityHeadPose(entity: EntityHeadPoseInput): EntityHeadPose { + const pitch = typeof entity.pitch === 'number' && Number.isFinite(entity.pitch) ? entity.pitch : 0 + const headYaw = + typeof entity.headYaw === 'number' && Number.isFinite(entity.headYaw) + ? entity.headYaw + : typeof entity.yaw === 'number' && Number.isFinite(entity.yaw) + ? entity.yaw + : 0 + return { pitch, headYaw } +} diff --git a/src/three/entity/horseHeadAnimation.test.ts b/src/three/entity/horseHeadAnimation.test.ts index b2bc317..c2408c1 100644 --- a/src/three/entity/horseHeadAnimation.test.ts +++ b/src/three/entity/horseHeadAnimation.test.ts @@ -2,12 +2,14 @@ import * as THREE from 'three' import { expect, test } from 'vitest' import { HORSE_HEAD_MAX_YAW, - advanceHorseHeadAnimation, applyHorseHeadPose, calculateHorseHeadPose, createHorseHeadAnimationState, createHorseHeadRig, getInterpolatedHorseHeadAnimation, + HORSE_HEAD_TICK_SECONDS, + setHorseHeadAnimationPosition, + updateHorseHeadAnimationFrame } from './horseHeadAnimation' test('head rig keeps world bounds and includes duplicate head parts', () => { @@ -40,54 +42,113 @@ test('head rig keeps world bounds and includes duplicate head parts', () => { expect(createHorseHeadRig(root)).toBe(rig) }) -test('horse head pitch and gait use radians', () => { +test('horse head pitch follows entity pitch in radians', () => { const pose = calculateHorseHeadPose({ - entityPitch: Math.PI / 6, + entityPitch: -Math.PI / 6, headYaw: 0, bodyYaw: 0, limbSwing: 0, - limbSwingAmount: 1, + limbSwingAmount: 0 }) - expect(pose.pitch).toBeCloseTo(Math.PI / 6 + 0.15) + expect(pose.pitch).toBeCloseTo(-Math.PI / 6) expect(pose.yaw).toBe(0) }) -test('horse gait is disabled for a small limb amount', () => { - expect(calculateHorseHeadPose({ - entityPitch: 0.3, +test('horse gait subtracts from pitch at full limb amount', () => { + const pose = calculateHorseHeadPose({ + entityPitch: 0, headYaw: 0, bodyYaw: 0, - limbSwing: Math.PI, - limbSwingAmount: 0.2, - }).pitch).toBeCloseTo(0.3) + limbSwing: 0, + limbSwingAmount: 1 + }) + expect(pose.pitch).toBeCloseTo(-0.15) +}) + +test('horse gait is disabled for a small limb amount', () => { + expect( + calculateHorseHeadPose({ + entityPitch: 0.3, + headYaw: 0, + bodyYaw: 0, + limbSwing: Math.PI, + limbSwingAmount: 0.2 + }).pitch + ).toBeCloseTo(0.3) }) test('relative head yaw is shortest-path and limited to twenty degrees', () => { - expect(calculateHorseHeadPose({ - entityPitch: 0, - headYaw: -Math.PI + 0.1, - bodyYaw: Math.PI - 0.1, - limbSwing: 0, - limbSwingAmount: 0, - }).yaw).toBeCloseTo(0.2) - expect(calculateHorseHeadPose({ - entityPitch: 0, - headYaw: Math.PI / 2, - bodyYaw: 0, - limbSwing: 0, - limbSwingAmount: 0, - }).yaw).toBeCloseTo(HORSE_HEAD_MAX_YAW) + expect( + calculateHorseHeadPose({ + entityPitch: 0, + headYaw: -Math.PI + 0.1, + bodyYaw: Math.PI - 0.1, + limbSwing: 0, + limbSwingAmount: 0 + }).yaw + ).toBeCloseTo(0.2) + expect( + calculateHorseHeadPose({ + entityPitch: 0, + headYaw: Math.PI / 2, + bodyYaw: 0, + limbSwing: 0, + limbSwingAmount: 0 + }).yaw + ).toBeCloseTo(HORSE_HEAD_MAX_YAW) }) test('animation phase uses authoritative horizontal distance and interpolates between ticks', () => { const state = createHorseHeadAnimationState() - advanceHorseHeadAnimation(state, { x: 10, z: 10 }) - advanceHorseHeadAnimation(state, { x: 10.1, z: 10 }) + setHorseHeadAnimationPosition(state, { x: 10, z: 10 }) + updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS) + setHorseHeadAnimationPosition(state, { x: 10.1, z: 10 }) + updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS) expect(state.limbSwingAmount).toBeCloseTo(0.16) expect(getInterpolatedHorseHeadAnimation(state, 0).limbSwingAmount).toBe(0) expect(getInterpolatedHorseHeadAnimation(state, 1).limbSwingAmount).toBeCloseTo(0.16) }) +test('movement position is consumed once per render tick', () => { + const state = createHorseHeadAnimationState() + setHorseHeadAnimationPosition(state, { x: 10, z: 10 }) + updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS) + setHorseHeadAnimationPosition(state, { x: 10.1, z: 10 }) + updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS) + expect(state.limbSwingAmount).toBeCloseTo(0.16) + + setHorseHeadAnimationPosition(state, { x: 10.2, z: 10 }) + expect(state.limbSwingAmount).toBeCloseTo(0.16) + updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS) + expect(state.limbSwingAmount).toBeCloseTo(0.256) + expect(state.limbSwing).toBeCloseTo(0.416) +}) + +test('render frames decay gait when a remote horse stops sending movement updates', () => { + const state = createHorseHeadAnimationState() + setHorseHeadAnimationPosition(state, { x: 10, z: 10 }) + updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS) + setHorseHeadAnimationPosition(state, { x: 10.1, z: 10 }) + updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS) + updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS) + expect(state.limbSwingAmount).toBeCloseTo(0.096) + expect(state.limbSwing).toBeCloseTo(0.256) +}) + +test('interpolates the leftover time after a tick at 30 FPS', () => { + const state = createHorseHeadAnimationState() + setHorseHeadAnimationPosition(state, { x: 10, z: 10 }) + updateHorseHeadAnimationFrame(state, HORSE_HEAD_TICK_SECONDS) + setHorseHeadAnimationPosition(state, { x: 10.1, z: 10 }) + + updateHorseHeadAnimationFrame(state, 0.033) + const interpolated = updateHorseHeadAnimationFrame(state, 0.033) + + expect(state.tickAccumulatorSeconds).toBeCloseTo(0.016) + expect(state.elapsedSeconds / HORSE_HEAD_TICK_SECONDS).toBeCloseTo(0.32) + expect(interpolated.limbSwingAmount).toBeCloseTo(0.16 * 0.32) +}) + test('applying a pose changes only the runtime rig', () => { const rig = new THREE.Group() applyHorseHeadPose(rig, { pitch: 0.4, yaw: -0.2 }) diff --git a/src/three/entity/horseHeadAnimation.ts b/src/three/entity/horseHeadAnimation.ts index 5161aab..d9d66f1 100644 --- a/src/three/entity/horseHeadAnimation.ts +++ b/src/three/entity/horseHeadAnimation.ts @@ -17,7 +17,7 @@ const HORSE_HEAD_PART_NAMES = new Set([ 'SaddleMouthR', 'SaddleMouthLine', 'SaddleMouthLineR', - 'HeadSaddle', + 'HeadSaddle' ]) export type HorseHeadRig = THREE.Group & { @@ -31,7 +31,7 @@ export const HORSE_HEAD_ANIMATION_USER_DATA_KEY = '_horseHeadAnimation' * Reparents every horse head-related OBJ object under the vanilla head_parts * pivot while retaining each object's world transform. */ -export function createHorseHeadRig (root: THREE.Object3D): HorseHeadRig { +export function createHorseHeadRig(root: THREE.Object3D): HorseHeadRig { const existing = root.userData[HORSE_HEAD_RIG_USER_DATA_KEY] as HorseHeadRig | undefined if (existing) return existing @@ -65,70 +65,86 @@ export type HorseHeadPose = { yaw: number } -export function normalizeYawDelta (fromYaw: number, toYaw: number): number { +export function normalizeYawDelta(fromYaw: number, toYaw: number): number { const tau = Math.PI * 2 - const delta = ((toYaw - fromYaw + Math.PI) % tau + tau) % tau - Math.PI + const delta = ((((toYaw - fromYaw + Math.PI) % tau) + tau) % tau) - Math.PI return delta === -Math.PI ? Math.PI : delta } -export function calculateHorseHeadPose (input: HorseHeadPoseInput): HorseHeadPose { +export function calculateHorseHeadPose(input: HorseHeadPoseInput): HorseHeadPose { const { entityPitch, headYaw, bodyYaw, limbSwing, limbSwingAmount } = input const safeAmount = Number.isFinite(limbSwingAmount) ? Math.max(0, limbSwingAmount) : 0 const gait = safeAmount > 0.2 ? Math.cos(limbSwing * 0.4) * 0.15 * safeAmount : 0 const relativeYaw = THREE.MathUtils.clamp(normalizeYawDelta(bodyYaw, headYaw), -HORSE_HEAD_MAX_YAW, HORSE_HEAD_MAX_YAW) return { - pitch: (Number.isFinite(entityPitch) ? entityPitch : 0) + gait, - yaw: relativeYaw, + pitch: (Number.isFinite(entityPitch) ? entityPitch : 0) - gait, + yaw: relativeYaw } } export type HorseHeadAnimationState = { - lastPosition?: { x: number, z: number } + lastPosition?: { x: number; z: number } + pendingPosition?: { x: number; z: number } prevLimbSwing: number limbSwing: number prevLimbSwingAmount: number limbSwingAmount: number elapsedSeconds: number + tickAccumulatorSeconds: number } -export function createHorseHeadAnimationState (): HorseHeadAnimationState { +export function createHorseHeadAnimationState(): HorseHeadAnimationState { return { prevLimbSwing: 0, limbSwing: 0, prevLimbSwingAmount: 0, limbSwingAmount: 0, elapsedSeconds: HORSE_HEAD_TICK_SECONDS, + tickAccumulatorSeconds: 0 } } +/** Save authoritative entity coordinates until the next animation tick. */ +export function setHorseHeadAnimationPosition(state: HorseHeadAnimationState, position: { x: number; z: number }): void { + state.pendingPosition = { x: position.x, z: position.z } +} + /** Advance using authoritative entity coordinates, before camera transforms. */ -export function advanceHorseHeadAnimation (state: HorseHeadAnimationState, position: { x: number, z: number }): void { +export function advanceHorseHeadAnimation(state: HorseHeadAnimationState, position: { x: number; z: number }): void { state.prevLimbSwing = state.limbSwing state.prevLimbSwingAmount = state.limbSwingAmount - const distance = state.lastPosition == null - ? 0 - : Math.hypot(position.x - state.lastPosition.x, position.z - state.lastPosition.z) + const distance = state.lastPosition == null ? 0 : Math.hypot(position.x - state.lastPosition.x, position.z - state.lastPosition.z) const targetAmount = Math.min(1, distance * 4) state.limbSwingAmount += (targetAmount - state.limbSwingAmount) * 0.4 state.limbSwing += state.limbSwingAmount state.lastPosition = { x: position.x, z: position.z } - state.elapsedSeconds = 0 } -export function getInterpolatedHorseHeadAnimation (state: HorseHeadAnimationState, partialTick: number): { limbSwing: number, limbSwingAmount: number } { +export function getInterpolatedHorseHeadAnimation(state: HorseHeadAnimationState, partialTick: number): { limbSwing: number; limbSwingAmount: number } { const alpha = THREE.MathUtils.clamp(partialTick, 0, 1) return { limbSwing: state.prevLimbSwing + (state.limbSwing - state.prevLimbSwing) * alpha, - limbSwingAmount: state.prevLimbSwingAmount + (state.limbSwingAmount - state.prevLimbSwingAmount) * alpha, + limbSwingAmount: state.prevLimbSwingAmount + (state.limbSwingAmount - state.prevLimbSwingAmount) * alpha } } -export function updateHorseHeadAnimationFrame (state: HorseHeadAnimationState, deltaSeconds: number): { limbSwing: number, limbSwingAmount: number } { - state.elapsedSeconds = Math.min(HORSE_HEAD_TICK_SECONDS, state.elapsedSeconds + Math.max(0, deltaSeconds)) +export function updateHorseHeadAnimationFrame(state: HorseHeadAnimationState, deltaSeconds: number): { limbSwing: number; limbSwingAmount: number } { + const safeDeltaSeconds = Math.max(0, deltaSeconds) + state.elapsedSeconds = Math.min(HORSE_HEAD_TICK_SECONDS, state.elapsedSeconds + safeDeltaSeconds) + state.tickAccumulatorSeconds += safeDeltaSeconds + + while (state.tickAccumulatorSeconds >= HORSE_HEAD_TICK_SECONDS) { + const position = state.pendingPosition ?? state.lastPosition + if (position) advanceHorseHeadAnimation(state, position) + state.pendingPosition = undefined + state.tickAccumulatorSeconds -= HORSE_HEAD_TICK_SECONDS + } + + state.elapsedSeconds = state.tickAccumulatorSeconds return getInterpolatedHorseHeadAnimation(state, state.elapsedSeconds / HORSE_HEAD_TICK_SECONDS) } -export function applyHorseHeadPose (rig: THREE.Object3D, pose: HorseHeadPose): void { +export function applyHorseHeadPose(rig: THREE.Object3D, pose: HorseHeadPose): void { rig.rotation.set(pose.pitch, pose.yaw, 0) } diff --git a/src/three/worldRendererThree.ts b/src/three/worldRendererThree.ts index 5e9ea7e..3693980 100644 --- a/src/three/worldRendererThree.ts +++ b/src/three/worldRendererThree.ts @@ -15,6 +15,7 @@ import { getMyHand } from './hand' import { createHoldingBlock } from './holdingBlockFactory' import type { IHoldingBlock } from './holdingBlockTypes' import { getMesh } from './entity/EntityMesh' +import { resolveEntityHeadPose } from './entity/entityHeadPose' import { armorModel } from './entity/armorModels' import { disposeObject, loadThreeJsTextureFromBitmap } from './threeJsUtils' import { CursorBlock } from './world/cursorBlock' @@ -480,17 +481,18 @@ export class WorldRendererThree extends WorldRendererCommon { } updateEntity(e, isPosUpdate = false) { + const headPose = resolveEntityHeadPose(e) const overrides = { rotation: { head: { - x: e.headPitch ?? e.pitch, - y: e.headYaw, + x: headPose.pitch, + y: headPose.headYaw, z: 0 } } } if (isPosUpdate) { - this.entities.updateEntityPosition(e, false, overrides) + this.entities.updateEntityPosition(e, false, overrides, e.headRotationOnly === true) } else { this.entities.update(e, overrides) } From 8bdd22920888be6a6d748d1d724a014c73d1b528 Mon Sep 17 00:00:00 2001 From: Aleksandr Zverev Date: Fri, 31 Jul 2026 14:45:56 +0300 Subject: [PATCH 19/20] fix(entities): sync local horse yaw with camera each frame Render-only prediction removes head jitter when turning while mounted; physics and network yaw updates stay on the 20 Hz tick. --- src/three/entities.ts | 9 +++ src/three/entity/interpolationPolicy.ts | 18 +++++ .../entity/localHorseCameraYawLock.test.ts | 75 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 src/three/entity/localHorseCameraYawLock.test.ts diff --git a/src/three/entities.ts b/src/three/entities.ts index 58ff0ed..4140efd 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -35,9 +35,11 @@ import { applyNetworkHeadPitch, storeNetworkHeadPitch, storeNetworkHeadYaw } fro import { processRemoteBoatPassengerRotations, type RemoteBoatPassengerEntity } from './entity/remoteBoatPassengerRotation' import { ENTITY_TWEEN_DURATION_MS, + applyLocalHorseCameraYawLock, getEntityTweenDurationMs, getEntityRotationTweenDurationMs, resolveLocalVehicleWorldPosition, + shouldApplyLocalHorseCameraYawLock, type EntityRenderHints, type Vec3Like, usesCameraSyncedVehiclePosition, @@ -481,6 +483,13 @@ export class Entities { playerObject.animation.update(playerObject, dt) } + if (!isPlayerEntity) { + const renderHints = entity.userData.renderHints as EntityRenderHints | undefined + if (shouldApplyLocalHorseCameraYawLock(renderHints)) { + applyLocalHorseCameraYawLock(entity, this.worldRenderer.cameraShake.getBaseRotation().yaw) + } + } + const horseHeadRig = entity.userData[HORSE_HEAD_RIG_USER_DATA_KEY] as HorseHeadRig | undefined const horseAnimation = entity.userData[HORSE_HEAD_ANIMATION_USER_DATA_KEY] as HorseHeadAnimationState | undefined if (horseHeadRig && horseAnimation) { diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index 31bf30f..69fda68 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -15,6 +15,8 @@ export type Vec3Like = { x: number; y: number; z: number } export type EntityRenderHints = { localVehicle?: boolean localVehicleVerticalCameraLock?: 'horse' + /** Render-only: lock locally controlled horse yaw to camera each frame. */ + localVehicleYawLock?: 'horse' boatWaterPatchVisible?: boolean boatPaddleLeft?: boolean boatPaddleRight?: boolean @@ -32,6 +34,22 @@ export function usesCameraSyncedVehiclePosition(entity: EntityWithRenderHints | return !!entity?.renderHints?.localVehicle } +export function shouldApplyLocalHorseCameraYawLock(renderHints: EntityRenderHints | undefined): boolean { + return renderHints?.localVehicleYawLock === 'horse' +} + +/** Visual-only: align local horse model yaw with camera before head pose and passengers. */ +export function applyLocalHorseCameraYawLock( + sceneEntity: { rotation: { y: number }; userData: Record }, + cameraYaw: number +): boolean { + if (!Number.isFinite(cameraYaw)) return false + sceneEntity.rotation.y = cameraYaw + sceneEntity.userData._horseBodyYaw = cameraYaw + sceneEntity.userData._horseHeadYaw = cameraYaw + return true +} + export function getEntityTweenDurationMs(entity: EntityWithRenderHints | undefined, justAdded: boolean): number { if (justAdded) return 0 if (usesCameraSyncedVehiclePosition(entity)) return 0 diff --git a/src/three/entity/localHorseCameraYawLock.test.ts b/src/three/entity/localHorseCameraYawLock.test.ts new file mode 100644 index 0000000..8d08e64 --- /dev/null +++ b/src/three/entity/localHorseCameraYawLock.test.ts @@ -0,0 +1,75 @@ +import * as THREE from 'three' +import { expect, test } from 'vitest' +import { + applyLocalHorseCameraYawLock, + shouldApplyLocalHorseCameraYawLock, + type EntityRenderHints +} from './interpolationPolicy' + +function makeHorseSceneEntity(initialYaw = 0) { + const entity = new THREE.Group() + entity.rotation.y = initialYaw + entity.userData = { + _horseBodyYaw: initialYaw, + _horseHeadYaw: initialYaw + } + return entity +} + +test('local controlled horse yaw equals camera yaw without entityMoved', () => { + const entity = makeHorseSceneEntity(1.2) + const cameraYaw = 2.5 + + expect(applyLocalHorseCameraYawLock(entity, cameraYaw)).toBe(true) + expect(entity.rotation.y).toBe(cameraYaw) + expect(entity.userData._horseBodyYaw).toBe(cameraYaw) + expect(entity.userData._horseHeadYaw).toBe(cameraYaw) +}) + +test('two sequential camera yaw changes immediately update the model', () => { + const entity = makeHorseSceneEntity(0) + + applyLocalHorseCameraYawLock(entity, 1.0) + expect(entity.rotation.y).toBe(1.0) + + applyLocalHorseCameraYawLock(entity, 1.5) + expect(entity.rotation.y).toBe(1.5) + expect(entity.userData._horseBodyYaw).toBe(1.5) + expect(entity.userData._horseHeadYaw).toBe(1.5) +}) + +test('wrap ±π assigns camera yaw directly without shortest-path adjustment', () => { + const entity = makeHorseSceneEntity(Math.PI - 0.01) + const cameraYaw = -Math.PI + 0.01 + + applyLocalHorseCameraYawLock(entity, cameraYaw) + + expect(entity.rotation.y).toBeCloseTo(cameraYaw, 5) + expect(entity.userData._horseBodyYaw).toBeCloseTo(cameraYaw, 5) + expect(entity.userData._horseHeadYaw).toBeCloseTo(cameraYaw, 5) +}) + +test('non-finite camera yaw is ignored', () => { + const entity = makeHorseSceneEntity(1.25) + + expect(applyLocalHorseCameraYawLock(entity, Number.NaN)).toBe(false) + expect(applyLocalHorseCameraYawLock(entity, Number.POSITIVE_INFINITY)).toBe(false) + expect(entity.rotation.y).toBe(1.25) + expect(entity.userData._horseBodyYaw).toBe(1.25) + expect(entity.userData._horseHeadYaw).toBe(1.25) +}) + +test('shouldApplyLocalHorseCameraYawLock is true only for local horse yaw lock hint', () => { + expect(shouldApplyLocalHorseCameraYawLock({ localVehicleYawLock: 'horse' })).toBe(true) + expect(shouldApplyLocalHorseCameraYawLock({ localVehicle: true, passengerLayout: 'horse' })).toBe(false) + expect(shouldApplyLocalHorseCameraYawLock({ localVehicle: true, localVehicleVerticalCameraLock: 'horse' })).toBe(false) + expect(shouldApplyLocalHorseCameraYawLock(undefined)).toBe(false) +}) + +test.each([ + ['remote horse', { passengerLayout: 'horse' } satisfies EntityRenderHints], + ['local boat', { localVehicle: true, passengerLayout: 'boat' } satisfies EntityRenderHints], + ['local minecart', { localVehicle: true, passengerLayout: 'minecart' } satisfies EntityRenderHints], +])('%s does not receive yaw lock', (_label, renderHints) => { + expect(shouldApplyLocalHorseCameraYawLock(renderHints)).toBe(false) +}) From a9cdb194deaea6ecaea7c783f34d25539a3a2c98 Mon Sep 17 00:00:00 2001 From: Aleksandr Zverev Date: Fri, 31 Jul 2026 14:47:55 +0300 Subject: [PATCH 20/20] chore: format Prittier --- src/three/entity/interpolationPolicy.ts | 5 +---- src/three/entity/localHorseCameraYawLock.test.ts | 8 ++------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/three/entity/interpolationPolicy.ts b/src/three/entity/interpolationPolicy.ts index 69fda68..44c3cc0 100644 --- a/src/three/entity/interpolationPolicy.ts +++ b/src/three/entity/interpolationPolicy.ts @@ -39,10 +39,7 @@ export function shouldApplyLocalHorseCameraYawLock(renderHints: EntityRenderHint } /** Visual-only: align local horse model yaw with camera before head pose and passengers. */ -export function applyLocalHorseCameraYawLock( - sceneEntity: { rotation: { y: number }; userData: Record }, - cameraYaw: number -): boolean { +export function applyLocalHorseCameraYawLock(sceneEntity: { rotation: { y: number }; userData: Record }, cameraYaw: number): boolean { if (!Number.isFinite(cameraYaw)) return false sceneEntity.rotation.y = cameraYaw sceneEntity.userData._horseBodyYaw = cameraYaw diff --git a/src/three/entity/localHorseCameraYawLock.test.ts b/src/three/entity/localHorseCameraYawLock.test.ts index 8d08e64..14489fa 100644 --- a/src/three/entity/localHorseCameraYawLock.test.ts +++ b/src/three/entity/localHorseCameraYawLock.test.ts @@ -1,10 +1,6 @@ import * as THREE from 'three' import { expect, test } from 'vitest' -import { - applyLocalHorseCameraYawLock, - shouldApplyLocalHorseCameraYawLock, - type EntityRenderHints -} from './interpolationPolicy' +import { applyLocalHorseCameraYawLock, shouldApplyLocalHorseCameraYawLock, type EntityRenderHints } from './interpolationPolicy' function makeHorseSceneEntity(initialYaw = 0) { const entity = new THREE.Group() @@ -69,7 +65,7 @@ test('shouldApplyLocalHorseCameraYawLock is true only for local horse yaw lock h test.each([ ['remote horse', { passengerLayout: 'horse' } satisfies EntityRenderHints], ['local boat', { localVehicle: true, passengerLayout: 'boat' } satisfies EntityRenderHints], - ['local minecart', { localVehicle: true, passengerLayout: 'minecart' } satisfies EntityRenderHints], + ['local minecart', { localVehicle: true, passengerLayout: 'minecart' } satisfies EntityRenderHints] ])('%s does not receive yaw lock', (_label, renderHints) => { expect(shouldApplyLocalHorseCameraYawLock(renderHints)).toBe(false) })