AgentSkillsCN

threejs-game

使用 Three.js 构建 3D 浏览器游戏,采用事件驱动的模块化架构。无论是全新 3D 游戏的开发,还是 3D 游戏功能的拓展,抑或是 Three.js 场景的搭建,乃至任何 Three.js 游戏项目的推进,此工具都能为您提供强大助力。

SKILL.md
--- frontmatter
name: threejs-game
description: Build 3D browser games with Three.js using event-driven modular architecture. Use when creating a new 3D game, adding 3D game features, setting up Three.js scenes, or working on any Three.js game project.

Three.js Game Development

You are an expert Three.js game developer. Follow these opinionated patterns when building 3D browser games.

Tech Stack

  • Renderer: Three.js (latest stable, ESM imports)
  • Build Tool: Vite
  • Language: TypeScript
  • Package Manager: npm

Project Setup

When scaffolding a new Three.js game:

bash
mkdir <game-name> && cd <game-name>
npm init -y
npm install three
npm install -D vite typescript @types/three

Create vite.config.ts:

typescript
import { defineConfig } from 'vite';

export default defineConfig({
  root: '.',
  publicDir: 'public',
  server: { port: 3000, open: true },
  build: { outDir: 'dist' },
});

Create tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

Add to package.json scripts:

json
{
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  }
}

Required Architecture

Every Three.js game MUST use this directory structure:

code
src/
├── core/
│   ├── Game.ts          # Main orchestrator - init systems, render loop
│   ├── EventBus.ts      # Singleton pub/sub for all module communication
│   ├── GameState.ts     # Centralized state singleton
│   └── Constants.ts     # ALL config values, balance numbers, asset paths
├── systems/             # Low-level engine systems
│   ├── InputSystem.ts   # Keyboard/mouse/gamepad input
│   ├── PhysicsSystem.ts # Collision detection
│   └── ...              # Audio, particles, etc.
├── gameplay/            # Game mechanics
│   └── ...              # Player, enemies, weapons, etc.
├── level/               # Level/world building
│   ├── LevelBuilder.ts  # Constructs the game world
│   └── AssetLoader.ts   # Loads models, textures, audio
├── ui/                  # User interface
│   └── ...              # Menus, HUD, overlays
└── main.ts              # Entry point - creates Game instance

Core Patterns (Non-Negotiable)

1. EventBus Singleton

ALL inter-module communication goes through an EventBus. Modules never import each other directly for communication.

typescript
type EventCallback = (data?: any) => void;

class EventBus {
  private listeners = new Map<string, Set<EventCallback>>();

  on(event: string, callback: EventCallback): () => void {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set());
    this.listeners.get(event)!.add(callback);
    return () => this.off(event, callback);
  }

  once(event: string, callback: EventCallback): void {
    const wrapper: EventCallback = (...args) => {
      this.off(event, wrapper);
      callback(...args);
    };
    this.on(event, wrapper);
  }

  off(event: string, callback: EventCallback): void {
    const cbs = this.listeners.get(event);
    if (cbs) {
      cbs.delete(callback);
      if (cbs.size === 0) this.listeners.delete(event);
    }
  }

  emit(event: string, data?: unknown): void {
    const cbs = this.listeners.get(event);
    if (cbs) cbs.forEach(cb => {
      try { cb(data); } catch (e) { console.error(`EventBus error [${event}]:`, e); }
    });
  }

  clear(event?: string): void {
    event ? this.listeners.delete(event) : this.listeners.clear();
  }
}

export const eventBus = new EventBus();

// Define ALL events as constants — use domain:action naming
export const Events = {
  // Group by domain: player:*, enemy:*, game:*, ui:*, etc.
} as const;

2. Centralized GameState

One singleton holds ALL game state. Systems read from it, events update it.

typescript
import { PLAYER_CONFIG } from './Constants';

interface PlayerState {
  health: number;
  score: number;
}

interface GameFlags {
  started: boolean;
  paused: boolean;
  isPlaying: boolean;
  menuState: string;
}

class GameState {
  player: PlayerState = {
    health: PLAYER_CONFIG.HEALTH,
    score: 0,
  };

  game: GameFlags = {
    started: false,
    paused: false,
    isPlaying: false,
    menuState: 'main',
  };

  reset(): void {
    this.player.health = PLAYER_CONFIG.HEALTH;
    this.player.score = 0;
    this.game.started = false;
    this.game.paused = false;
    this.game.isPlaying = false;
    this.game.menuState = 'main';
  }
}

export const gameState = new GameState();

3. Constants File

Every magic number, balance value, asset path, and configuration goes in Constants.ts. Never hardcode values in game logic.

typescript
export const PLAYER_CONFIG = {
  HEALTH: 100,
  SPEED: 5,
  JUMP_FORCE: 8,
} as const;

export const ENEMY_CONFIG = {
  SPEED: 3,
  HEALTH: 50,
  SPAWN_RATE: 2000,
} as const;

export const WORLD = {
  WIDTH: 100,
  HEIGHT: 50,
  GRAVITY: 9.8,
  FOG_DENSITY: 0.04,
} as const;

export const CAMERA = {
  FOV: 75,
  NEAR: 0.01,
  FAR: 100,
} as const;

export const COLORS = {
  AMBIENT: 0x404040,
  DIRECTIONAL: 0xffffff,
  FOG: 0x000000,
} as const;

export const ASSET_PATHS = {
  // model paths, texture paths, etc.
} as const;

4. Game.ts Orchestrator

The Game class initializes everything and runs the render loop:

typescript
import * as THREE from 'three';
import { CAMERA, COLORS, WORLD } from './Constants';

class Game {
  private scene!: THREE.Scene;
  private camera!: THREE.PerspectiveCamera;
  private renderer!: THREE.WebGLRenderer;
  private clock = new THREE.Clock();

  constructor() {
    this.init();
  }

  private init(): void {
    this.setupRenderer();
    this.setupScene();
    this.setupCamera();
    this.setupSystems();
    this.setupUI();
    this.setupEventListeners();
    this.animate();
  }

  private setupRenderer(): void {
    this.renderer = new THREE.WebGLRenderer({
      antialias: false,
      powerPreference: 'high-performance',
    });
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    document.getElementById('game-container')!.appendChild(this.renderer.domElement);
    window.addEventListener('resize', () => this.onWindowResize());
  }

  private setupScene(): void {
    this.scene = new THREE.Scene();
    this.scene.fog = new THREE.FogExp2(COLORS.FOG, WORLD.FOG_DENSITY);

    this.scene.add(new THREE.AmbientLight(COLORS.AMBIENT, 0.5));
    const dirLight = new THREE.DirectionalLight(COLORS.DIRECTIONAL, 1);
    dirLight.position.set(5, 10, 5);
    this.scene.add(dirLight);
  }

  private setupCamera(): void {
    this.camera = new THREE.PerspectiveCamera(
      CAMERA.FOV,
      window.innerWidth / window.innerHeight,
      CAMERA.NEAR,
      CAMERA.FAR,
    );
  }

  private setupSystems(): void {
    // Initialize game systems
  }

  private setupUI(): void {
    // Initialize UI overlays
  }

  private setupEventListeners(): void {
    // Subscribe to EventBus events
  }

  private onWindowResize(): void {
    this.camera.aspect = window.innerWidth / window.innerHeight;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(window.innerWidth, window.innerHeight);
  }

  private animate(): void {
    requestAnimationFrame(() => this.animate());
    const delta = Math.min(this.clock.getDelta(), 0.1); // Cap delta to prevent spiral
    // Update all systems with delta
    this.renderer.render(this.scene, this.camera);
  }
}

export default Game;

Performance Rules

  • Cap delta time: Math.min(clock.getDelta(), 0.1) to prevent death spirals
  • Object pooling: Reuse Vector3, Box3, temp objects in hot loops to minimize GC
  • Disable shadows unless specifically needed and performant
  • Use powerPreference: 'high-performance' on the renderer
  • Dispose properly: Call .dispose() on geometries, materials, textures when removing objects
  • Frustum culling: Let Three.js handle it (enabled by default) but set bounding spheres on custom geometry

Asset Loading

  • Place static assets in /public/ for Vite
  • Use GLB format for 3D models (smaller, single file)
  • Use THREE.TextureLoader, GLTFLoader from three/addons
  • Show loading progress via callbacks to UI
typescript
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const loader = new GLTFLoader();

function loadModel(path: string): Promise<THREE.Group> {
  return new Promise((resolve, reject) => {
    loader.load(
      path,
      (gltf) => resolve(gltf.scene),
      undefined,
      (error) => reject(error),
    );
  });
}

Input Handling (Mobile-First)

All games MUST work on desktop AND mobile unless explicitly specified otherwise. Allocate 60% effort to mobile / 40% desktop when making tradeoffs. Choose the best mobile input for each game concept:

Game TypePrimary Mobile InputFallback
Marble/tilt/balanceGyroscope (DeviceOrientation)Virtual joystick
Runner/endlessTap zones (left/right half)Swipe gestures
Puzzle/turn-basedTap targets (44px min)Drag & drop
Shooter/aimVirtual joystick + tap-to-fireDual joysticks
PlatformerVirtual D-pad + jump buttonTilt for movement

Unified Analog InputSystem

Use a dedicated InputSystem that merges keyboard, gyroscope, and touch into a single analog interface. Game logic reads moveX/moveZ (-1..1) and never knows the source:

typescript
class InputSystem {
  private keys: Record<string, boolean> = {};
  moveX = 0;  // -1..1
  moveZ = 0;  // -1..1
  isMobile: boolean;

  constructor() {
    this.isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
      (navigator.maxTouchPoints > 1);
    document.addEventListener('keydown', (e) => { this.keys[e.code] = true; });
    document.addEventListener('keyup', (e) => { this.keys[e.code] = false; });
  }

  /** Call from a user gesture (e.g. PLAY button) to init gyro/joystick. */
  async initMobile(): Promise<void> {
    // Request gyroscope permission (required on iOS 13+)
    // If denied/unavailable, show virtual joystick fallback
  }

  /** Call once per frame. Merges all sources into moveX/moveZ. */
  update(): void {
    let mx = 0, mz = 0;
    // Keyboard (always active, acts as override)
    if (this.keys['ArrowLeft'] || this.keys['KeyA']) mx -= 1;
    if (this.keys['ArrowRight'] || this.keys['KeyD']) mx += 1;
    if (this.keys['ArrowUp'] || this.keys['KeyW']) mz -= 1;
    if (this.keys['ArrowDown'] || this.keys['KeyS']) mz += 1;
    const kbActive = mx !== 0 || mz !== 0;
    if (!kbActive) {
      // Read from gyro or joystick (whichever is active)
    }
    this.moveX = Math.max(-1, Math.min(1, mx));
    this.moveZ = Math.max(-1, Math.min(1, mz));
  }
}

Gyroscope Input Pattern

For tilt-controlled games (marble, balance, racing):

typescript
class GyroscopeInput {
  available = false;
  moveX = 0;
  moveZ = 0;
  private calibBeta: number | null = null;
  private calibGamma: number | null = null;

  async requestPermission(): Promise<boolean> {
    // iOS 13+: DeviceOrientationEvent.requestPermission()
    // Must be called from a user gesture handler
  }

  recalibrate(): void {
    // Capture current orientation as neutral position
  }

  update(): void {
    // Apply deadzone, normalize to -1..1, smooth with EMA
  }
}

Virtual Joystick Pattern

DOM-based circle-in-circle touch joystick for non-gyro devices:

typescript
class VirtualJoystick {
  active = false;
  moveX = 0;  // -1..1
  moveZ = 0;  // -1..1

  show(): void {
    // Create outer circle + inner knob DOM elements
    // Track touch by identifier to handle multi-touch correctly
    // Clamp knob movement to maxDistance from center
    // Normalize displacement to -1..1
  }

  hide(): void { /* Remove DOM, reset values */ }
}

Input Priority

  1. On mobile: try gyroscope first (request permission from PLAY button tap)
  2. If gyro denied/unavailable: show virtual joystick
  3. Keyboard always active as fallback/override on any platform
  4. Game logic consumes only input.moveX and input.moveZ -- never knows the source

When Adding Features

  1. Create a new module in the appropriate src/ subdirectory
  2. Define new events in EventBus.ts Events enum using domain:action naming
  3. Add configuration to Constants.ts
  4. Add state to GameState.ts if needed
  5. Wire it up in Game.ts orchestrator
  6. Communicate with other systems ONLY through EventBus