third person view for character
This commit is contained in:
parent
8daa62ad8e
commit
da67932827
|
|
@ -2,14 +2,22 @@ import { observer } from "mobx-react-lite";
|
||||||
import type { Character } from "../types";
|
import type { Character } from "../types";
|
||||||
import { SyncRigidBody } from "./SyncRigidBody";
|
import { SyncRigidBody } from "./SyncRigidBody";
|
||||||
import { state } from "../state";
|
import { state } from "../state";
|
||||||
import { useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Vector3 } from "three";
|
import { Euler, Quaternion, Vector3 } from "three";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import { PointerLockControls, useKeyboardControls } from "@react-three/drei";
|
import { useKeyboardControls } from "@react-three/drei";
|
||||||
import type { RapierRigidBody } from "@react-three/rapier";
|
import type { RapierRigidBody } from "@react-three/rapier";
|
||||||
|
|
||||||
const SPEED = 5;
|
const SPEED = 5;
|
||||||
const _fwd = new Vector3();
|
const SENSITIVITY = 0.002;
|
||||||
|
// Shoulder offset in character-local space: right, up, behind
|
||||||
|
const SHOULDER_OFFSET = new Vector3(0.1, 1.5, 2.5);
|
||||||
|
|
||||||
|
const _q = new Quaternion();
|
||||||
|
const _e = new Euler(0, 0, 0, 'YXZ');
|
||||||
|
const _offset = new Vector3();
|
||||||
|
const _charPos = new Vector3();
|
||||||
|
const _lookAt = new Vector3();
|
||||||
|
|
||||||
type CharacterViewProps = {
|
type CharacterViewProps = {
|
||||||
character: Character;
|
character: Character;
|
||||||
|
|
@ -18,50 +26,86 @@ type CharacterViewProps = {
|
||||||
|
|
||||||
export const CharacterView = observer(function ({ character, editMode }: CharacterViewProps) {
|
export const CharacterView = observer(function ({ character, editMode }: CharacterViewProps) {
|
||||||
const pos = character.transform.position;
|
const pos = character.transform.position;
|
||||||
|
|
||||||
const rbRef = useRef<RapierRigidBody>(null);
|
const rbRef = useRef<RapierRigidBody>(null);
|
||||||
|
|
||||||
const [, get] = useKeyboardControls();
|
const [, get] = useKeyboardControls();
|
||||||
|
const { gl } = useThree();
|
||||||
|
|
||||||
|
const yawRef = useRef(0);
|
||||||
|
const pitchRef = useRef(0);
|
||||||
|
const mouseRef = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editMode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const canvas = gl.domElement;
|
||||||
|
const onClick = () => canvas.requestPointerLock();
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
if (document.pointerLockElement !== canvas) return;
|
||||||
|
mouseRef.current.x += e.movementX;
|
||||||
|
mouseRef.current.y += e.movementY;
|
||||||
|
};
|
||||||
|
canvas.addEventListener('click', onClick);
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('click', onClick);
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
};
|
||||||
|
}, [gl, editMode]);
|
||||||
|
|
||||||
useFrame(({ camera }) => {
|
useFrame(({ camera }) => {
|
||||||
if (state.game?.isPaused || !rbRef.current)
|
if (editMode)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!editMode) {
|
if (!rbRef.current)
|
||||||
|
return;
|
||||||
|
|
||||||
|
yawRef.current -= mouseRef.current.x * SENSITIVITY;
|
||||||
|
pitchRef.current = Math.max(-0.4, Math.min(0.6, pitchRef.current - mouseRef.current.y * SENSITIVITY));
|
||||||
|
mouseRef.current.x = 0;
|
||||||
|
mouseRef.current.y = 0;
|
||||||
|
|
||||||
|
const yaw = yawRef.current;
|
||||||
|
|
||||||
|
// Rotate character body on Y axis only
|
||||||
|
_q.setFromEuler(_e.set(0, yaw, 0));
|
||||||
|
rbRef.current.setRotation({ x: _q.x, y: _q.y, z: _q.z, w: _q.w }, true);
|
||||||
|
|
||||||
|
// Pin camera to shoulder offset, rotated by yaw + pitch
|
||||||
|
const t = rbRef.current.translation();
|
||||||
|
_charPos.set(t.x, t.y, t.z);
|
||||||
|
_offset.copy(SHOULDER_OFFSET).applyEuler(_e.set(pitchRef.current, yaw, 0, 'YXZ'));
|
||||||
|
camera.position.copy(_charPos).add(_offset);
|
||||||
|
_lookAt.set(t.x, t.y + 1, t.z);
|
||||||
|
camera.lookAt(_lookAt);
|
||||||
|
|
||||||
|
// Movement relative to character facing direction
|
||||||
|
if (!state.game?.isPaused && !editMode) {
|
||||||
const { forward, backward, left, right } = get();
|
const { forward, backward, left, right } = get();
|
||||||
|
const fwdX = -Math.sin(yaw);
|
||||||
camera.getWorldDirection(_fwd);
|
const fwdZ = -Math.cos(yaw);
|
||||||
_fwd.y = 0;
|
|
||||||
_fwd.normalize();
|
|
||||||
const fx = _fwd.x, fz = _fwd.z;
|
|
||||||
|
|
||||||
const fwdScale = (forward ? 1 : 0) - (backward ? 1 : 0);
|
const fwdScale = (forward ? 1 : 0) - (backward ? 1 : 0);
|
||||||
const rightScale = (right ? 1 : 0) - (left ? 1 : 0);
|
const rightScale = (right ? 1 : 0) - (left ? 1 : 0);
|
||||||
|
// right vector = cross(fwd, up) = (-fwdZ, 0, fwdX)
|
||||||
const vx = (fx * fwdScale - fz * rightScale) * SPEED;
|
const vx = (fwdX * fwdScale - fwdZ * rightScale) * SPEED;
|
||||||
const vz = (fz * fwdScale + fx * rightScale) * SPEED;
|
const vz = (fwdZ * fwdScale + fwdX * rightScale) * SPEED;
|
||||||
|
|
||||||
const cur = rbRef.current.linvel();
|
const cur = rbRef.current.linvel();
|
||||||
rbRef.current.setLinvel({ x: vx, y: cur.y, z: vz }, true);
|
rbRef.current.setLinvel({ x: vx, y: cur.y, z: vz }, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (<>
|
return (
|
||||||
{!editMode && <PointerLockControls />}
|
|
||||||
<SyncRigidBody
|
<SyncRigidBody
|
||||||
ref={rbRef}
|
ref={rbRef}
|
||||||
colliders="cuboid"
|
colliders="cuboid"
|
||||||
|
lockRotations
|
||||||
position={[pos[0] + 0.5, pos[1] + 0.5, pos[2] + 0.5]}
|
position={[pos[0] + 0.5, pos[1] + 0.5, pos[2] + 0.5]}
|
||||||
rotation={character.transform.look}
|
|
||||||
onSync={(data) => {
|
onSync={(data) => {
|
||||||
state.game?.setCharacterTransform(
|
// state.game?.setCharacterTransform(
|
||||||
{
|
// { position: data.position, look: character.transform.look },
|
||||||
position: data.position,
|
// data.linearVelocity,
|
||||||
look: character.transform.look,
|
// undefined,
|
||||||
},
|
// );
|
||||||
data.linearVelocity,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<group>
|
<group>
|
||||||
|
|
@ -71,5 +115,5 @@ export const CharacterView = observer(function ({ character, editMode }: Charact
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
</SyncRigidBody>
|
</SyncRigidBody>
|
||||||
</>);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,15 @@
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { SceneView } from "./SceneView";
|
import { SceneView } from "./SceneView";
|
||||||
import { state } from "../state";
|
import { state } from "../state";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Physics } from "@react-three/rapier";
|
import { Physics } from "@react-three/rapier";
|
||||||
|
|
||||||
export const GameView = observer(function () {
|
export const GameView = observer(function () {
|
||||||
const game = state.game;
|
const game = state.game;
|
||||||
const { camera } = useThree();
|
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
state.game?.tick(delta);
|
state.game?.tick(delta);
|
||||||
|
|
||||||
if (!game)
|
|
||||||
return;
|
|
||||||
const { position, look } = game.camera;
|
|
||||||
camera.position.set(position[0], position[1], position[2]);
|
|
||||||
camera.rotation.set(look[0], look[1], look[2]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!game)
|
if (!game)
|
||||||
|
|
|
||||||
|
|
@ -31,41 +31,42 @@ export const ThreeView = observer(function () {
|
||||||
const infoRef = useRef<HTMLDivElement>(null);
|
const infoRef = useRef<HTMLDivElement>(null);
|
||||||
return (
|
return (
|
||||||
<KeyboardControls map={[
|
<KeyboardControls map={[
|
||||||
{ name: 'forward', keys: ['ArrowUp', 'w', 'W'] },
|
{ name: 'forward', keys: ['ArrowUp', 'w', 'W'] },
|
||||||
{ name: 'backward', keys: ['ArrowDown', 's', 'S'] },
|
{ name: 'backward', keys: ['ArrowDown', 's', 'S'] },
|
||||||
{ name: 'left', keys: ['ArrowLeft', 'a', 'A'] },
|
{ name: 'left', keys: ['ArrowLeft', 'a', 'A'] },
|
||||||
{ name: 'right', keys: ['ArrowRight', 'd', 'D'] },
|
{ name: 'right', keys: ['ArrowRight', 'd', 'D'] },
|
||||||
|
{ name: 'jump', keys: ['Space'] },
|
||||||
]}>
|
]}>
|
||||||
<div style={{ position: 'relative', width: '800px', height: '600px', border: '1px solid #333', borderRadius: '8px', overflow: 'hidden' }}>
|
<div style={{ position: 'relative', width: '800px', height: '600px', border: '1px solid #333', borderRadius: '8px', overflow: 'hidden' }}>
|
||||||
<Canvas
|
<Canvas
|
||||||
// camera={state.world.character.camera}
|
// camera={state.world.character.camera}
|
||||||
onPointerMissed={() => state.worldEditor.resetSelectedObject()}
|
onPointerMissed={() => state.worldEditor.resetSelectedObject()}
|
||||||
>
|
>
|
||||||
<Stats />
|
<Stats />
|
||||||
<RenderInfoUpdater domRef={infoRef} />
|
<RenderInfoUpdater domRef={infoRef} />
|
||||||
{isGame ? <GameView /> : <SceneEditorView />}
|
{isGame ? <GameView /> : <SceneEditorView />}
|
||||||
</Canvas>
|
</Canvas>
|
||||||
<div ref={infoRef} style={{
|
<div ref={infoRef} style={{
|
||||||
position: 'absolute', bottom: 8, left: 8,
|
position: 'absolute', bottom: 8, left: 8,
|
||||||
color: 'white', fontSize: 11, fontFamily: 'monospace',
|
color: 'white', fontSize: 11, fontFamily: 'monospace',
|
||||||
background: 'rgba(0,0,0,0.5)', padding: '2px 6px', borderRadius: 3,
|
background: 'rgba(0,0,0,0.5)', padding: '2px 6px', borderRadius: 3,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}} />
|
}} />
|
||||||
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
|
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4 }}>
|
||||||
{
|
{
|
||||||
state.game
|
state.game
|
||||||
? <>
|
? <>
|
||||||
<button onClick={() => state.stopGame()}><IconStop /></button>
|
<button onClick={() => state.stopGame()}><IconStop /></button>
|
||||||
{
|
{
|
||||||
state.game!.isPaused
|
state.game!.isPaused
|
||||||
? <button onClick={() => state.game!.resume()}><IconPlay /></button>
|
? <button onClick={() => state.game!.resume()}><IconPlay /></button>
|
||||||
: <button onClick={() => state.game!.pause()}><IconPause /></button>
|
: <button onClick={() => state.game!.pause()}><IconPause /></button>
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
: <button onClick={() => state.startGame()}><IconPlay /></button>
|
: <button onClick={() => state.startGame()}><IconPlay /></button>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</KeyboardControls>
|
</KeyboardControls>
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue