blockly3d/src/components/CharacterView.tsx

158 lines
6.0 KiB
TypeScript

import { observer } from "mobx-react-lite";
import type { ObjectType, RuntimeGameObjectInstance } from "../types";
import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
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";
import { useRapier, useBeforePhysicsStep } from "@react-three/rapier";
import { joystickValues } from "../joystickInput";
import { state } from "../state";
const SPEED = 5;
const JUMP_SPEED = 8;
const GRAVITY = 20;
const SENSITIVITY = 0.002;
const LOOK_RATE = 2000;
type RapierWorldCreateCharacterControllerFunction = ReturnType<typeof useRapier>['world']['createCharacterController'];
type KinematicCharacterController = ReturnType<RapierWorldCreateCharacterControllerFunction>;
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 PlayerObjectViewProps = {
object: RuntimeGameObjectInstance;
objectType: ObjectType;
}
export const PlayerObjectView = observer(function ({ object, objectType }: PlayerObjectViewProps) {
const handleRef = useRef<ObjectViewInternalHandle>(null);
const [, get] = useKeyboardControls();
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 });
const jumpPressedRef = useRef(false);
const vyRef = useRef(0);
const controllerRef = useRef<KinematicCharacterController | null>(null);
useEffect(() => {
const controller = world.createCharacterController(0.01);
controller.setUp({ x: 0, y: 1, z: 0 });
controller.setMaxSlopeClimbAngle(45 * Math.PI / 180);
controller.setMinSlopeSlideAngle(30 * Math.PI / 180);
controller.enableAutostep(0.5, 0.2, true);
controller.enableSnapToGround(0.5);
controller.setApplyImpulsesToDynamicBodies(true);
controllerRef.current = controller;
return () => { world.removeCharacterController(controller); };
}, [world]);
useEffect(() => {
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]);
useBeforePhysicsStep((world) => {
const rb = handleRef.current?.rb;
if (!rb) return;
const collider = rb.collider(0);
if (!collider) return;
const controller = controllerRef.current;
if (!controller) return;
if (state.game?.isPaused) return;
const dt = world.timestep;
const yaw = yawRef.current;
const { forward, backward, left, right, jump } = get();
const fwdX = -Math.sin(yaw);
const fwdZ = -Math.cos(yaw);
const fwdScale = Math.max(-1, Math.min(1, (forward ? 1 : 0) - (backward ? 1 : 0) + joystickValues.move.y));
const rightScale = Math.max(-1, Math.min(1, (right ? 1 : 0) - (left ? 1 : 0) + joystickValues.move.x));
const vx = (fwdX * fwdScale - fwdZ * rightScale) * SPEED;
const vz = (fwdZ * fwdScale + fwdX * rightScale) * SPEED;
const jumpInput = jump || joystickValues.jump;
const isGrounded = controller.computedGrounded();
if (jumpInput && !jumpPressedRef.current && isGrounded) {
vyRef.current = JUMP_SPEED;
} else if (!isGrounded) {
vyRef.current -= GRAVITY * dt;
} else {
vyRef.current = 0;
}
jumpPressedRef.current = jumpInput;
controller.computeColliderMovement(collider, { x: vx * dt, y: vyRef.current * dt, z: vz * dt });
const corrected = controller.computedMovement();
const t = rb.translation();
rb.setNextKinematicTranslation({
x: t.x + corrected.x,
y: t.y + corrected.y,
z: t.z + corrected.z,
});
_q.setFromEuler(_e.set(0, yaw, 0));
rb.setNextKinematicRotation({ x: _q.x, y: _q.y, z: _q.z, w: _q.w });
});
useFrame(({ camera }, delta) => {
const rb = handleRef.current?.rb;
if (!rb) return;
mouseRef.current.x += joystickValues.look.x * LOOK_RATE * delta;
mouseRef.current.y += -joystickValues.look.y * LOOK_RATE * delta;
yawRef.current -= mouseRef.current.x * SENSITIVITY;
pitchRef.current = Math.max(-0.85, Math.min(0.5, pitchRef.current - mouseRef.current.y * SENSITIVITY));
mouseRef.current.x = 0;
mouseRef.current.y = 0;
const t = rb.translation();
_charPos.set(t.x, t.y, t.z);
_offset.copy(shoulderOffset).applyEuler(_e.set(pitchRef.current, yawRef.current, 0, 'YXZ'));
camera.position.copy(_charPos).add(_offset);
_lookAt.set(t.x, t.y + lookAtY, t.z);
camera.lookAt(_lookAt);
});
return (
<ObjectViewInternal
ref={handleRef}
object={object}
objectType={objectType}
isPlayer
/>
);
});