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 |