๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
Graphic/R3F

66 Mini-Game: Marble Race

by curious week 2025. 8. 20.

Mini-Game: Marble Race

์™„์„ฑ:

 

Marble Race

 

66-create-a-game-with-r3f-three.vercel.app


๐ŸŽฎ ๊ฒŒ์ž„ ๊ฐœ์š”

  • ํ”Œ๋ ˆ์ด์–ด๋Š” ๊ตฌ์Šฌ(๋งˆ๋ธ”)์„ ์กฐ์ข…ํ•ด ๋‹ค์–‘ํ•œ ํŠธ๋žฉ์ด ์žˆ๋Š” ๋ ˆ๋ฒจ์„ ํ†ต๊ณผํ•ด์•ผ ํ•จ.
  • ๊ธฐ๋Šฅ
    • ํ‚ค๋ณด๋“œ๋กœ ๊ตฌ์Šฌ ์ด๋™/์ ํ”„
    • ๊ตฌ์Šฌ์ด ์›€์ง์ด๊ธฐ ์‹œ์ž‘ํ•˜๋ฉด ํƒ€์ด๋จธ ์‹œ์ž‘
    • ๋ ˆ๋ฒจ ๋์— ๋„๋‹ฌํ•˜๋ฉด Restart ๋ฒ„ํŠผ ํ‘œ์‹œ
    • Restart ์‹œ ์ดˆ๊ธฐํ™” + ์ƒˆ๋กœ์šด ๋žœ๋ค ํŠธ๋žฉ ์ƒ์„ฑ → ๋งค๋ฒˆ ๋‹ค๋ฅธ ๋ ˆ๋ฒจ

โš™๏ธ Setup

  • ๊ธฐ๋ณธ ์˜ค๋ธŒ์ ํŠธ: ์˜ค๋ Œ์ง€ ๊ตฌ์ฒด, ๋ณด๋ผ์ƒ‰ ํ๋ธŒ, ์ดˆ๋ก์ƒ‰ ๋ฐ”๋‹ฅ
  • <Lights> ์ปดํฌ๋„ŒํŠธ์— directionalLight + ambientLight ์ •์˜
  • Shadows ํ™œ์„ฑํ™”
<directionalLight 
    castShadow 
    position={[4, 4, 1]} 
    intensity={1.5} 
    shadow-mapSize={[1024, 1024]} 
    shadow-camera-near={1} 
    shadow-camera-far={10} 
    shadow-camera-top={10} 
    shadow-camera-right={10} 
    shadow-camera-bottom={-10} 
    shadow-camera-left={-10} 
/>
  • OrbitControls ์ถ”๊ฐ€ (๋””๋ฒ„๊น…์šฉ, ๋‚˜์ค‘์— ์ œ๊ฑฐ)

๋ ˆ๋ฒจ/ํŠธ๋žฉ/ํ”Œ๋ ˆ์ด์–ด ๊ตฌํ˜„


๐Ÿ—๏ธ ๋ ˆ๋ฒจ ๊ตฌ์„ฑ

Level ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ

/src/Level.jsx

// ๋ ˆ๋ฒจ ์ปดํฌ๋„ŒํŠธ
export default function Level() {
  return (
    <>
      <mesh castShadow position-x={-2}>
        <sphereGeometry />
        <meshStandardMaterial color="orange" />
      </mesh>
      <mesh castShadow position-x={2} scale={1.5}>
        <boxGeometry />
        <meshStandardMaterial color="mediumpurple" />
      </mesh>
      <mesh
        receiveShadow
        position-y={-1}
        rotation-x={-Math.PI * 0.5}
        scale={10}>
        <planeGeometry />
        <meshStandardMaterial color="greenyellow" />
      </mesh>
    </>
  );
}

→ ์ดํ›„ Experience.jsx์—์„œ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ:

import Level from './Level.jsx';

export default function Experience() {
  return (
    <>
      <OrbitControls makeDefault />
      <Lights />
      <Level />
    </>
  );
}

๐Ÿงฉ Physics ์ถ”๊ฐ€

npm install @react-three/rapier@2.0
import { Physics } from '@react-three/rapier';

export default function Experience() {
  return (
    <>
      <OrbitControls makeDefault />
      <Physics debug>
        <Lights />
        <Level />
      </Physics>
    </>
  );
}

๐ŸŸฉ ๋ธ”๋ก ์‹œ์Šคํ…œ

  • ๋ ˆ๋ฒจ์€ ์—ฌ๋Ÿฌ Block ๋‹จ์œ„๋กœ ๊ตฌ์„ฑ
  • BlockStart, BlockEnd, BlockSpinner, BlockLimbo, BlockAxe ๋“ฑ ๊ตฌํ˜„
  • ๋ธ”๋ก๋“ค์€ <group>์œผ๋กœ ๊ฐ์‹ธ์„œ ์œ„์น˜๋ฅผ position prop์œผ๋กœ ์ œ์–ด

BlockStart ์˜ˆ์‹œ

function BlockStart({ position = [0, 0, 0] }) {
  return (
    <group position={position}>
      <mesh
        geometry={boxGeometry}
        material={floor1Material}
        position={[0, -0.1, 0]}
        scale={[4, 0.2, 4]}
        receiveShadow
      />
    </group>
  );
}

๐Ÿ“ฆ Geometry & Material ์ตœ์ ํ™”

  • ๋ชจ๋“  ๋ธ”๋ก์—์„œ ๋™์ผํ•œ BoxGeometry, MeshStandardMaterial๋ฅผ ๋ฐ˜๋ณต ์ƒ์„ฑ → ์„ฑ๋Šฅ ์ €ํ•˜
  • ์™ธ๋ถ€์—์„œ ํ•œ ๋ฒˆ๋งŒ ์ƒ์„ฑ ํ›„ ์žฌ์‚ฌ์šฉ
import * as THREE from 'three';

const boxGeometry = new THREE.BoxGeometry(1, 1, 1);

// ์žฌ์‚ฌ์šฉํ•  Material๋“ค
const floor1Material = new THREE.MeshStandardMaterial({ color: 'limegreen' });
const floor2Material = new THREE.MeshStandardMaterial({ color: 'greenyellow' });
const obstacleMaterial = new THREE.MeshStandardMaterial({ color: 'orangered' });
const wallMaterial = new THREE.MeshStandardMaterial({ color: 'slategrey' });

๐Ÿ”„ Spinner Trap (ํšŒ์ „ ์žฅ์น˜)

BlockSpinner

import { RigidBody } from '@react-three/rapier';
import { useFrame } from '@react-three/fiber';
import { useState, useRef } from 'react';

export function BlockSpinner({ position = [0, 0, 0] }) {
  const obstacle = useRef();
  const [speed] = useState(
    () => (Math.random() + 0.2) * (Math.random() < 0.5 ? -1 : 1),
  );

  useFrame((state) => {
    const time = state.clock.getElapsedTime();
    const rotation = new THREE.Quaternion();
    rotation.setFromEuler(new THREE.Euler(0, time * speed, 0));
    obstacle.current.setNextKinematicRotation(rotation);
  });

  return (
    <group position={position}>
      {/* ๋ฐ”๋‹ฅ */}
      <mesh
        geometry={boxGeometry}
        material={floor2Material}
        position={[0, -0.1, 0]}
        scale={[4, 0.2, 4]}
        receiveShadow
      />

      {/* ์žฅ์• ๋ฌผ */}
      <RigidBody
        ref={obstacle}
        type="kinematicPosition"
        position={[0, 0.3, 0]}
        restitution={0.2}
        friction={0}>
        <mesh
          geometry={boxGeometry}
          material={obstacleMaterial}
          scale={[3.5, 0.3, 0.3]}
          castShadow
          receiveShadow
        />
      </RigidBody>
    </group>
  );
}
  • kinematicPosition: ๋ฌผ๋ฆฌ์—”์ง„์˜ ํž˜์ด ์•„๋‹ˆ๋ผ ์ฝ”๋“œ๋กœ ์ง์ ‘ ์›€์ง์ž„ ์ œ์–ด
  • setNextKinematicRotation ์œผ๋กœ ํšŒ์ „ ๊ตฌํ˜„
  • useState๋กœ ๋žœ๋ค ์†๋„/๋ฐฉํ–ฅ ๋ถ€์—ฌ → ๋ธ”๋ก๋งˆ๋‹ค ๋‹ค๋ฅด๊ฒŒ ๋™์ž‘

โฌ†๏ธ Limbo Trap (์ƒํ•˜ ์ด๋™ ์žฅ์น˜)

  • setNextKinematicTranslation ์‚ฌ์šฉ
  • Math.sin(time + offset) ํ™œ์šฉํ•ด ์ƒํ•˜ ๋ฐ˜๋ณต ์šด๋™ ๊ตฌํ˜„
  • timeOffset์„ ๋žœ๋ค ๋ถ€์—ฌ → ๋ธ”๋ก ๊ฐ„ ์• ๋‹ˆ๋ฉ”์ด์…˜ ๋น„๋™๊ธฐํ™”

โฌ…๏ธโžก๏ธ Axe Trap (์ขŒ์šฐ ์ด๋™ ์žฅ์น˜)

  • Limbo ๊ตฌ์กฐ์™€ ๋™์ผ, ๋‹จ์ถ• ๋ฐฉํ–ฅ๋งŒ ๋‹ค๋ฆ„
  • x์ถ•์œผ๋กœ ์ด๋™ํ•˜๋ฉฐ Math.sin(time) * 1.25 ์ ์šฉ → ์–‘์˜† ์™•๋ณต ์šด๋™

๐Ÿ” End Block

  • ์ข…๋ฃŒ ๋ธ”๋ก์— hamburger.glb ๋ชจ๋ธ ๋ฐฐ์น˜
  • GLTF ๋กœ๋”ฉ: useGLTF('./hamburger.glb')
  • ๋ฌผ๋ฆฌ ์ ์šฉ: <RigidBody type="fixed" colliders="hull" />
  • ๊ทธ๋ฆผ์ž ์ ์šฉ: hamburger.scene.children.forEach(mesh => mesh.castShadow = true)

๐Ÿงฑ Bounds (๋ฒฝ & ๋ฐ”๋‹ฅ)

  • ์–‘์ชฝ ๋ฒฝ, ๋ ๋ฒฝ, ๋ฐ”๋‹ฅ์— RigidBody type="fixed" + CuboidCollider ์ถ”๊ฐ€
  • ๋งˆ์ฐฐ๋ ฅ & ๋ฐ˜๋ฐœ๋ ฅ ์กฐ์ •
<RigidBody type="fixed" restitution={0.2} friction={0}> 
	<CuboidCollider 
    	args={[2, 0.1, 2 * length]} 
        position={[0, -0.1, -(length * 2) + 2]} 
        restitution={0.2} 
        friction={1} 
    /> 
</RigidBody>

ํ˜„์žฌ๊นŒ์ง€ ๊ตฌํ˜„๋œ ์˜ค๋ธŒ์ ํŠธ๋“ค


๐ŸŸฃ Player (๊ตฌ์Šฌ)

/src/Player.jsx

import { RigidBody } from '@react-three/rapier';

export default function Player() {
  return (
    <RigidBody
      canSleep={false}
      colliders="ball"
      restitution={0.2}
      friction={1}
      linearDamping={0.5}
      angularDamping={0.5}
      position={[0, 1, 0]}>
      <mesh castShadow>
        <icosahedronGeometry args={[0.3, 1]} />
        <meshStandardMaterial flatShading color="mediumpurple" />
      </mesh>
    </RigidBody>
  );
}
  • colliders="ball" → ํšŒ์ „ ๋ฐ ๊ตฌ๋ฅด๊ธฐ ์ ํ•ฉ
  • canSleep={false} → ๋ฌผ์ฒด๊ฐ€ ์ ˆ์ „ ๋ชจ๋“œ๋กœ ์•ˆ ๋“ค์–ด๊ฐ€๊ฒŒ ์„ค์ •
  • linearDamping, angularDamping → ๊ตฌ์Šฌ์ด ์ ์  ๋А๋ ค์ง€๋„๋ก ์ œ์–ด

์ค‘๊ฐ„ ์ •๋ฆฌ

  • Level ์‹œ์Šคํ…œ ๊ตฌ์ถ• (Start, End, Traps)
  • Physics ์ ์šฉ (RigidBody, Collider)
  • 3์ข… ํŠธ๋žฉ ๊ตฌํ˜„ (Spinner, Limbo, Axe)
  • End Block ๋ณด์ƒ (ํ–„๋ฒ„๊ฑฐ ๋ชจ๋ธ)
  • Bounds (๋ฒฝ + ๋ฐ”๋‹ฅ ์ถฉ๋Œ ์˜์—ญ)
  • Player ๊ตฌ์Šฌ ์ƒ์„ฑ (๊ตฌํ˜„ ์™„๋ฃŒ)

์นด๋ฉ”๋ผ ์ถ”์ , ์กฐ๋ช…, UI, ๊ธ€๋กœ๋ฒŒ ์ƒํƒœ ๊ด€๋ฆฌ, ๊ฒŒ์ž„ ๋ฉ”์ปค๋‹‰(ํƒ€์ด๋จธ/๋ฆฌ์…‹) ๊ตฌํ˜„


๐ŸŽฅ ์นด๋ฉ”๋ผ ์ถ”์  (Camera Controller)

ํ”Œ๋ ˆ์ด์–ด ๊ตฌ์Šฌ์„ ๋”ฐ๋ผ๋‹ค๋‹ˆ๋Š” ์นด๋ฉ”๋ผ ๊ตฌํ˜„:

import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';
import { useRapier } from '@react-three/rapier';
import * as THREE from 'three';

export default function CameraController({ player }) {
  const cameraTarget = useRef(new THREE.Vector3());

  useFrame((state) => {
    if (!player.current) return;

    // ํ”Œ๋ ˆ์ด์–ด ์œ„์น˜ ๊ฐ€์ ธ์˜ค๊ธฐ
    const playerPos = player.current.translation();

    // ๋ชฉํ‘œ ์นด๋ฉ”๋ผ ์œ„์น˜ (๋’ค์ชฝ & ์œ„์ชฝ ์˜คํ”„์…‹)
    const idealCameraPos = new THREE.Vector3(
      playerPos.x,
      playerPos.y + 2,
      playerPos.z + 5,
    );

    // Lerp๋กœ ๋ถ€๋“œ๋Ÿฝ๊ฒŒ ์ถ”์ 
    state.camera.position.lerp(idealCameraPos, 0.05);
    state.camera.lookAt(playerPos.x, playerPos.y + 0.5, playerPos.z);
  });
}
  • player.current.translation() : Rapier rigid body ์œ„์น˜
  • lerp(Linear Interpolation)์œผ๋กœ ์นด๋ฉ”๋ผ ์›€์ง์ž„์„ ๋ถ€๋“œ๋Ÿฝ๊ฒŒ
  • lookAt์„ ์‚ฌ์šฉํ•ด ํ•ญ์ƒ ๊ตฌ์Šฌ์„ ์‘์‹œ

๐Ÿ’ก ์กฐ๋ช… (Lights ์ตœ์ ํ™”)

  • ๊ฒŒ์ž„ ์ „์ฒด๊ฐ€ ๋‹ค ๋ณด์ด๋„๋ก ๊ธฐ๋ณธ ์กฐ๋ช… ๋ณด์™„
  • ๊ทธ๋ฆผ์ž ํ’ˆ์งˆ ์œ ์ง€ํ•˜๋ฉด์„œ ์„ฑ๋Šฅ๋„ ๊ณ ๋ คํ•ด์•ผ ํ•จ
export function Lights() {
  return (
    <>
      <ambientLight intensity={0.3} />
      <directionalLight
        castShadow
        position={[5, 10, 5]}
        intensity={1.5}
        shadow-mapSize={[1024, 1024]}
        shadow-camera-far={20}
        shadow-camera-left={-10}
        shadow-camera-right={10}
        shadow-camera-top={10}
        shadow-camera-bottom={-10}
      />
    </>
  );
}

๐Ÿ•น๏ธ ํ‚ค๋ณด๋“œ ์ปจํŠธ๋กค (Keyboard Controls)

Zustand Store ์ƒ์„ฑ

npm install zustand
import create from 'zustand';

export const useGameControls = create((set) => ({
  forward: false,
  backward: false,
  left: false,
  right: false,
  jump: false,
  setKey: (key, value) => set((state) => ({ ...state, [key]: value })),
}));

์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ

useEffect(() => {
  const handleKeyDown = (e) => {
    if (e.code === 'ArrowUp' || e.code === 'KeyW') setKey('forward', true);
    if (e.code === 'ArrowDown' || e.code === 'KeyS') setKey('backward', true);
    if (e.code === 'ArrowLeft' || e.code === 'KeyA') setKey('left', true);
    if (e.code === 'ArrowRight' || e.code === 'KeyD') setKey('right', true);
    if (e.code === 'Space') setKey('jump', true);
  };

  const handleKeyUp = (e) => {
    if (e.code === 'ArrowUp' || e.code === 'KeyW') setKey('forward', false);
    if (e.code === 'ArrowDown' || e.code === 'KeyS') setKey('backward', false);
    if (e.code === 'ArrowLeft' || e.code === 'KeyA') setKey('left', false);
    if (e.code === 'ArrowRight' || e.code === 'KeyD') setKey('right', false);
    if (e.code === 'Space') setKey('jump', false);
  };

  window.addEventListener('keydown', handleKeyDown);
  window.addEventListener('keyup', handleKeyUp);

  return () => {
    window.removeEventListener('keydown', handleKeyDown);
    window.removeEventListener('keyup', handleKeyUp);
  };
}, []);

โšก Player ์ด๋™ ์ œ์–ด

Player.jsx ๋‚ด์—์„œ Rapier force ์ ์šฉ:

import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { RigidBody } from '@react-three/rapier';
import { useGameControls } from './store';

/**
 * Player
 * - ์„ค๊ณ„ ์˜๋„: ๊ตฌ(๋งˆ๋ธ”) ํ˜•ํƒœ์˜ ํ”Œ๋ ˆ์ด์–ด๋ฅผ ๋ฌผ๋ฆฌ๋กœ ๊ตด๋ฆฌ๋ฉฐ ์ด๋™/์ ํ”„์‹œํ‚ค๋Š” ์ปจํŠธ๋กค๋Ÿฌ
 * - ๊ตฌ์กฐ์ƒ์˜ ์ด์œ :
 *   1) ์ด๋™์€ '์ž„ํŽ„์Šค(impulse)'๋กœ ๋ฐ€์–ด ๊ฐ„๋‹จํ•˜๊ณ  ๋ฐ˜์‘์„ฑ ์ข‹์€ ์ œ์–ด๋ฅผ ๊ตฌํ˜„
 *   2) ์ ํ”„๋Š” ํ˜„์žฌ ์„ ์†๋„(linvel)๋กœ '์ง€๋ฉด ์ ‘์ด‰์— ๊ฐ€๊นŒ์šด ์ƒํƒœ'๋ฅผ ํŒ์ •ํ•ด ์ค‘๋ณต ์ ํ”„ ๋ฐฉ์ง€
 *   3) ๋ฌผ๋ฆฌ ๋งค๊ฐœ๋ณ€์ˆ˜(๋งˆ์ฐฐ/๋ฐ˜๋ฐœ/๋Œํ•‘)๋กœ ์ž์—ฐ์Šค๋Ÿฌ์šด ๊ฐ์†๊ณผ ๋ฐ”๋‹ฅ ์ ‘์ง€๋ ฅ ํ™•๋ณด
 */
export default function Player() {
  // body: Rapier RigidBody์— ์ ‘๊ทผํ•˜๊ธฐ ์œ„ํ•œ ref
  //   - ํƒ€์ž…(๊ฐœ๋…): React.RefObject<RapierRigidBody>
  const body = useRef();

  // controls: ์ „์—ญ/๋กœ์ปฌ ์Šคํ† ์–ด์—์„œ ๊ฐ€์ ธ์˜จ ํ˜„์žฌ ์ž…๋ ฅ ์ƒํƒœ
  //   - ๊ตฌ์กฐ(์˜ˆ): { forward:boolean, backward:boolean, left:boolean, right:boolean, jump:boolean }
  const controls = useGameControls();

  /**
   * useFrame
   * - ๋งค ํ”„๋ ˆ์ž„ ํ˜ธ์ถœ๋˜์–ด ๋ฌผ๋ฆฌ ํž˜(impulse)์„ ์ ์šฉ
   * - ๋งค๊ฐœ๋ณ€์ˆ˜:
   *   - state: RenderLoop ์ƒํƒœ(์—ฌ๊ธฐ์„œ๋Š” ๋ฏธ์‚ฌ์šฉ)
   *   - delta: number — ์ด์ „ ํ”„๋ ˆ์ž„ ๋Œ€๋น„ ๊ฒฝ๊ณผ ์‹œ๊ฐ„(์ดˆ). ํ”„๋ ˆ์ž„ ๋…๋ฆฝ ์›€์ง์ž„์„ ์›ํ•˜๋ฉด ์Šค์ผ€์ผ๋ง์— ์‚ฌ์šฉ
   */
  useFrame(() => {
    if (!body.current) return;

    /**
     * impulse: { x:number, y:number, z:number }
     * - ์—ญํ• : ํ•œ ํ”„๋ ˆ์ž„ ๋™์•ˆ ๋ฌผ์ฒด์— '์ˆœ๊ฐ„์ ์ธ ์„ ํ˜• ์šด๋™๋Ÿ‰'์„ ๋ถ€์—ฌ
     * - ๋‹จ์œ„(๊ฐœ๋…): ์งˆ๋Ÿ‰ * ์†๋„
     * - ์ ์šฉ ๋ฐฉํ–ฅ:
     *    X: ์ขŒ/์šฐ, Z: ์ „/ํ›„ (three.js/R3F๋Š” ๋ณดํ†ต -Z๊ฐ€ '์•ž')
     * - ์ฐธ๊ณ : delta๋ฅผ ๊ณฑํ•ด ํ”„๋ ˆ์ž„ ๋…๋ฆฝ์  ์ œ์–ด๋ฅผ ํ•  ์ˆ˜ ์žˆ์œผ๋‚˜,
     *   ์—ฌ๊ธฐ์„  ๊ณ ์ • ๊ฐ’(0.02)์œผ๋กœ ๋ฐ˜์‘์„ฑ์„ ๋†’์ž„.
     */
    const impulse = { x: 0, y: 0, z: 0 };

    // ์ด๋™ ์ž…๋ ฅ → ์ž„ํŽ„์Šค ๋ˆ„์ 
    if (controls.forward)  impulse.z -= 0.02; // -Z ๋ฐฉํ–ฅ(์•ž)
    if (controls.backward) impulse.z += 0.02; // +Z ๋ฐฉํ–ฅ(๋’ค)
    if (controls.left)     impulse.x -= 0.02; // -X ๋ฐฉํ–ฅ(์ขŒ)
    if (controls.right)    impulse.x += 0.02; // +X ๋ฐฉํ–ฅ(์šฐ)

    /**
     * applyImpulse(impulse, wakeUp?)
     * - ๋งค๊ฐœ๋ณ€์ˆ˜:
     *   - impulse: { x,y,z }  | ํƒ€์ž…: VectorLike
     *   - wakeUp?: boolean     | true๋ฉด ์ˆ˜๋ฉด(sleep) ์ค‘์ธ ๋ฐ”๋””๋ฅผ ๊นจ์›€
     * - ์„ค๊ณ„ ์˜๋„: ํ‚ค ์ž…๋ ฅ๋งˆ๋‹ค ์งง๊ณ  ์ž‘์€ ํž˜์„ ๊ณ„์† ๋ˆ„์  → '๊ตด๋Ÿฌ๊ฐ€๋Š”' ๋А๋‚Œ
     */
    body.current.applyImpulse(impulse, true);

    /**
     * ์ ํ”„
     * - ์„ค๊ณ„ ์˜๋„: ๊ณต์ค‘์—์„œ ์—ฐํƒ€ ์ ํ”„๋ฅผ ๋ง‰๊ธฐ ์œ„ํ•ด '์ˆ˜์ง ์†๋„'๋กœ ์ ‘์ง€ ์ƒํƒœ ๊ทผ์‚ฌ
     * - linvel(): ํ˜„์žฌ ์„ ์†๋„ ๋ฒกํ„ฐ ๋ฐ˜ํ™˜ | ํƒ€์ž…: { x:number, y:number, z:number }
     * - ์ž„๊ณ„๊ฐ’ 0.05:
     *    |vel.y|๊ฐ€ ์ž‘๋‹ค๋ฉด ๋ฐ”๋‹ฅ์— ๊ฑฐ์˜ ๋ถ™์–ด ์žˆ๋Š” ์ƒํƒœ๋กœ ๊ฐ„์ฃผ(์™„๋ฒฝํ•œ ์ ‘์ง€ ํŒ์ •์€ ์•„๋‹˜)
     *   → ์‹ค์ œ๋กœ๋Š” raycast/Contact ์ด๋ฒคํŠธ๋กœ ๋” ์ •ํ™•ํžˆ ํŒ์ • ๊ฐ€๋Šฅ
     */
    if (controls.jump) {
      const vel = body.current.linvel();
      if (Math.abs(vel.y) < 0.05) {
        // applyImpulse๋กœ ์œ„์ชฝ(y+) ๋ฐฉํ–ฅ ๊ฐ€์† → ํŠ€์–ด ์˜ค๋ฆ„
        body.current.applyImpulse({ x: 0, y: 0.4, z: 0 }, true);
      }
    }
  });

  return (
    <RigidBody
      ref={body}
      /**
       * colliders="ball"
       * - ์—ญํ• : ๊ตฌํ˜• ์ฝœ๋ผ์ด๋” ์‚ฌ์šฉ(ํšŒ์ „/๊ตด๋ฆผ ์‹œ ์ ‘์ด‰์ด ์•ˆ์ •์ )
       */
      colliders="ball"
      /**
       * restitution: number
       * - ์—ญํ• : ๋ฐ˜๋ฐœ๊ณ„์ˆ˜(bounciness). 0์— ๊ฐ€๊นŒ์šธ์ˆ˜๋ก ๋œ ํŠ€๊ณ , 1์— ๊ฐ€๊นŒ์šธ์ˆ˜๋ก ์ž˜ ํА
       * - ์—ฌ๊ธฐ์„  0.2 → ์•ฝ๊ฐ„๋งŒ ํŠ€๋„๋ก
       */
      restitution={0.2}
      /**
       * friction: number
       * - ์—ญํ• : ๋งˆ์ฐฐ ๊ณ„์ˆ˜. ๊ตฌ๊ฐ€ ๋ฐ”๋‹ฅ์—์„œ '๊ตด๋Ÿฌ๊ฐ€๋ฉฐ ์ „์ง„'ํ•˜๋„๋ก ํ•„์ˆ˜
       * - ๋„ˆ๋ฌด ๋‚ฎ์œผ๋ฉด ํ—›๋Œ๊ณ , ๋„ˆ๋ฌด ๋†’์œผ๋ฉด ๊ฐ‘์ž๊ธฐ ๋ฉˆ์นซ๊ฑฐ๋ฆด ์ˆ˜ ์žˆ์Œ
       */
      friction={1}
      /**
       * linearDamping / angularDamping: number
       * - ์—ญํ• : ์„ ์†/๊ฐ์† ๊ฐ์‡ (๊ณต๊ธฐ์ €ํ•ญ ๊ฐ™์€ ๊ฐ์‡  ํšจ๊ณผ)
       * - ํ‚ค์—์„œ ์†์„ ๋–ผ๋ฉด ์„œ์„œํžˆ ๋ฉˆ์ถ”๋„๋ก 0.5๋กœ ์„ธํŒ…(์ทจํ–ฅ๊ป ์กฐ์ •)
       */
      linearDamping={0.5}
      angularDamping={0.5}
      /**
       * position: [x,y,z]
       * - ์‹œ์ž‘ ์œ„์น˜(๋ฐ”๋‹ฅ์—์„œ ์‚ด์ง ๋„์›Œ ์Šคํฐ)
       */
      position={[0, 1, 0]}
    >
      <mesh castShadow>
        {/* ๋ฐ˜์ง€์˜ค์‹ญ์ด๋ฉด์ฒด: ๊ตฌ์™€ ๋น„์Šทํ•˜์ง€๋งŒ ๋ฉด์ด ์‚ด์•„ ์žˆ์–ด ํšŒ์ „์ด ์‹œ๊ฐ์ ์œผ๋กœ ์ž˜ ๋ณด์ž„ */}
        <icosahedronGeometry args={[0.3, 1]} />
        {/**
         * meshStandardMaterial
         * - flatShading: true → ๋ฉด ๋‹จ์œ„ ์Œ์˜์œผ๋กœ ํšŒ์ „๊ฐ ๊ฐ•์กฐ
         */}
        <meshStandardMaterial flatShading color="mediumpurple" />
      </mesh>
    </RigidBody>
  );
}

โฑ๏ธ ํƒ€์ด๋จธ & UI

Zustand์— ๊ฒŒ์ž„ ์ƒํƒœ ์ถ”๊ฐ€

export const useGame = create((set) => ({
  phase: 'ready', // ready, playing, ended
  startTime: 0,
  endTime: 0,
  start: () => set({ phase: 'playing', startTime: Date.now() }),
  end: () => set({ phase: 'ended', endTime: Date.now() }),
  restart: () => set({ phase: 'ready', startTime: 0, endTime: 0 }),
}));

HUD UI

import { Html } from '@react-three/drei';
import { useGame } from './store';

export function Interface() {
  const phase = useGame((state) => state.phase);
  const startTime = useGame((state) => state.startTime);
  const endTime = useGame((state) => state.endTime);

  let elapsed = 0;
  if (phase === 'playing') elapsed = (Date.now() - startTime) / 1000;
  if (phase === 'ended') elapsed = (endTime - startTime) / 1000;

  return (
    <Html position={[0, 2, 0]} center>
      <div className="hud">
        {phase !== 'ready' && <h1>{elapsed.toFixed(2)}s</h1>}
        {phase === 'ended' && (
          <button onClick={() => useGame.getState().restart()}>Restart</button>
        )}
      </div>
    </Html>
  );
}

๐Ÿ”„ Restart ๊ธฐ๋Šฅ

  • Restart ์‹œ:
    • Player ์œ„์น˜ ์ดˆ๊ธฐํ™”
    • ํƒ€์ด๋จธ ์ดˆ๊ธฐํ™”
    • ๋ ˆ๋ฒจ ์žฌ๋žœ๋ค ์ƒ์„ฑ
function RestartButton() {
  const restart = useGame((state) => state.restart);
  return (
    <button onClick={restart} className="restart-btn">
      Restart
    </button>
  );
}

๐ŸŽ‰ ์ตœ์ข… ๊ตฌ์กฐ

src/
 โ”œโ”€โ”€ Experience.jsx
 โ”œโ”€โ”€ Level.jsx
 โ”œโ”€โ”€ Player.jsx
 โ”œโ”€โ”€ CameraController.jsx
 โ”œโ”€โ”€ Lights.jsx
 โ”œโ”€โ”€ store.js   (zustand ์ƒํƒœ ๊ด€๋ฆฌ)
 โ”œโ”€โ”€ Interface.jsx
 โ””โ”€โ”€ assets/
      โ””โ”€โ”€ hamburger.glb

์ •๋ฆฌ

  • ์นด๋ฉ”๋ผ ์ถ”์  : ํ”Œ๋ ˆ์ด์–ด๋ฅผ ๋”ฐ๋ผ์˜ค๋Š” ๋ถ€๋“œ๋Ÿฌ์šด ์นด๋ฉ”๋ผ
  • ์กฐ๋ช… ์ตœ์ ํ™” : ์„ฑ๋Šฅ ๊ณ ๋ คํ•œ ๊ทธ๋ฆผ์ž ์„ธํŒ…
  • Zustand ์ƒํƒœ ๊ด€๋ฆฌ : ํ‚ค๋ณด๋“œ ์ž…๋ ฅ & ๊ฒŒ์ž„ ์ƒํƒœ ๊ด€๋ฆฌ
  • ํ”Œ๋ ˆ์ด์–ด ์ด๋™ ์ œ์–ด : Rapier impulse๋ฅผ ์ด์šฉํ•œ ์ด๋™/์ ํ”„
  • UI & ํƒ€์ด๋จธ : ๊ฒŒ์ž„ ์‹œ๊ฐ„ ํ‘œ์‹œ, Restart ๋ฒ„ํŠผ
  • Restart ๋ฉ”์ปค๋‹‰ : ๋ ˆ๋ฒจ ๋ฆฌ์…‹ + ์ƒˆ๋กœ์šด ํŠธ๋žฉ ๋ฐฐ์น˜

// index.jsx
import './style.css';
import ReactDOM from 'react-dom/client';
import { Canvas } from '@react-three/fiber';
import Experience from './Experience.jsx';
import { KeyboardControls } from '@react-three/drei';
import Interface from './Interface.jsx';

const root = ReactDOM.createRoot(document.querySelector('#root'));

root.render(
  <KeyboardControls
    map={[
      {
        name: 'forward',
        keys: ['ArrowUp', 'KeyW'],
      },
      {
        name: 'backward',
        keys: ['ArrowDown', 'KeyS'],
      },
      {
        name: 'leftward',
        keys: ['ArrowLeft', 'KeyA'],
      },
      {
        name: 'rightward',
        keys: ['ArrowRight', 'KeyD'],
      },
      {
        name: 'jump',
        keys: ['Space'],
      },
    ]}>
    <Canvas
      shadows
      camera={{
        fov: 45,
        near: 0.1,
        far: 200,
        position: [2.5, 4, 6],
      }}>
      <Experience />
    </Canvas>
    <Interface />
  </KeyboardControls>,
);
// Experience.jsx
import { OrbitControls } from '@react-three/drei';
import Lights from './Lights.jsx';
import { Level } from './Level.jsx';
import { Physics } from '@react-three/rapier';
import { Perf } from 'r3f-perf';
import Player from './Player.jsx';
import useGame from './stores/useGame.js';

export default function Experience() {
  const blocksCount = useGame((state) => state.blocksCount);
  const blockSeed = useGame((state) => state.blockSeed);

  return (
    <>
      <color args={['#bdedfc']} attach="background" />
      <Perf position="top-left" />
      <OrbitControls makeDefault />

      <Physics debug={false}>
        <Lights />
        <Level count={blocksCount} seed={blockSeed} />
        <Player />
      </Physics>
    </>
  );
}
// Light.jsx
import { useFrame } from '@react-three/fiber';
import { useRef } from 'react';

export default function Lights() {
  const light = useRef();

  // ์นด๋ฉ”๋ผ๊ฐ€ ์•ž์œผ๋กœ ์ด๋™ํ•  ๋•Œ(ํ˜„์žฌ Z์ถ•) ๋น›๋„ ํ•จ๊ป˜ ์ด๋™์‹œ์ผœ ๊ทธ๋ฆผ์ž ๋งต์ด ํ”Œ๋ ˆ์ด์–ด ์ฃผ๋ณ€์„ ๋ฎ๋„๋ก.
  useFrame((state) => {
    light.current.position.z = state.camera.position.z + 1 - 4;
    light.current.target.position.z = state.camera.position.z - 4;
    light.current.target.updateMatrixWorld();
  });

  return (
    <>
      <directionalLight
        ref={light}
        castShadow
        position={[4, 4, 1]}
        intensity={4.5}
        shadow-mapSize={[1024, 1024]}
        shadow-camera-near={1}
        shadow-camera-far={10}
        shadow-camera-top={10}
        shadow-camera-right={10}
        shadow-camera-bottom={-10}
        shadow-camera-left={-10}
      />
      <ambientLight intensity={1.5} />
    </>
  );
}
// Level.jsx
import * as THREE from 'three';
import { CuboidCollider, RigidBody } from '@react-three/rapier';
import { useState, useRef, useMemo } from 'react';
import { useFrame } from '@react-three/fiber';
import { Float, Text, useGLTF } from '@react-three/drei';

const boxGeometry = new THREE.BoxGeometry(1, 1, 1);
const floorMaterial = new THREE.MeshStandardMaterial({ color: 'limegreen' });
const floor2Material = new THREE.MeshStandardMaterial({ color: 'greenyellow' });
const obstacleMaterial = new THREE.MeshStandardMaterial({ color: 'orangered' });
const wallMaterial = new THREE.MeshStandardMaterial({ color: 'slategrey' });

function BlockStart({ position = [0, 0, 0] }) {
  return (
    <group position={position}>
      <Float floatIntensity={0.25} rotationIntensity={0.25}>
        <Text
          font="./bebas-neue-v9-latin-regular.woff"
          scale={0.5}
          maxWidth={0.25}
          lineHeight={0.75}
          textAlign="right"
          position={[0.75, 0.65, 0]}
          rotation-y={-0.25}>
          Marble Race
          <meshBasicMaterial toneMapped={false} />
        </Text>
      </Float>

      {/* Floor */}
      <mesh
        geometry={boxGeometry}
        material={floorMaterial}
        position={[0, -0.1, 0]}
        scale={[4, 0.2, 4]}
        receiveShadow
      />
    </group>
  );
}
function BlockEnd({ position = [0, 0, 0] }) {
  const hamburger = useGLTF('./hamburger.glb');

  // burger shadow on
  hamburger.scene.children.forEach((mesh) => {
    mesh.castShadow = true;
  });
  return (
    <group position={position}>
      <Text
        font="./bebas-neue-v9-latin-regular.woff"
        scale={1}
        position={[0, 2.25, 2]}>
        Finish
        <meshBasicMaterial toneMapped={false} />
      </Text>
      {/* Floor */}
      <mesh
        geometry={boxGeometry}
        material={floorMaterial}
        position={[0, 0, 0]}
        scale={[4, 0.2, 4]}
        receiveShadow
      />
      <RigidBody
        type="fixed"
        colliders="hull"
        position={[0, 0.25, 0]}
        restitution={0.2}
        castShadow
        friction={0}>
        <primitive castShadow object={hamburger.scene} scale={0.2} />
      </RigidBody>
    </group>
  );
}

export function BlockSpinner({ position = [0, 0, 0] }) {
  const obstacle = useRef();
  const [speed] = useState(
    // ์ตœ์†Œ ํšŒ์ „ ์†๋„ 0.2 * ํšŒ์ „ ๋ฐฉํ–ฅ ์„ค์ •
    () => (Math.random() + 0.2) * (Math.random() < 0.5 ? -1 : 1),
  );

  useFrame((state) => {
    const time = state.clock.elapsedTime;

    const rotation = new THREE.Quaternion();
    rotation.setFromEuler(new THREE.Euler(0, time * speed, 0));
    obstacle.current.setNextKinematicRotation(rotation);
  });

  return (
    <group position={position}>
      {/* Floor */}
      <mesh
        geometry={boxGeometry}
        material={floor2Material}
        position={[0, -0.1, 0]}
        scale={[4, 0.2, 4]}
        receiveShadow
      />

      {/* obstacle */}
      <RigidBody
        ref={obstacle}
        type="kinematicPosition"
        position={[0, 0.3, 0]}
        restitution={0.2}
        friction={0}>
        <mesh
          geometry={boxGeometry}
          material={obstacleMaterial}
          scale={[3.5, 0.3, 0.3]}
          castShadow
          receiveShadow
        />
      </RigidBody>
    </group>
  );
}
export function BlockLimbo({ position = [0, 0, 0] }) {
  const obstacle = useRef();
  const [timeOffset] = useState(() => Math.random() * Math.PI * 2);

  useFrame((state) => {
    const time = state.clock.elapsedTime;

    const y = Math.sin(time * timeOffset) + 1.15;
    obstacle.current.setNextKinematicTranslation({
      x: position[0],
      y: position[1] + y,
      z: position[2],
    });
  });

  return (
    <group position={position}>
      {/* Floor */}
      <mesh
        geometry={boxGeometry}
        material={floor2Material}
        position={[0, -0.1, 0]}
        scale={[4, 0.2, 4]}
        receiveShadow
      />

      <RigidBody
        ref={obstacle}
        type="kinematicPosition"
        position={[0, 0.3, 0]}
        restitution={0.2}
        friction={0}>
        <mesh
          geometry={boxGeometry}
          material={obstacleMaterial}
          scale={[3.5, 0.3, 0.3]}
          castShadow
          receiveShadow
        />
      </RigidBody>
    </group>
  );
}
export function BlockAxe({ position = [0, 0, 0] }) {
  const obstacle = useRef();
  const [timeOffset] = useState(() => Math.random() * Math.PI * 2);

  useFrame((state) => {
    const time = state.clock.elapsedTime;

    const x = Math.sin(time * timeOffset) * 1.25;
    obstacle.current.setNextKinematicTranslation({
      x: position[0] + x,
      y: position[1] + 0.75,
      z: position[2],
    });
  });

  return (
    <group position={position}>
      {/* Floor */}
      <mesh
        geometry={boxGeometry}
        material={floor2Material}
        position={[0, -0.1, 0]}
        scale={[4, 0.2, 4]}
        receiveShadow
      />

      <RigidBody
        ref={obstacle}
        type="kinematicPosition"
        position={[0, 0.3, 0]}
        restitution={0.2}
        friction={0}>
        <mesh
          geometry={boxGeometry}
          material={obstacleMaterial}
          scale={[1.5, 1.5, 0.3]}
          castShadow
          receiveShadow
        />
      </RigidBody>
    </group>
  );
}

// Walls
function Bounds({ length = 1 }) {
  return (
    <RigidBody type="fixed" restitution={0.2} friction={0}>
      {/* Right wall */}
      <mesh
        position={[2.15, 0.75, -(length * 2) + 2]}
        geometry={boxGeometry}
        material={wallMaterial}
        scale={[0.3, 1.5, 4 * length]}
        castShadow
      />
      {/* Left wall */}
      <mesh
        position={[-2.15, 0.75, -(length * 2) + 2]}
        geometry={boxGeometry}
        material={wallMaterial}
        scale={[0.3, 1.5, 4 * length]}
        receiveShadow
      />
      {/* End wall */}
      <mesh
        position={[0, 0.75, -(length * 4) + 2]}
        geometry={boxGeometry}
        material={wallMaterial}
        scale={[4, 1.5, 0.3]}
        receiveShadow
      />
      {/* Floor collider */}
      <CuboidCollider
        args={[2, 0.1, 2 * length]}
        position={[0, -0.1, -(length * 2) + 2]}
        restitution={0.2}
        friction={1} // friction์ด ์žˆ์–ด์•ผ ๊ตด๋Ÿฌ๊ฐ.
      />
    </RigidBody>
  );
}

export function Level({
  count = 5,
  types = [BlockSpinner, BlockAxe, BlockLimbo],
  seed = 0,
}) {
  const blocks = useMemo(() => {
    const blocks = [];

    for (let i = 0; i < count; i++) {
      const type = types[Math.floor(Math.random() * types.length)];
      blocks.push(type);
    }

    return blocks;
  }, [count, types, seed]);

  return (
    <>
      <BlockStart position={[0, 0, 0]} />

      {blocks.map((Block, index) => (
        <Block key={index} position={[0, 0, -(index + 1) * 4]} />
      ))}
      <Bounds length={count + 2} />

      <BlockEnd position={[0, 0, -(count + 1) * 4]} />
    </>
  );
}
// Player.jsx
import { useRapier, RigidBody } from '@react-three/rapier';
import { useFrame } from '@react-three/fiber';
import { useKeyboardControls } from '@react-three/drei';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import useGame from './stores/useGame';

export default function Player() {
  const body = useRef();
  // index.js KeyboardControls์™€ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์Œ.
  const [subscribeKeys, getKeys] = useKeyboardControls();
  const { rapier, world } = useRapier();

  const [smoothedCameraPosition] = useState(
    // camera position ์ดˆ๊ธฐ๊ฐ’ ์„ค์ •์œผ๋กœ player๊นŒ์ง€ ๊ฐ€๋Š” ์• ๋‹ˆ๋ฉ”์ด์…˜ ์„ค์ •
    () => new THREE.Vector3(10, 10, 10),
  );
  const [smoothedCameraTarget] = useState(() => new THREE.Vector3());
  const start = useGame((state) => state.start);
  const end = useGame((state) => state.end);
  const restart = useGame((state) => state.restart);
  const blocksCount = useGame((state) => state.blocksCount);

  /**
   * Jump
   */
  const jump = useCallback(() => {
    if (!body.current) return;

    // 1) ์›์ : ๊ตฌ์˜ ์•„๋ž˜์ชฝ ๊ฐ€์žฅ์ž๋ฆฌ๋ณด๋‹ค ์‚ด์ง ๋” ์•„๋ž˜
    // RigidBody์˜ ์›”๋“œ ์œ„์น˜๋ฅผ {x:number, y:number, z:number}๋กœ ๋ฐ˜ํ™˜.
    const origin = body.current.translation();
    // ๊ตฌ(๋ฐ˜์ง€๋ฆ„ 0.3)์˜ ์•„๋ž˜์ชฝ ๊ฐ€์žฅ์ž๋ฆฌ๋ณด๋‹ค ์‚ด์ง ๋” ์•„๋ž˜์—์„œ ๋ ˆ์ด๋ฅผ ์˜๋ ค๊ณ  0.31๋งŒํผ ๋‚ด๋ฆผ
    origin.y -= 0.31; // radius 0.3 + epsilon

    // 2) ์•„๋ž˜ ๋ฐฉํ–ฅ(-y๋กœ ,๋‹จ์œ„ ๋ฒกํ„ฐ)
    const direction = { x: 0, y: -1, z: 0 };

    // 3) Ray ์ƒ์„ฑ + ์บ์ŠคํŒ…
    const ray = new rapier.Ray(origin, direction); // ๊ตฌ์˜ -0.31์ง€์ ์—์„œ, ์•„๋ž˜ ๋ฐฉํ–ฅ์œผ๋กœ
    // ray: ์œ„์—์„œ ๋งŒ๋“  ๋ ˆ์ด
    // maxToi: ์ตœ๋Œ€ ์ถฉ๋Œ ๊ธธ์ด(์›”๋“œ ์œ ๋‹›) — ์—ฌ๊ธฐ์„  10
    // solid: true๋ฉด ์ฝœ๋ผ์ด๋” ๋‚ด๋ถ€์—์„œ ์‹œ์ž‘ํ•ด๋„ ์ฆ‰์‹œ ๊ต์ฐจ๋กœ ๊ฐ„์ฃผ(= “๊ณ ์ฒด์ฒ˜๋Ÿผ” ์ทจ๊ธ‰)
    const hit = world.castRay(ray, 10, true); // maxToi=10, solid=true

    // 4) ๋ฒ„์ „๋ณ„ ํ•„๋“œ ํ˜ธํ™˜, hit.toi(์ด์ „ ๋ฒ„์ „) == hit.TimeOfImpact(ํžˆํŠธ ์ง€์ ๊นŒ์ง€์˜ ๊ฑฐ๋ฆฌ = t * ๊ฑฐ๋ฆฌ)
    const toi = hit ? hit.timeOfImpact ?? hit.toi : null;

    // 5) ์ ‘์ง€ ์ž„๊ณ„ ํŒ์ • ํ›„ ์ ํ”„
    if (toi !== null && toi < 0.15) {
      body.current.applyImpulse({ x: 0, y: 0.5, z: 0 }, true);
    }
  }, [world, rapier]);

  const reset = () => {
    body.current.setTranslation({ x: 0, y: 1, z: 0 });
    body.current.setLinvel({ x: 0, y: 0, z: 0 });
    body.current.setAngvel({ x: 0, y: 0, z: 0 });
  };

  useEffect(() => {
    const unsubscribeReset = useGame.subscribe(
      (state) => {
        return state.phase;
      },
      (value) => {
        if (value === 'ready') reset();
      },
    );

    const unsubscribeJump = subscribeKeys(
      // state: const state = getKeys();์™€ ๊ฐ™์€ ๊ฐ’์„ ๋ฆฌํ„ด
      (state) => state.jump, // selector
      // jump boolean ๊ฐ’์„ return
      (pressed) => {
        if (pressed) jump();
      }, // listener
    );

    // phase
    const unsubscribeAny = subscribeKeys(() => {
      start();
    });

    // cleanup: ๋งˆ์šดํŠธ ํ•ด์ œ/๋ฆฌ๋ Œ๋” ์‹œ ์•ˆ์ „
    return () => {
      unsubscribeReset();
      unsubscribeJump();
      unsubscribeAny();
    };
  }, [subscribeKeys, jump]);

  useFrame((state, delta) => {
    /**
     * Controls
     */
    const { forward, backward, leftward, rightward } = getKeys();

    const impulse = { x: 0, y: 0, z: 0 };
    const torque = { x: 0, y: 0, z: 0 };

    const impulseStrength = 0.6 * delta;
    const torqueStrength = 0.2 * delta;

    if (forward) {
      impulse.z -= impulseStrength; // ์ง์ ‘ ๋ฐ€๋ฉด์„œ -Z๋กœ ์ด๋™
      torque.x -= torqueStrength; // X์ถ•์œผ๋กœ ํšŒ์ „ ํ† ํฌ = -Z๋กœ ๊ตด๋Ÿฌ๊ฐ€๊ฒŒ๋”(๋ถ€ํ˜ธ ๋งž์ถ˜ ๊ฒƒ)
    }
    if (backward) {
      impulse.z += impulseStrength;
      torque.x += torqueStrength;
    }
    if (rightward) {
      impulse.x += impulseStrength;
      torque.z -= torqueStrength;
    }
    if (leftward) {
      impulse.x -= impulseStrength;
      torque.z += torqueStrength;
    }

    body.current.applyImpulse(impulse);
    // ํšŒ์ „ TorqueImpulse ์ดํ•ดํ•˜๊ธฐ
    // ๋ฐ”ํ€ด(๋˜๋Š” ๊ตฌ์ฒด)์˜ ์ „์ง„ ๋ฐฉํ–ฅ์€ ํ•ญ์ƒ ํšŒ์ „์ถ•๊ณผ ์ง๊ตํ•œ๋‹ค.
    // R3F(์œ„=Y) ๊ธฐ์ค€์œผ๋กœ X์ถ•์œผ๋กœ ํšŒ์ „์‹œํ‚ค๋ฉด Z์ถ• ๋ฐฉํ–ฅ(๋ณดํ†ต −Z)์œผ๋กœ ๊ตด๋Ÿฌ ์ „์ง„ํ•œ๋‹ค.
    // ๋ฐ”๋‹ฅ๊ณผ์˜ ๋งˆ์ฐฐ์ด ์žˆ์–ด์•ผ ํšŒ์ „์ด ๋ณ‘์ง„์œผ๋กœ ๋ฐ”๋€Œ๋ฉฐ, ๋งˆ์ฐฐ์ด ์—†์œผ๋ฉด ์ œ์ž๋ฆฌ์—์„œ ํ—›๋ˆ๋‹ค.
    // Blender์—์„œ X์ถ• ํšŒ์ „(R → X) ์„ ๋– ์˜ฌ๋ฆฌ๋ฉด ์ดํ•ด๊ฐ€ ์‰ฝ๋‹ค(์ขŒํ‘œ๊ณ„ ์ฐจ์ด๋Š” ๊ฐ์•ˆ).
    // ์ž๋™์ฐจ์˜ ๊ฒฝ์šฐ ๋‘ ๋ฐ”ํ€ด๋ฅผ ์ž‡๋Š” ์ฐจ์ถ•(X์ถ•) ์ด ํšŒ์ „์ถ•์ด๊ณ , ์ฐจ๋Ÿ‰์€ ๊ทธ ์ถ•์— ์ง๊ตํ•˜๋Š” ์ง„ํ–‰์ถ•(์•ž/๋’ค)์œผ๋กœ ๋‚˜์•„๊ฐ„๋‹ค.
    body.current.applyTorqueImpulse(torque);

    /**
     * Camera
     */
    const bodyPosition = body.current.translation();

    // new THREE.Vector3()์— ๊ฐ’์„ ๋ณต์‚ฌํ•ด ๋‘๋Š” ๊ฒŒ ์•ˆ์ „
    // const [cameraPosition] = useState(() => new THREE.Vector3());๋กœ ๋งŒ๋“ค์–ด์„œ ์žฌ์‚ฌ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•๋„ ์žˆ์Œ.
    const cameraPosition = new THREE.Vector3();
    cameraPosition.copy(bodyPosition); // copy ๋Œ€์‹  set๋„ ๊ฐ€๋Šฅ
    cameraPosition.z += 2.25;
    cameraPosition.y += 0.65;

    const cameraTarget = new THREE.Vector3();
    cameraTarget.copy(bodyPosition);
    cameraTarget.y += 0.25;

    // Vector3.lerp(v: THREE.Vector3Like, alpha: number)
    // Linear Interpolation(lerp): ์ด์ „ ๊ฐ’์„ ๋‹ด์•„์„œ ๋‘ vector3 ์‚ฌ์ด์— ์ค‘๊ฐ„์ ์„ ์ฐพ์Œ.
    smoothedCameraPosition.lerp(cameraPosition, 5 * delta); // ๋น„๊ต vec3 , alpha
    smoothedCameraTarget.lerp(cameraTarget, 5 * delta);

    // ์นด๋ฉ”๋ผ ํฌ์ง€์…˜ ์ง€์ •๊ณผ ์นด๋ฉ”๋ผ ๋ฐฉํ–ฅ ์ง€์ •
    state.camera.position.copy(smoothedCameraPosition); // ์นด๋ฉ”๋ผ world ์œ„์น˜ ๋ณ€๊ฒฝ
    state.camera.lookAt(smoothedCameraTarget); // ํšŒ์ „ ๋ณ€๊ฒฝ

    /**
     * Phases
     */
    if (bodyPosition.z < -(blocksCount * 4 + 2)) {
      end();
    }
    if (bodyPosition.y < -4) {
      restart();
    }
  });

  return (
    <RigidBody
      ref={body}
      canSleep={false} // ์ž ๋“ค๋ฉด ํ‚ค๋ณด๋“œ๊ฐ€ ๋™์ž‘ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ sleep false ์„ค์ •
      colliders="ball"
      restitution={0.2}
      friction={1}
      position={[0, 1, 0]}
      linearDamping={0.5} // ์„ ์† ๊ฐ์‡  (ํ‚ค์—์„œ ์†์„ ๋–ผ๋ฉด ์„œ์„œํžˆ ๋ฉˆ์ถ”๋„๋ก 0.5๋กœ ์„ธํŒ…)
      angularDamping={0.5} // ๊ฐ์†๋„ ๊ฐ์‡ 
    >
      <mesh castShadow>
        <icosahedronGeometry args={[0.3, 1]} />
        <meshStandardMaterial
          flatShading // segment ๋ณด์ด๋„๋ก
          color="mediumpurple"
        />
      </mesh>
    </RigidBody>
  );
}
// interface.jsx
import { useKeyboardControls } from '@react-three/drei';
import useGame from './stores/useGame';
import { useEffect, useRef } from 'react';
import { addEffect } from '@react-three/fiber';

export default function Interface() {
  const time = useRef();

  const restart = useGame((state) => state.restart);
  const phase = useGame((state) => state.phase);

  const forward = useKeyboardControls((state) => state.forward);
  const backward = useKeyboardControls((state) => state.backward);
  const leftward = useKeyboardControls((state) => state.leftward);
  const rightward = useKeyboardControls((state) => state.rightward);
  const jump = useKeyboardControls((state) => state.jump);

  useEffect(() => {
    // addEffect: canvas ๋ฐ–์—์„œ๋„ useFrame ๋‹ค์Œ ํ˜ธ์ถœ๋จ.
    const unsubscribeEffect = addEffect(() => {
      const state = useGame.getState();

      let elapsedTime = 0;

      if (state.phase === 'playing') elapsedTime = Date.now() - state.startTime;
      else if (state.phase === 'ended')
        elapsedTime = state.endTime - state.startTime;

      elapsedTime /= 1000;
      elapsedTime = elapsedTime.toFixed(2);

      if (time.current) time.current.textContent = elapsedTime;
    });

    return () => {
      unsubscribeEffect();
    };
  }, []);

  return (
    <div className="interface">
      {/* Time */}
      <div ref={time} className="time">
        0.00
      </div>

      {/* Restart */}
      {phase === 'ended' && (
        <div className="restart" onClick={restart}>
          RESTART
        </div>
      )}

      {/* Controls */}
      <div className="controls">
        <div className="raw">
          <div className={`key ${forward ? 'active' : ''}`}></div>
        </div>
        <div className="raw">
          <div className={`key ${leftward ? 'active' : ''}`}></div>
          <div className={`key ${backward ? 'active' : ''}`}></div>
          <div className={`key ${rightward ? 'active' : ''}`}></div>
        </div>
        <div className="raw">
          <div className={`key large ${jump ? 'active' : ''}`}></div>
        </div>
      </div>
    </div>
  );
}

js:

// useGame.js
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

export default create(
  subscribeWithSelector((set) => {
    return {
      blocksCount: 10,
      blockSeed: 0,

      // Time
      startTime: 0,
      endTime: 0,

      // Phases
      phase: 'ready',

      start: () => {
        set((state) => {
          if (state.phase === 'ready')
            return { phase: 'playing', startTime: Date.now() };

          return {};
        });
      },
      restart: () => {
        set((state) => {
          if (state.phase === 'playing' || state.phase === 'ended')
            return { phase: 'ready', blockSeed: Math.random() };

          return {};
        });
      },
      end: () => {
        set((state) => {
          if (state.phase === 'playing')
            return { phase: 'ended', endTime: Date.now() };

          return {};
        });
      },
    };
  }),
);

css:

html,
body,
#root {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: ivory;
}

@font-face {
  font-family: 'Bebas Neue'; /* A name you choose for your font */
  src: url('./bebas-neue-v9-latin-regular.woff') format('woff');
  font-weight: normal; /* Define font weight if applicable */
  font-style: normal; /* Define font style if applicable */
  font-display: swap; /* Optional: Controls font loading behavior */
}

/* Interface */
.interface {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  font-family: 'Bebas Neue', sans-serif;
}

/* Time */
.time {
  position: absolute;
  top: 15%;
  left: 0;
  width: 100%;
  color: #fff;
  font-size: 6vh;
  background-color: #00000033;
  padding-top: 5px;
  text-align: center;
}
/* Restart */
.restart {
  display: flex;
  justify-content: center;
  position: absolute;
  top: 40%;
  left: 0;
  width: 100%;
  color: #fff;
  font-size: 80px;
  background: #00000033;
  padding-top: 10px;
  pointer-events: auto;
  cursor: pointer;
}

/* Controls */
.controls {
  position: absolute;
  bottom: 10%;
  left: 0;
  width: 100%;
}

.controls .raw {
  display: flex;
  justify-content: center;
}

.controls .key {
  width: 40px;
  height: 40px;
  margin: 4px;
  border: 2px solid #ffffff;
  background: #ffffff44;
}

.controls .key.large {
  width: 144px;
}

.controls .key.active {
  background: #ffffff99;
}

 

'Graphic > R3F' ์นดํ…Œ๊ณ ๋ฆฌ์˜ ๋‹ค๋ฅธ ๊ธ€

65 React Three Rapier  (13) 2025.08.19
64 Laptop Scene  (1) 2025.08.18
63 ํ›„์ฒ˜๋ฆฌ(Post Processing)  (5) 2025.08.18
62 Pointer Events (Mouse Events)  (7) 2025.08.18
61 Portal Scene  (0) 2025.08.18