Codex로 Three.js 웹게임 만들기

Three.js 웹게임은 거대한 전투 시스템보다 화면에 장면을 그리고 캐릭터 하나를 움직이는 것부터 시작하는 편이 좋습니다.

이 가이드에서는 Codex와 함께 다음 결과물을 만듭니다.

  • Vite와 TypeScript 기반 프로젝트
  • 반응형 Three.js Canvas
  • 원근 카메라, 바닥, 조명과 그림자
  • 단순한 Mesh로 만든 플레이어와 목표물
  • WASD와 방향키 이동
  • 프레임 속도에 영향을 덜 받는 delta time 이동
  • 이동 범위 제한과 현재 좌표 표시
  • 프로덕션 빌드 검증

공격, 적 AI, 충돌, 체력은 일부러 넣지 않습니다. 먼저 Scene → Update → Render 흐름을 이해한 뒤 기능을 한 단계씩 확장합니다.


1. 준비 사항

다음 도구가 필요합니다.

  • 현재 Vite가 지원하는 Node.js 버전
  • npm
  • 코드를 편집할 수 있는 에디터
  • Codex를 사용할 수 있는 앱, IDE 확장 또는 CLI

설치된 Node.js와 npm 버전을 확인합니다.

node --version
npm --version

Vite의 Node.js 요구 버전은 바뀔 수 있습니다. 프로젝트 생성 명령에서 버전 경고가 나오면 Vite 공식 시작 가이드의 현재 요구 사항을 확인하고 Node.js를 업데이트하세요.

Codex에 첫 요청 보내기

Codex는 목표만 던지는 것보다 목표, 현재 맥락, 지켜야 할 제약, 완료 조건을 함께 알려줄 때 결과를 검토하기 쉽습니다.

목표: Vite + TypeScript + Three.js로 초급 3D 웹게임 프로젝트를 만들고 싶어.

맥락: 아직 프로젝트를 생성하지 않았고 Node.js와 npm은 설치되어 있어.

제약:
- 프레임워크 없이 vanilla TypeScript를 사용해줘.
- 처음에는 외부 3D 모델과 물리 엔진을 사용하지 말아줘.
- 기능을 한 번에 많이 추가하지 말아줘.

완료 조건:
- 로컬 개발 서버에서 3D 장면이 표시되어야 해.
- npm run build가 통과해야 해.

먼저 작업 순서와 확인 지점을 계획해줘.

복잡하거나 범위가 애매한 작업은 바로 코드를 요청하기보다 계획부터 요청하면 누락을 줄일 수 있습니다.


2. Vite 프로젝트 만들기

터미널에서 다음 명령을 실행합니다.

npm create vite@latest threejs-webgame -- --template vanilla-ts
cd threejs-webgame
npm install
npm install three
npm install --save-dev @types/three
npm run dev

브라우저에서 Vite가 출력한 Local 주소를 열어 기본 화면이 표시되는지 확인합니다.

이번 가이드에서 사용할 핵심 구조는 다음과 같습니다.

threejs-webgame/
  index.html
  package.json
  src/
    main.ts
    style.css

기본 템플릿의 로고 파일이나 카운터 코드는 사용하지 않으므로 삭제해도 됩니다.

이 단계에서 Codex에 확인시킬 내용

현재 폴더가 vanilla TypeScript Vite 프로젝트인지 확인해줘.
package.json과 src 구조를 읽고 Three.js 설치 여부를 점검해줘.
아직 파일은 수정하지 말고, 제거해도 되는 기본 템플릿 파일과 다음 구현 순서만 알려줘.

이처럼 먼저 읽기와 설명만 요청하면 Codex가 엉뚱한 폴더나 다른 프레임워크를 기준으로 작업하는 문제를 줄일 수 있습니다.


3. HTML 구성하기

index.html 전체를 다음 내용으로 바꿉니다.

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Three.js 키보드 이동 기초 예제" />
    <title>Three.js 기초 장면</title>
  </head>
  <body>
    <div id="app">
      <canvas id="game-canvas" aria-label="Three.js 3D 기초 장면"></canvas>
      <aside class="hud" aria-live="polite">
        <strong>Three.js Playground</strong>
        <span>WASD 또는 방향키로 이동</span>
        <span id="player-position">X 0.0 · Z 0.0</span>
      </aside>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Canvas는 Three.js가 3D 장면을 그리는 표면입니다. HUD는 WebGL 장면과 분리된 일반 HTML이므로 안내 문구나 상태를 표시하기 쉽습니다.


4. 화면 스타일 만들기

src/style.css 전체를 다음 내용으로 바꿉니다.

:root {
  color: #f8fafc;
  background: #07111f;
  font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

html,
body,
#app {
  width: 100%;
  height: 100%;
  margin: 0;
}

body {
  overflow: hidden;
}

#app {
  position: relative;
}

#game-canvas {
  width: 100%;
  height: 100%;
  display: block;
  touch-action: none;
}

.hud {
  position: absolute;
  top: 16px;
  left: 16px;
  display: grid;
  gap: 6px;
  min-width: 210px;
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 10px;
  padding: 12px 14px;
  background: rgba(7, 17, 31, 0.78);
  box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28);
  backdrop-filter: blur(8px);
  pointer-events: none;
}

.hud strong {
  color: #fff2a8;
}

.hud span {
  color: rgba(255, 255, 255, 0.78);
  font-size: 13px;
}

@media (max-width: 480px) {
  .hud {
    top: 10px;
    left: 10px;
    right: 10px;
    min-width: 0;
  }
}

Canvas 크기는 CSS가 결정합니다. TypeScript에서는 Canvas의 표시 크기를 확인하고 WebGL drawing buffer가 달라졌을 때만 갱신합니다.


5. Three.js 장면과 이동 구현하기

src/main.ts 전체를 다음 내용으로 바꿉니다.

import * as THREE from 'three';
import './style.css';

const requiredElement = <T extends Element>(selector: string) => {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`필수 요소를 찾을 수 없습니다: ${selector}`);
  return element;
};

const canvas = requiredElement<HTMLCanvasElement>('#game-canvas');
const positionLabel = requiredElement<HTMLElement>('#player-position');

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x8dc7e8);

const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
camera.position.set(0, 9, 11);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({
  canvas,
  antialias: true
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

const ambientLight = new THREE.AmbientLight(0xffffff, 1.1);
scene.add(ambientLight);

const sunLight = new THREE.DirectionalLight(0xffffff, 2.2);
sunLight.position.set(6, 10, 4);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(1024, 1024);
sunLight.shadow.camera.left = -10;
sunLight.shadow.camera.right = 10;
sunLight.shadow.camera.top = 10;
sunLight.shadow.camera.bottom = -10;
scene.add(sunLight);

const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(18, 18),
  new THREE.MeshStandardMaterial({
    color: 0x4d9f68,
    roughness: 0.9
  })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

const grid = new THREE.GridHelper(18, 18, 0xe8f6ff, 0x477759);
grid.position.y = 0.01;
const gridMaterial = grid.material as THREE.Material;
gridMaterial.transparent = true;
gridMaterial.opacity = 0.26;
scene.add(grid);

const player = new THREE.Group();

const playerBody = new THREE.Mesh(
  new THREE.CylinderGeometry(0.48, 0.62, 1.15, 20),
  new THREE.MeshStandardMaterial({ color: 0x49b8ff, roughness: 0.55 })
);
playerBody.position.y = 0.58;
playerBody.castShadow = true;
player.add(playerBody);

const playerHead = new THREE.Mesh(
  new THREE.SphereGeometry(0.38, 20, 16),
  new THREE.MeshStandardMaterial({ color: 0xdff5ff, roughness: 0.45 })
);
playerHead.position.y = 1.42;
playerHead.castShadow = true;
player.add(playerHead);
scene.add(player);

const beacon = new THREE.Mesh(
  new THREE.IcosahedronGeometry(0.65, 1),
  new THREE.MeshStandardMaterial({
    color: 0xffd45c,
    emissive: 0x7a4800,
    emissiveIntensity: 0.65,
    roughness: 0.35
  })
);
beacon.position.set(3, 1.1, -2);
beacon.castShadow = true;
scene.add(beacon);

const pressedKeys = new Set<string>();
const movement = new THREE.Vector3();
const playerSpeed = 5;
const arenaLimit = 8.1;
const movementKeys = new Set([
  'w',
  'a',
  's',
  'd',
  'arrowup',
  'arrowdown',
  'arrowleft',
  'arrowright'
]);

const onKeyDown = (event: KeyboardEvent) => {
  const key = event.key.toLowerCase();
  if (!movementKeys.has(key)) return;
  event.preventDefault();
  pressedKeys.add(key);
};

const onKeyUp = (event: KeyboardEvent) => {
  const key = event.key.toLowerCase();
  if (!movementKeys.has(key)) return;
  event.preventDefault();
  pressedKeys.delete(key);
};

const onWindowBlur = () => pressedKeys.clear();

window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onWindowBlur);

const updatePlayer = (deltaSeconds: number) => {
  movement.set(0, 0, 0);

  if (pressedKeys.has('w') || pressedKeys.has('arrowup')) movement.z -= 1;
  if (pressedKeys.has('s') || pressedKeys.has('arrowdown')) movement.z += 1;
  if (pressedKeys.has('a') || pressedKeys.has('arrowleft')) movement.x -= 1;
  if (pressedKeys.has('d') || pressedKeys.has('arrowright')) movement.x += 1;

  if (movement.lengthSq() > 0) {
    movement.normalize();
    player.position.addScaledVector(movement, playerSpeed * deltaSeconds);
    player.rotation.y = Math.atan2(movement.x, movement.z);
  }

  player.position.x = THREE.MathUtils.clamp(player.position.x, -arenaLimit, arenaLimit);
  player.position.z = THREE.MathUtils.clamp(player.position.z, -arenaLimit, arenaLimit);
  positionLabel.textContent = `X ${player.position.x.toFixed(1)} · Z ${player.position.z.toFixed(1)}`;
};

const resizeRendererToDisplaySize = () => {
  const pixelRatio = Math.min(window.devicePixelRatio, 2);
  let width = Math.max(1, Math.floor(canvas.clientWidth * pixelRatio));
  let height = Math.max(1, Math.floor(canvas.clientHeight * pixelRatio));
  const maxPixelCount = 1920 * 1080;
  const pixelCount = width * height;

  if (pixelCount > maxPixelCount) {
    const renderScale = Math.sqrt(maxPixelCount / pixelCount);
    width = Math.floor(width * renderScale);
    height = Math.floor(height * renderScale);
  }

  if (canvas.width === width && canvas.height === height) return false;
  renderer.setSize(width, height, false);
  return true;
};

let previousTime = performance.now();

renderer.setAnimationLoop((currentTime) => {
  const deltaSeconds = Math.min((currentTime - previousTime) / 1000, 0.05);
  previousTime = currentTime;

  if (resizeRendererToDisplaySize()) {
    camera.aspect = canvas.clientWidth / Math.max(1, canvas.clientHeight);
    camera.updateProjectionMatrix();
  }

  updatePlayer(deltaSeconds);
  beacon.rotation.x += deltaSeconds * 0.7;
  beacon.rotation.y += deltaSeconds * 1.1;
  beacon.position.y = 1.1 + Math.sin(currentTime * 0.002) * 0.18;

  renderer.render(scene, camera);
});

const cleanup = () => {
  renderer.setAnimationLoop(null);
  window.removeEventListener('keydown', onKeyDown);
  window.removeEventListener('keyup', onKeyUp);
  window.removeEventListener('blur', onWindowBlur);

  [ground, playerBody, playerHead, beacon].forEach((mesh) => {
    mesh.geometry.dispose();
    const material = mesh.material;
    if (Array.isArray(material)) material.forEach((item) => item.dispose());
    else material.dispose();
  });
  grid.geometry.dispose();
  gridMaterial.dispose();
  renderer.dispose();
};

window.addEventListener('pagehide', cleanup, { once: true });

저장하면 초록색 바닥, 파란색 플레이어와 회전하는 노란 목표물이 표시됩니다. WASD 또는 방향키로 플레이어를 움직일 수 있습니다.

이 구현에서 중요한 흐름

  • Scene은 카메라, 조명과 Mesh가 들어가는 3D 공간입니다.
  • PerspectiveCamera는 원근감이 있는 화면을 만듭니다.
  • WebGLRenderer는 Scene을 Camera 관점에서 Canvas에 그립니다.
  • 입력 이벤트는 현재 눌린 키만 기록합니다.
  • 실제 위치 변경은 렌더 루프의 updatePlayer에서 수행합니다.
  • 이동량에 delta time을 곱해 프레임 속도가 달라도 비슷한 속도를 유지합니다.
  • 대각선 이동 벡터를 정규화해 대각선에서 더 빨라지지 않게 합니다.
  • Canvas 크기가 달라질 때 renderer와 camera projection을 함께 갱신합니다.
  • 탭을 닫거나 이동할 때 이벤트와 GPU 리소스를 정리합니다.

6. Codex로 단계별 개선하기

완성 코드를 한 번에 다시 작성해 달라고 하기보다, 현재 상태를 읽고 작은 변경을 검증하게 하는 편이 안전합니다.

장면 초기화 검토 요청

목표: src/main.ts의 Three.js 초기화가 초급 예제로 안전한지 검토해줘.

확인할 내용:
- Scene, Camera, Renderer 연결
- 조명과 그림자 설정
- Canvas 반응형 처리
- 페이지 종료 시 리소스 정리

제약:
- 외부 라이브러리를 추가하지 말아줘.
- 게임 기능은 추가하지 말아줘.
- 문제를 발견하면 먼저 원인을 설명하고 수정 범위를 알려줘.

완료 조건:
- npm run build가 통과해야 해.
- 화면 비율 변경 시 장면이 찌그러지지 않아야 해.

입력 처리 검토 요청

WASD와 방향키 이동 코드를 검토해줘.
대각선 속도, 키를 누른 채 창 포커스를 잃는 경우, 프레임 속도 차이와 브라우저 스크롤을 확인해줘.
이동 동작을 바꾸지 않는 범위에서만 수정하고 npm run build로 검증해줘.

작업 완료 검토 요청

이번 변경 전체를 최종 검토해줘.

완료 조건:
- TypeScript 오류가 없어야 해.
- 개발 서버와 프로덕션 빌드에서 장면이 표시되어야 해.
- WASD와 방향키가 모두 동작해야 해.
- 창 크기를 바꿔도 장면 비율이 유지되어야 해.
- 불필요한 의존성과 사용하지 않는 코드가 없어야 해.

관련 검사를 실행하고, 변경 요약과 남은 위험을 알려줘.

Codex가 코드를 생성했더라도 diff를 읽고 직접 실행해 확인해야 합니다. 3D 장면의 카메라 위치, 조명 밝기와 움직임 감각은 빌드 성공만으로 검증할 수 없습니다.


7. 반복할 규칙은 AGENTS.md에 기록하기

같은 프로젝트를 계속 작업한다면 루트에 AGENTS.md를 두고 프로젝트 규칙과 검증 명령을 기록할 수 있습니다.

# Project Notes

- Vite + TypeScript + Three.js 프로젝트다.
- 사용자 화면에 보이는 문자열은 하드코딩 전에 다국어 적용 여부를 확인한다.
- 입력 업데이트와 렌더링 코드를 분리한다.
- 새 Geometry, Material, Texture를 만들면 정리 경로도 함께 구현한다.
- 기능 변경 후 npm run build를 실행한다.
- 작업 범위를 벗어난 파일은 수정하지 않는다.

AGENTS.md에는 추상적인 구호보다 실제 폴더 구조, 실행 명령, 지켜야 할 제약과 완료 기준을 적는 편이 유용합니다. 일회성 요청은 현재 대화에 남기고, 반복해서 지켜야 할 규칙만 저장하세요.


8. 실행과 빌드 확인

개발 서버를 실행합니다.

npm run dev

다음 항목을 직접 확인합니다.

  • 바닥, 플레이어, 목표물이 보인다.
  • 목표물이 회전하고 위아래로 움직인다.
  • WASD와 방향키로 이동할 수 있다.
  • 대각선 이동이 직선 이동보다 빨라지지 않는다.
  • 경기장 밖으로 이동하지 않는다.
  • 창 크기를 변경해도 화면이 찌그러지지 않는다.
  • 모바일 크기에서 HUD가 화면 밖으로 나가지 않는다.

그 다음 프로덕션 빌드를 확인합니다.

npm run build
npm run preview

npm run dev가 정상이어도 TypeScript 오류나 빌드 환경 차이 때문에 npm run build가 실패할 수 있습니다. 기능 단위를 마칠 때마다 빌드하는 습관을 들이면 오류가 커지기 전에 발견할 수 있습니다.


9. 자주 발생하는 문제

화면이 완전히 검다

  • 브라우저 개발자 도구 Console의 오류를 먼저 확인합니다.
  • #game-canvas 선택자가 HTML과 TypeScript에서 같은지 확인합니다.
  • Camera가 오브젝트를 향하는지 확인합니다.
  • Mesh가 Scene에 추가되었는지 확인합니다.
  • 조명을 사용하는 Material이라면 Light가 있는지 확인합니다.

오브젝트가 납작하거나 길게 보인다

Camera의 aspect와 Canvas의 표시 비율이 다를 가능성이 큽니다. Canvas 크기 변경 후 camera.updateProjectionMatrix()까지 호출했는지 확인합니다.

고해상도 화면에서 너무 느리다

기기 픽셀 비율을 그대로 사용하면 모바일에서 렌더링해야 하는 픽셀 수가 크게 늘어날 수 있습니다. 예제는 pixel ratio를 최대 2로 제한하고 drawing buffer 전체 픽셀 수도 제한합니다. 게임 규모에 맞게 더 낮은 제한을 사용할 수 있습니다.

키에서 손을 뗐는데 계속 움직인다

창이 포커스를 잃으면 keyup을 받지 못할 수 있습니다. blur 이벤트에서 눌린 키 Set을 비우는지 확인합니다.

TypeScript가 three 타입을 찾지 못한다

의존성이 설치되었는지 확인합니다.

npm install three
npm install --save-dev @types/three

node_modules가 손상되었거나 설치가 중단되었다면 의존성을 다시 설치한 뒤 빌드합니다.


10. 다음 단계

기초 장면이 안정적으로 동작하면 다음 순서로 한 기능씩 확장해 보세요.

  1. 플레이어가 바라보는 방향에 간단한 공격 Mesh 표시
  2. 일정 거리에서 플레이어를 따라오는 적 추가
  3. Box3 또는 거리 계산으로 단순 충돌 판정
  4. 체력, 피해 피드백과 게임오버 UI
  5. GLTFLoader를 이용한 GLB 캐릭터 불러오기
  6. 애니메이션과 사운드 연결
  7. 공간 분할 또는 오브젝트 풀링으로 성능 개선
  8. 필요한 경우 물리 엔진 도입

한 단계가 끝날 때마다 다음을 반복합니다.

작은 목표 선택
  ↓
Codex에 현재 코드와 완료 조건 전달
  ↓
변경 diff 검토
  ↓
브라우저에서 직접 확인
  ↓
npm run build
  ↓
다음 기능으로 이동

처음부터 완성된 게임 구조를 만들려고 하지 말고, 플레이 가능한 작은 장면을 계속 검증하며 확장하는 것이 중요합니다.


참고 자료