diff --git a/src/components/CharacterView.tsx b/src/components/CharacterView.tsx index c2215f9..aa6ae4c 100644 --- a/src/components/CharacterView.tsx +++ b/src/components/CharacterView.tsx @@ -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['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); }); diff --git a/src/components/MenuView.scss b/src/components/MenuView.scss index ad3ba2d..1533bcd 100644 --- a/src/components/MenuView.scss +++ b/src/components/MenuView.scss @@ -32,6 +32,13 @@ &>.title { flex: 1; } + + & svg { + vertical-align: middle; + margin: 2px; + + box-sizing: border-box; + } } & details { diff --git a/src/model/objectPrefabs/wolf.ts b/src/model/objectPrefabs/wolf.ts index f3caa7f..7f286ad 100644 --- a/src/model/objectPrefabs/wolf.ts +++ b/src/model/objectPrefabs/wolf.ts @@ -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), ]; diff --git a/src/state/menuState.tsx b/src/state/menuState.tsx index 706aa47..7606f85 100644 --- a/src/state/menuState.tsx +++ b/src/state/menuState.tsx @@ -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: , 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 && } + {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)); } diff --git a/src/state/worldState.ts b/src/state/worldState.ts index 3a33ed3..b0593db 100644 --- a/src/state/worldState.ts +++ b/src/state/worldState.ts @@ -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: { diff --git a/src/types/model/runtime.ts b/src/types/model/runtime.ts index 9067556..18667fb 100644 --- a/src/types/model/runtime.ts +++ b/src/types/model/runtime.ts @@ -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 }; diff --git a/src/utils/runtime/object.ts b/src/utils/runtime/object.ts index ae680f6..0d65d45 100644 --- a/src/utils/runtime/object.ts +++ b/src/utils/runtime/object.ts @@ -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 {