touchscreen controls

This commit is contained in:
azykov@mail.ru 2026-06-04 14:41:20 +03:00
parent 8ef089c833
commit c80b76e06e
No known key found for this signature in database
7 changed files with 150 additions and 8 deletions

View File

@ -18,6 +18,7 @@
"mobx": "^6.15.4", "mobx": "^6.15.4",
"mobx-react-lite": "^4.1.1", "mobx-react-lite": "^4.1.1",
"mobx-utils": "^6.1.1", "mobx-utils": "^6.1.1",
"nipplejs": "^1.0.4",
"npm": "^11.16.0", "npm": "^11.16.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",

View File

@ -32,6 +32,9 @@ importers:
mobx-utils: mobx-utils:
specifier: ^6.1.1 specifier: ^6.1.1
version: 6.1.1(mobx@6.15.4) version: 6.1.1(mobx@6.15.4)
nipplejs:
specifier: ^1.0.4
version: 1.0.4
npm: npm:
specifier: ^11.16.0 specifier: ^11.16.0
version: 11.16.0 version: 11.16.0
@ -1059,6 +1062,9 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nipplejs@1.0.4:
resolution: {integrity: sha512-YtvRZDuyWQ+tOj0nUjfnZt4ZQmJVWfK9+yPmV5OXpQ4k4AkOGcSfKvSXbPNkV0ERqc4eakkBSZ7/Wyx1y6h/Sg==}
node-addon-api@7.1.1: node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
@ -2362,6 +2368,8 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
nipplejs@1.0.4: {}
node-addon-api@7.1.1: node-addon-api@7.1.1:
optional: true optional: true

View File

@ -6,13 +6,15 @@ import { useEffect, useRef } from "react";
import { Euler, Quaternion, Vector3 } from "three"; import { Euler, Quaternion, Vector3 } from "three";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei"; import { useKeyboardControls } from "@react-three/drei";
import { CuboidCollider, useRapier, useBeforePhysicsStep, type RapierRigidBody, type RapierCollider, type RapierContext } from "@react-three/rapier"; import { CuboidCollider, useRapier, useBeforePhysicsStep, type RapierRigidBody, type RapierCollider, BallCollider, CapsuleCollider, RoundCuboidCollider } from "@react-three/rapier";
import { joystickValues } from "../joystickInput";
const SPEED = 5; const SPEED = 5;
const JUMP_SPEED = 8; const JUMP_SPEED = 8;
const GRAVITY = 20; const GRAVITY = 20;
const SENSITIVITY = 0.002; const SENSITIVITY = 0.002;
const SHOULDER_OFFSET = new Vector3(0.1, 1.5, 2.5); const SHOULDER_OFFSET = new Vector3(0.1, 1.5, 2.5);
const LOOK_RATE = 2000;
// recreate private types // recreate private types
type RapierWorldCreateCharacterControllerFunction = ReturnType<typeof useRapier>['world']['createCharacterController']; type RapierWorldCreateCharacterControllerFunction = ReturnType<typeof useRapier>['world']['createCharacterController'];
@ -86,20 +88,21 @@ export const CharacterView = observer(function ({ character, editMode }: Charact
const { forward, backward, left, right, jump } = get(); const { forward, backward, left, right, jump } = get();
const fwdX = -Math.sin(yaw); const fwdX = -Math.sin(yaw);
const fwdZ = -Math.cos(yaw); const fwdZ = -Math.cos(yaw);
const fwdScale = (forward ? 1 : 0) - (backward ? 1 : 0); const fwdScale = Math.max(-1, Math.min(1, (forward ? 1 : 0) - (backward ? 1 : 0) + joystickValues.move.y));
const rightScale = (right ? 1 : 0) - (left ? 1 : 0); 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 vx = (fwdX * fwdScale - fwdZ * rightScale) * SPEED;
const vz = (fwdZ * fwdScale + fwdX * rightScale) * SPEED; const vz = (fwdZ * fwdScale + fwdX * rightScale) * SPEED;
const jumpInput = jump || joystickValues.jump;
const isGrounded = controller.computedGrounded(); const isGrounded = controller.computedGrounded();
if (jump && !jumpPressedRef.current && isGrounded) { if (jumpInput && !jumpPressedRef.current && isGrounded) {
vyRef.current = JUMP_SPEED; vyRef.current = JUMP_SPEED;
} else if (!isGrounded) { } else if (!isGrounded) {
vyRef.current -= GRAVITY * dt; vyRef.current -= GRAVITY * dt;
} else { } else {
vyRef.current = 0; vyRef.current = 0;
} }
jumpPressedRef.current = jump; jumpPressedRef.current = jumpInput;
controller.computeColliderMovement(collider, { x: vx * dt, y: vyRef.current * dt, z: vz * dt }); controller.computeColliderMovement(collider, { x: vx * dt, y: vyRef.current * dt, z: vz * dt });
const corrected = controller.computedMovement(); const corrected = controller.computedMovement();
@ -114,13 +117,16 @@ export const CharacterView = observer(function ({ character, editMode }: Charact
rb.setNextKinematicRotation({ x: _q.x, y: _q.y, z: _q.z, w: _q.w }); rb.setNextKinematicRotation({ x: _q.x, y: _q.y, z: _q.z, w: _q.w });
}); });
useFrame(({ camera }) => { useFrame(({ camera }, delta) => {
if (editMode) return; if (editMode) return;
const rb = rbRef.current; const rb = rbRef.current;
if (!rb) return; 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; yawRef.current -= mouseRef.current.x * SENSITIVITY;
pitchRef.current = Math.max(-0.4, Math.min(0.6, pitchRef.current - mouseRef.current.y * SENSITIVITY)); pitchRef.current = Math.max(-0.85, Math.min(0.5, pitchRef.current - mouseRef.current.y * SENSITIVITY));
mouseRef.current.x = 0; mouseRef.current.x = 0;
mouseRef.current.y = 0; mouseRef.current.y = 0;
@ -140,7 +146,10 @@ export const CharacterView = observer(function ({ character, editMode }: Charact
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]}
onSync={() => { }} onSync={() => { }}
> >
<CuboidCollider ref={colliderRef} args={[0.55, 0.4, 0.55]} /> {/* <BallCollider ref={colliderRef} args={[0.55]} /> */}
{/* <CapsuleCollider ref={colliderRef} args={[0.4, 0.5]} /> */}
{/* <CuboidCollider ref={colliderRef} args={[0.55, 0.4, 0.55]} /> */}
<RoundCuboidCollider ref={colliderRef} args={[0.4, 0.4, 0.4, 0.1]} />
<group> <group>
<mesh rotation={[-Math.PI / 2, -Math.PI / 4, 0]}> <mesh rotation={[-Math.PI / 2, -Math.PI / 4, 0]}>
<coneGeometry args={[0.55, 0.8, 4]} /> <coneGeometry args={[0.55, 0.8, 4]} />

View File

@ -0,0 +1,110 @@
import { useEffect, useRef } from "react";
import { create } from "nipplejs";
import { DEFAULT_JOYSTICK_VALUES, joystickValues } from "../joystickInput";
// const isTouch = navigator.maxTouchPoints > 0;
const isTouch = true; // debug
export function JoystickView() {
const moveZoneRef = useRef<HTMLDivElement>(null);
const jumpBtnRef = useRef<HTMLDivElement>(null);
const lookZoneRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isTouch)
return;
const moveZone = moveZoneRef.current;
const jumpBtn = jumpBtnRef.current;
const lookZone = lookZoneRef.current;
if (!moveZone || !jumpBtn || !lookZone)
return;
const moveJoystick = create({
zone: moveZone,
mode: 'static',
position: { left: '80px', bottom: '80px' },
color: 'white',
shape: 'square',
size: 100,
});
const lookJoystick = create({
zone: lookZone,
mode: 'dynamic',
dataOnly: true,
});
moveJoystick.on('move', (evt) => {
joystickValues.move.x = evt.data.vector.x;
joystickValues.move.y = evt.data.vector.y;
});
moveJoystick.on('end', () => {
joystickValues.move = { x: 0, y: 0 };
});
lookJoystick.on('move', (evt) => {
joystickValues.look = evt.data.vector;
});
lookJoystick.on('end', () => {
joystickValues.look = { x: 0, y: 0 };
});
const onJumpDown = () => { joystickValues.jump = true; };
const onJumpUp = () => { joystickValues.jump = false; };
jumpBtn.addEventListener('pointerdown', onJumpDown);
jumpBtn.addEventListener('pointerup', onJumpUp);
jumpBtn.addEventListener('pointerleave', onJumpUp);
return () => {
moveJoystick.destroy();
lookJoystick.destroy();
jumpBtn.removeEventListener('pointerdown', onJumpDown);
jumpBtn.removeEventListener('pointerup', onJumpUp);
jumpBtn.removeEventListener('pointerleave', onJumpUp);
Object.assign(joystickValues, DEFAULT_JOYSTICK_VALUES);
};
}, []);
if (!isTouch) return null;
return (
<>
<div
ref={moveZoneRef}
style={{ position: 'absolute', left: 0, bottom: 0, width: '50%', height: '45%', touchAction: 'none' }}
/>
<div
ref={lookZoneRef}
style={{ position: 'absolute', right: 0, bottom: 0, width: '50%', height: '45%', touchAction: 'none' }}
/>
<div
ref={jumpBtnRef}
style={{
position: 'absolute',
right: 30,
bottom: 30,
width: 80,
height: 80,
borderRadius: '50%',
background: 'rgba(255,255,255,0.25)',
border: '2px solid rgba(255,255,255,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 14,
fontWeight: 'bold',
touchAction: 'none',
userSelect: 'none',
pointerEvents: 'auto',
}}
>
JUMP
</div>
</>
);
}

View File

@ -10,6 +10,11 @@ export type PanelProps = {
export const Panel = observer(function ({ side = 'left' }: PanelProps) { export const Panel = observer(function ({ side = 'left' }: PanelProps) {
const isGame = state.game && !state.game.isPaused;
if (isGame)
return null;
function handleCloneTest1Object(): void { function handleCloneTest1Object(): void {
state.worldEditor.addObjectCloneAtRandomPosition('test1'); state.worldEditor.addObjectCloneAtRandomPosition('test1');
} }

View File

@ -24,6 +24,7 @@ import { observer } from 'mobx-react-lite';
import { state } from '../state'; import { state } from '../state';
import { GameView } from './GameView'; import { GameView } from './GameView';
import { SceneEditorView } from './SceneEditorView'; import { SceneEditorView } from './SceneEditorView';
import { JoystickView } from './JoystickView';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
const IconStop = () => <svg viewBox="0 0 14 14"><rect x="2" y="2" width="10" height="10" fill="currentColor" /></svg>; const IconStop = () => <svg viewBox="0 0 14 14"><rect x="2" y="2" width="10" height="10" fill="currentColor" /></svg>;
@ -49,6 +50,7 @@ export const ThreeView = observer(function () {
<RenderInfoUpdater /> <RenderInfoUpdater />
{isGame ? <GameView /> : <SceneEditorView />} {isGame ? <GameView /> : <SceneEditorView />}
</Canvas> </Canvas>
{isGame && <JoystickView />}
<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

7
src/joystickInput.ts Normal file
View File

@ -0,0 +1,7 @@
export const DEFAULT_JOYSTICK_VALUES = {
move: { x: 0, y: 0 },
jump: false,
look: { x: 0, y: 0 },
};
export const joystickValues = DEFAULT_JOYSTICK_VALUES;