third person view for character

This commit is contained in:
azykov@mail.ru 2026-06-03 22:13:16 +03:00
parent 8daa62ad8e
commit da67932827
No known key found for this signature in database
3 changed files with 110 additions and 72 deletions

View File

@ -2,14 +2,22 @@ import { observer } from "mobx-react-lite";
import type { Character } from "../types";
import { SyncRigidBody } from "./SyncRigidBody";
import { state } from "../state";
import { useRef } from "react";
import { Vector3 } from "three";
import { useFrame } from "@react-three/fiber";
import { PointerLockControls, useKeyboardControls } from "@react-three/drei";
import { useEffect, useRef } from "react";
import { Euler, Quaternion, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei";
import type { RapierRigidBody } from "@react-three/rapier";
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 = {
character: Character;
@ -18,50 +26,86 @@ type CharacterViewProps = {
export const CharacterView = observer(function ({ character, editMode }: CharacterViewProps) {
const pos = character.transform.position;
const rbRef = useRef<RapierRigidBody>(null);
const [, get] = useKeyboardControls();
const { gl } = useThree();
useFrame(({ camera }) => {
if (state.game?.isPaused || !rbRef.current)
const yawRef = useRef(0);
const pitchRef = useRef(0);
const mouseRef = useRef({ x: 0, y: 0 });
useEffect(() => {
if (editMode)
return;
if (!editMode) {
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 }) => {
if (editMode)
return;
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();
camera.getWorldDirection(_fwd);
_fwd.y = 0;
_fwd.normalize();
const fx = _fwd.x, fz = _fwd.z;
const fwdX = -Math.sin(yaw);
const fwdZ = -Math.cos(yaw);
const fwdScale = (forward ? 1 : 0) - (backward ? 1 : 0);
const rightScale = (right ? 1 : 0) - (left ? 1 : 0);
const vx = (fx * fwdScale - fz * rightScale) * SPEED;
const vz = (fz * fwdScale + fx * rightScale) * SPEED;
// right vector = cross(fwd, up) = (-fwdZ, 0, fwdX)
const vx = (fwdX * fwdScale - fwdZ * rightScale) * SPEED;
const vz = (fwdZ * fwdScale + fwdX * rightScale) * SPEED;
const cur = rbRef.current.linvel();
rbRef.current.setLinvel({ x: vx, y: cur.y, z: vz }, true);
}
});
return (<>
{!editMode && <PointerLockControls />}
return (
<SyncRigidBody
ref={rbRef}
colliders="cuboid"
lockRotations
position={[pos[0] + 0.5, pos[1] + 0.5, pos[2] + 0.5]}
rotation={character.transform.look}
onSync={(data) => {
state.game?.setCharacterTransform(
{
position: data.position,
look: character.transform.look,
},
data.linearVelocity,
undefined,
);
// state.game?.setCharacterTransform(
// { position: data.position, look: character.transform.look },
// data.linearVelocity,
// undefined,
// );
}}
>
<group>
@ -71,5 +115,5 @@ export const CharacterView = observer(function ({ character, editMode }: Charact
</mesh>
</group>
</SyncRigidBody>
</>);
);
});

View File

@ -1,22 +1,15 @@
import { observer } from "mobx-react-lite";
import { SceneView } from "./SceneView";
import { state } from "../state";
import { useFrame, useThree } from "@react-three/fiber";
import { useFrame } from "@react-three/fiber";
import { Suspense } from "react";
import { Physics } from "@react-three/rapier";
export const GameView = observer(function () {
const game = state.game;
const { camera } = useThree();
useFrame((_, 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)

View File

@ -35,6 +35,7 @@ export const ThreeView = observer(function () {
{ name: 'backward', keys: ['ArrowDown', 's', 'S'] },
{ name: 'left', keys: ['ArrowLeft', 'a', 'A'] },
{ 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' }}>
<Canvas