camera fix for large player models

This commit is contained in:
azykov@mail.ru 2026-06-05 13:16:06 +03:00
parent fa459ba8ec
commit 3d518ce3f0
No known key found for this signature in database
7 changed files with 99 additions and 56 deletions

View File

@ -1,7 +1,7 @@
import { observer } from "mobx-react-lite";
import type { ObjectType, RuntimeGameObjectInstance } from "../types";
import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Euler, Quaternion, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei";
@ -13,7 +13,6 @@ const SPEED = 5;
const JUMP_SPEED = 8;
const GRAVITY = 20;
const SENSITIVITY = 0.002;
const SHOULDER_OFFSET = new Vector3(0.1, 1.5, 2.5);
const LOOK_RATE = 2000;
type RapierWorldCreateCharacterControllerFunction = ReturnType<typeof useRapier>['world']['createCharacterController'];
@ -36,6 +35,19 @@ export const PlayerObjectView = observer(function ({ object, objectType }: Playe
const { gl } = useThree();
const { world } = useRapier();
const { shoulderOffset, lookAtY } = useMemo(() => {
const bb = object.cache.boundingBox;
const W = bb.max[0] - bb.min[0];
const H = bb.max[1] - bb.min[1];
const D = bb.max[2] - bb.min[2];
const radius = Math.sqrt(W * W + H * H + D * D) / 2;
const centerY = (bb.min[1] + bb.max[1]) / 2;
return {
shoulderOffset: new Vector3(W * 0.1, centerY + H * 0.3, bb.max[2] + radius),
lookAtY: centerY,
};
}, [object.id]);
const yawRef = useRef(0);
const pitchRef = useRef(0);
const mouseRef = useRef({ x: 0, y: 0 });
@ -128,9 +140,9 @@ export const PlayerObjectView = observer(function ({ object, objectType }: Playe
const t = rb.translation();
_charPos.set(t.x, t.y, t.z);
_offset.copy(SHOULDER_OFFSET).applyEuler(_e.set(pitchRef.current, yawRef.current, 0, 'YXZ'));
_offset.copy(shoulderOffset).applyEuler(_e.set(pitchRef.current, yawRef.current, 0, 'YXZ'));
camera.position.copy(_charPos).add(_offset);
_lookAt.set(t.x, t.y + 1, t.z);
_lookAt.set(t.x, t.y + lookAtY, t.z);
camera.lookAt(_lookAt);
});

View File

@ -32,6 +32,13 @@
&>.title {
flex: 1;
}
& svg {
vertical-align: middle;
margin: 2px;
box-sizing: border-box;
}
}
& details {

View File

@ -11,25 +11,26 @@ function v(x: number, y: number, z: number, color: string): Voxel {
return { typeId: 'stone', position: [x, y, z], color };
}
// Wolf faces +Z (nose at Z=3). Head spans X:-3..3, Y:0..5, Z:-3..3.
// Wolf faces -Z (nose at Z=-3). Head spans X:-3..3, Y:0..5, Z:-3..3.
export const wolf: Voxel[] = [
// ── Back of skull Z=-3 ──────────────────────────────────────────────
v(-1,2,-3,G), v(0,2,-3,G), v(1,2,-3,G),
v(-1,3,-3,G), v(0,3,-3,G), v(1,3,-3,G),
v(-1,4,-3,G), v(0,4,-3,G), v(1,4,-3,G),
// ── Snout tip + nose Z=-3 ────────────────────────────────────────────
v(-1,1,-3,L), v(0,1,-3,L), v(1,1,-3,L),
v(-1,0,-3,L), v(0,0,-3,L), v(1,0,-3,L),
v(-1,2,-3,N), v(0,2,-3,N), v(1,2,-3,N), // nose
// ── Skull Z=-2 ──────────────────────────────────────────────────────
v(-2,2,-2,G), v(-1,2,-2,G), v(0,2,-2,G), v(1,2,-2,G), v(2,2,-2,G),
v(-2,3,-2,G), v(-1,3,-2,G), v(0,3,-2,G), v(1,3,-2,G), v(2,3,-2,G),
// ── Front face + pupils + snout Z=-2 ────────────────────────────────
v(-2,2,-2,L), v(-1,2,-2,L), v(0,2,-2,L), v(1,2,-2,L), v(2,2,-2,L),
v(-2,3,-2,G), v(-1,3,-2,N), v(0,3,-2,G), v(1,3,-2,N), v(2,3,-2,G),
v(-2,4,-2,G), v(-1,4,-2,G), v(0,4,-2,G), v(1,4,-2,G), v(2,4,-2,G),
v(-1,5,-2,G), v(0,5,-2,G), v(1,5,-2,G),
v(-1,0,-2,L), v(0,0,-2,L), v(1,0,-2,L),
v(-1,1,-2,L), v(0,1,-2,L), v(1,1,-2,L),
// ── Skull + ear bases Z=-1 ───────────────────────────────────────────
v(-2,2,-1,G), v(-1,2,-1,G), v(0,2,-1,G), v(1,2,-1,G), v(2,2,-1,G),
v(-2,3,-1,G), v(-1,3,-1,G), v(0,3,-1,G), v(1,3,-1,G), v(2,3,-1,G),
v(-3,4,-1,G), v(-2,4,-1,G), v(-1,4,-1,G), v(0,4,-1,G), v(1,4,-1,G), v(2,4,-1,G), v(3,4,-1,G),
v(-2,5,-1,G), v(-1,5,-1,G), v(0,5,-1,G), v(1,5,-1,G), v(2,5,-1,G),
v(-3,5,-1,D), v(3,5,-1,D),
// ── Face + amber eyes + snout Z=-1 ──────────────────────────────────
v(-2,2,-1,L), v(-1,2,-1,L), v(0,2,-1,L), v(1,2,-1,L), v(2,2,-1,L),
v(-2,3,-1,G), v(-1,3,-1,E), v(0,3,-1,G), v(1,3,-1,E), v(2,3,-1,G),
v(-2,4,-1,G), v(-1,4,-1,G), v(0,4,-1,G), v(1,4,-1,G), v(2,4,-1,G),
v(-1,0,-1,L), v(0,0,-1,L), v(1,0,-1,L),
v(-1,1,-1,L), v(0,1,-1,L), v(1,1,-1,L),
// ── Face + ears Z=0 ──────────────────────────────────────────────────
v(-2,2,0,L), v(-1,2,0,L), v(0,2,0,L), v(1,2,0,L), v(2,2,0,L),
@ -41,22 +42,21 @@ export const wolf: Voxel[] = [
v(-1,0,0,L), v(0,0,0,L), v(1,0,0,L),
v(-1,1,0,L), v(0,1,0,L), v(1,1,0,L),
// ── Face + amber eyes + snout Z=1 ────────────────────────────────────
v(-2,2,1,L), v(-1,2,1,L), v(0,2,1,L), v(1,2,1,L), v(2,2,1,L),
v(-2,3,1,G), v(-1,3,1,E), v(0,3,1,G), v(1,3,1,E), v(2,3,1,G),
v(-2,4,1,G), v(-1,4,1,G), v(0,4,1,G), v(1,4,1,G), v(2,4,1,G),
v(-1,0,1,L), v(0,0,1,L), v(1,0,1,L),
v(-1,1,1,L), v(0,1,1,L), v(1,1,1,L),
// ── Skull + ear bases Z=1 ────────────────────────────────────────────
v(-2,2,1,G), v(-1,2,1,G), v(0,2,1,G), v(1,2,1,G), v(2,2,1,G),
v(-2,3,1,G), v(-1,3,1,G), v(0,3,1,G), v(1,3,1,G), v(2,3,1,G),
v(-3,4,1,G), v(-2,4,1,G), v(-1,4,1,G), v(0,4,1,G), v(1,4,1,G), v(2,4,1,G), v(3,4,1,G),
v(-2,5,1,G), v(-1,5,1,G), v(0,5,1,G), v(1,5,1,G), v(2,5,1,G),
v(-3,5,1,D), v(3,5,1,D),
// ── Front face + pupils + snout Z=2 ──────────────────────────────────
v(-2,2,2,L), v(-1,2,2,L), v(0,2,2,L), v(1,2,2,L), v(2,2,2,L),
v(-2,3,2,G), v(-1,3,2,N), v(0,3,2,G), v(1,3,2,N), v(2,3,2,G),
// ── Skull Z=2 ───────────────────────────────────────────────────────
v(-2,2,2,G), v(-1,2,2,G), v(0,2,2,G), v(1,2,2,G), v(2,2,2,G),
v(-2,3,2,G), v(-1,3,2,G), v(0,3,2,G), v(1,3,2,G), v(2,3,2,G),
v(-2,4,2,G), v(-1,4,2,G), v(0,4,2,G), v(1,4,2,G), v(2,4,2,G),
v(-1,0,2,L), v(0,0,2,L), v(1,0,2,L),
v(-1,1,2,L), v(0,1,2,L), v(1,1,2,L),
v(-1,5,2,G), v(0,5,2,G), v(1,5,2,G),
// ── Snout tip + nose Z=3 ─────────────────────────────────────────────
v(-1,1,3,L), v(0,1,3,L), v(1,1,3,L),
v(-1,0,3,L), v(0,0,3,L), v(1,0,3,L),
v(-1,2,3,N), v(0,2,3,N), v(1,2,3,N), // nose
// ── Back of skull Z=3 ───────────────────────────────────────────────
v(-1,2,3,G), v(0,2,3,G), v(1,2,3,G),
v(-1,3,3,G), v(0,3,3,G), v(1,3,3,G),
v(-1,4,3,G), v(0,4,3,G), v(1,4,3,G),
];

View File

@ -12,7 +12,7 @@ export type MenuNodeAction = {
export type MenuNode = {
id: string;
title: string;
title: ReactNode;
className?: string;
actions?: MenuNodeAction[];
onClick?: () => void;
@ -26,31 +26,40 @@ export class MenuState {
}
private get editorObjectTypesMenu(): MenuNode[] {
const scene = state.worldEditor.scene;
return Object.values(state.world.data.objectTypes)
.map((ot) => ({
id: `ot-${ot.id}`,
title: ot.name,
onClick: () => { state.worldEditor.setSelectedObjectType({ id: ot.id, editMode: 'scripts' }) },
selected: () => state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection?.id === ot.id,
children: Object.values(state.worldEditor.scene.objects)
children: Object.values(scene.objects)
.filter((o) => o.typeId === ot.id)
.map((o) => ({
id: `o-${o.id}`,
title: o.id,
className: state.worldEditor.scene.playerObjectId === o.id
? 'player-controlled-object'
: undefined,
actions: [
{
.map((o) => {
const isPlayer = scene.playerObjectId === o.id;
const actions: MenuNodeAction[] = [];
if (!isPlayer)
actions.push({
id: 'control-by-player',
content: <IconRun size="1em" />,
tooltip: 'Mark as player',
onClick: () => { state.markObjectAsPlayer(o); },
}
],
onClick: () => { state.worldEditor.setSelectedObject({ id: o.id, editMode: 'translate' }) },
selected: () => state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === o.id,
} as MenuNode))
});
return {
id: `o-${o.id}`,
title: <>
{isPlayer && <IconRun size="1em" />}
{o.id}
</>,
className: isPlayer ? 'player-controlled-object' : undefined,
actions,
onClick: () => { state.worldEditor.setSelectedObject({ id: o.id, editMode: 'translate' }) },
selected: () => state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === o.id,
} as MenuNode;
})
} as MenuNode));
}

View File

@ -68,8 +68,8 @@ export class WorldState {
},
voxelTypes: DEFAULT_VOXEL_TYPES,
editorCamera: {
position: [-9, 11, 30],
look: [-0.52, -0.35, -0.2],
position: [14, 17, -25],
look: [-2.5, 0.31, 3],
},
initialScene: {
objects: {

View File

@ -6,6 +6,7 @@ export type ObjectInstanceRuntimeData = {
cache: {
voxelGroups: VoxelGroup[];
colliderMesh: [Float32Array, Uint32Array] | null;
boundingBox: { min: V3; max: V3 };
};
pendingActions: {
impulse?: { direction: V3, amplitude: number };

View File

@ -1,7 +1,23 @@
import { getObjectVoxelGroups } from "../graphics/voxelGroup";
import type { ObjectInstance, RuntimeObjectInstance, World } from "../../types";
import type { ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types";
import { buildObjectTrimesh } from "../graphics/mesh";
function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {
if (!objectType.voxels.length)
return { min: [0, 0, 0], max: [1, 1, 1] };
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (const v of objectType.voxels) {
minX = Math.min(minX, v.position[0]);
minY = Math.min(minY, v.position[1]);
minZ = Math.min(minZ, v.position[2]);
maxX = Math.max(maxX, v.position[0] + 1);
maxY = Math.max(maxY, v.position[1] + 1);
maxZ = Math.max(maxZ, v.position[2] + 1);
}
return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] };
}
export function populateRuntimeObject(object: ObjectInstance, world: World): RuntimeObjectInstance {
const objectType = world.objectTypes[object.typeId];
@ -10,12 +26,10 @@ export function populateRuntimeObject(object: ObjectInstance, world: World): Run
cache: {
voxelGroups: getObjectVoxelGroups(objectType, world.voxelTypes),
colliderMesh: buildObjectTrimesh(objectType, world.voxelTypes),
boundingBox: computeBoundingBox(objectType),
},
pendingActions: {
}
pendingActions: {},
};
}
export function depopulateRuntimeObject(object: RuntimeObjectInstance, _world: World): ObjectInstance {