threejs-game

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Three.js Game Development

Three.js 游戏开发

You are an expert Three.js game developer. Follow these opinionated patterns when building 3D browser games.
Reference: See
reference/llms.txt
(quick guide) and
reference/llms-full.txt
(full API + TSL) for official Three.js LLM documentation. Prefer patterns from those files when they conflict with this skill.
你是一名Three.js游戏开发专家。在构建3D浏览器游戏时,请遵循以下既定模式。
参考资料:查看
reference/llms.txt
(快速指南)和
reference/llms-full.txt
(完整API + TSL)获取官方Three.js LLM文档。当本技能中的内容与这些文件冲突时,优先采用文件中的模式。

Tech Stack

技术栈

  • Renderer: Three.js (
    three@0.183.0+
    , ESM imports)
  • Build Tool: Vite
  • Language: JavaScript (not TypeScript) for game templates — TypeScript optional
  • Package Manager: npm
  • 渲染器:Three.js(
    three@0.183.0+
    ,ESM导入)
  • 构建工具:Vite
  • 编程语言:游戏模板使用JavaScript(非TypeScript)——TypeScript为可选
  • 包管理器:npm

Project Setup

项目搭建

When scaffolding a new Three.js game:
bash
mkdir <game-name> && cd <game-name>
npm init -y
npm install three@^0.183.0
npm install -D vite
Create
vite.config.js
:
js
import { defineConfig } from 'vite';

export default defineConfig({
  root: '.',
  publicDir: 'public',
  server: { port: 3000, open: true },
  build: { outDir: 'dist' },
});
Add to
package.json
scripts:
json
{
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}
搭建新的Three.js游戏项目时:
bash
mkdir <game-name> && cd <game-name>
npm init -y
npm install three@^0.183.0
npm install -D vite
创建
vite.config.js
:
js
import { defineConfig } from 'vite';

export default defineConfig({
  root: '.',
  publicDir: 'public',
  server: { port: 3000, open: true },
  build: { outDir: 'dist' },
});
在
package.json
的scripts中添加:
json
{
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}

Modern Import Patterns

现代导入模式

Vite / npm (default — used in our templates)

Vite / npm(默认——我们的模板中使用)

js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

Import Maps / CDN (standalone HTML games, no build step)

导入映射 / CDN(独立HTML游戏,无需构建步骤)

html
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
  }
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
</script>
Use import maps when shipping a single HTML file with no build tooling. Pin the version in the import map URL.
html
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
  }
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
</script>
当发布单个HTML文件且无需构建工具时,使用导入映射。在导入映射URL中固定版本号。

Required Architecture

必备架构

Every Three.js game MUST use this directory structure:
src/
├── core/
│   ├── Game.js          # Main orchestrator - init systems, render loop
│   ├── EventBus.js      # Singleton pub/sub for all module communication
│   ├── GameState.js     # Centralized state singleton
│   └── Constants.js     # ALL config values, balance numbers, asset paths
├── systems/             # Low-level engine systems
│   ├── InputSystem.js   # Keyboard/mouse/gamepad input
│   ├── PhysicsSystem.js # Collision detection
│   └── ...              # Audio, particles, etc.
├── gameplay/            # Game mechanics
│   └── ...              # Player, enemies, weapons, etc.
├── level/               # Level/world building
│   ├── LevelBuilder.js  # Constructs the game world
│   └── AssetLoader.js   # Loads models, textures, audio
├── ui/                  # User interface
│   └── ...              # Game over, overlays
└── main.js              # Entry point - creates Game instance
每个Three.js游戏必须使用以下目录结构:
src/
├── core/
│   ├── Game.js          # 主协调器 - 初始化系统、渲染循环
│   ├── EventBus.js      # 单例发布/订阅,用于所有模块通信
│   ├── GameState.js     # 集中式状态单例
│   └── Constants.js     # 所有配置值、平衡数值、资源路径
├── systems/             # 底层引擎系统
│   ├── InputSystem.js   # 键盘/鼠标/游戏手柄输入
│   ├── PhysicsSystem.js # 碰撞检测
│   └── ...              # 音频、粒子系统等
├── gameplay/            # 游戏机制
│   └── ...              # 玩家、敌人、武器等
├── level/               # 关卡/世界构建
│   ├── LevelBuilder.js  # 构建游戏世界
│   └── AssetLoader.js   # 加载模型、纹理、音频
├── ui/                  # 用户界面
│   └── ...              # 游戏结束界面、覆盖层等
└── main.js              # 入口文件 - 创建Game实例

Core Principles

核心原则

  1. Core loop first — Implement one camera, one scene, one gameplay loop. Add player input and a terminal condition (win/lose) before adding visual polish. Keep initial scope small: 1 mechanic, 1 fail condition, 1 scoring system.
  2. Gameplay clarity > visual complexity — Treat 3D as a style choice, not a complexity mandate. A readable game with simple materials beats a visually complex but confusing one.
  3. Restart-safe — Gameplay must be fully restart-safe.
    GameState.reset()
    must restore a clean slate. Dispose geometries/materials/textures on cleanup. No stale references or leaked listeners across restarts.
  1. 优先实现核心循环——先实现一个相机、一个场景、一个游戏循环。在添加视觉美化之前,先添加玩家输入和终端条件(胜利/失败)。初始范围要小:1种机制、1种失败条件、1种计分系统。
  2. 游戏玩法清晰度 > 视觉复杂度——将3D视为一种风格选择,而非复杂度要求。一个玩法清晰、材质简单的游戏,胜过视觉复杂但令人困惑的游戏。
  3. 支持安全重启——游戏玩法必须完全支持安全重启。
    GameState.reset()
    必须恢复到干净状态。清理时释放几何体/材质/纹理资源。重启过程中不能有陈旧引用或泄漏的监听器。

Core Patterns (Non-Negotiable)

核心模式(必须遵守)

1. EventBus Singleton

1. EventBus 单例

ALL inter-module communication goes through an EventBus. Modules never import each other directly for communication.
js
class EventBus {
  constructor() {
    this.listeners = new Map();
  }

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

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

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

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

  clear(event) {
    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.
};
所有模块间通信必须通过EventBus进行。模块之间绝不能直接导入对方来进行通信。
js
class EventBus {
  constructor() {
    this.listeners = new Map();
  }

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

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

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

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

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

export const eventBus = new EventBus();

// 定义所有事件为常量 —— 使用 领域:操作 命名方式
export const Events = {
  // 按领域分组:player:*, enemy:*, game:*, ui:*, 等
};

2. Centralized GameState

2. 集中式GameState

One singleton holds ALL game state. Systems read from it, events update it.
js
import { PLAYER_CONFIG } from './Constants.js';

class GameState {
  constructor() {
    this.player = {
      health: PLAYER_CONFIG.HEALTH,
      score: 0,
    };
    this.game = {
      started: false,
      paused: false,
      isPlaying: false,
    };
  }

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

export const gameState = new GameState();
单个单例持有所有游戏状态。系统从其中读取数据,事件更新其中的数据。
js
import { PLAYER_CONFIG } from './Constants.js';

class GameState {
  constructor() {
    this.player = {
      health: PLAYER_CONFIG.HEALTH,
      score: 0,
    };
    this.game = {
      started: false,
      paused: false,
      isPlaying: false,
    };
  }

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

export const gameState = new GameState();

3. Constants File

3. 常量文件

Every magic number, balance value, asset path, and configuration goes in
Constants.js
. Never hardcode values in game logic.
js
export const PLAYER_CONFIG = {
  HEALTH: 100,
  SPEED: 5,
  JUMP_FORCE: 8,
};

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

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

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

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

export const ASSET_PATHS = {
  // model paths, texture paths, etc.
};
所有魔法数值、平衡值、资源路径和配置都要放在
Constants.js
中。绝不要在游戏逻辑中硬编码数值。
js
export const PLAYER_CONFIG = {
  HEALTH: 100,
  SPEED: 5,
  JUMP_FORCE: 8,
};

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

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

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

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

export const ASSET_PATHS = {
  // 模型路径、纹理路径等
};

4. Game.js Orchestrator

4. Game.js 协调器

The Game class initializes everything and runs the render loop. Uses
renderer.setAnimationLoop()
— the official Three.js pattern (handles WebGPU async correctly and pauses when the tab is hidden):
js
import * as THREE from 'three';
import { CAMERA, COLORS, WORLD } from './Constants.js';

class Game {
  constructor() {
    this.clock = new THREE.Clock();
    this.init();
  }

  init() {
    this.setupRenderer();
    this.setupScene();
    this.setupCamera();
    this.setupSystems();
    this.setupUI();
    this.setupEventListeners();
    this.renderer.setAnimationLoop(() => this.animate());
  }

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

  setupScene() {
    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);
  }

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

  setupSystems() {
    // Initialize game systems
  }

  setupUI() {
    // Initialize UI overlays
  }

  setupEventListeners() {
    // Subscribe to EventBus events
  }

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

  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;
Game类初始化所有内容并运行渲染循环。使用
renderer.setAnimationLoop()
——这是Three.js的官方模式(能正确处理WebGPU异步,且在标签页隐藏时暂停):
js
import * as THREE from 'three';
import { CAMERA, COLORS, WORLD } from './Constants.js';

class Game {
  constructor() {
    this.clock = new THREE.Clock();
    this.init();
  }

  init() {
    this.setupRenderer();
    this.setupScene();
    this.setupCamera();
    this.setupSystems();
    this.setupUI();
    this.setupEventListeners();
    this.renderer.setAnimationLoop(() => this.animate());
  }

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

  setupScene() {
    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);
  }

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

  setupSystems() {
    // 初始化游戏系统
  }

  setupUI() {
    // 初始化UI覆盖层
  }

  setupEventListeners() {
    // 订阅EventBus事件
  }

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

  animate() {
    const delta = Math.min(this.clock.getDelta(), 0.1); // 限制delta值以防止恶性循环
    // 使用delta值更新所有系统
    this.renderer.render(this.scene, this.camera);
  }
}

export default Game;

Renderer Selection

渲染器选择

WebGLRenderer (default — use for all game templates)

WebGLRenderer(默认——所有游戏模板中使用)

Maximum browser compatibility. Well-established, most examples and tutorials use this. Our templates default to WebGLRenderer.
js
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
浏览器兼容性最强。技术成熟,大多数示例和教程都使用它。我们的模板默认使用WebGLRenderer。
js
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });

WebGPURenderer (when you need TSL or compute shaders)

WebGPURenderer(当你需要TSL或计算着色器时)

Required for custom node-based materials (TSL), compute shaders, and advanced rendering. Note: import path changes to
'three/webgpu'
and init is async.
js
import * as THREE from 'three/webgpu';
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init();
When to pick WebGPU: You need TSL custom shaders, compute shaders, or node-based materials. Otherwise, stick with WebGL.
自定义基于节点的材质(TSL)、计算着色器和高级渲染需要使用它。注意:导入路径变为
'three/webgpu'
,且初始化是异步的。
js
import * as THREE from 'three/webgpu';
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init();
何时选择WebGPU:当你需要TSL自定义着色器、计算着色器或基于节点的材质时。否则,请继续使用WebGL。

TSL (Three.js Shading Language)

TSL(Three.js 着色语言)

TSL is Three.js's cross-backend shading language — write shader logic in JavaScript instead of raw GLSL/WGSL. Works with both WebGL and WebGPU backends.
TSL是Three.js的跨后端着色语言——用JavaScript编写着色器逻辑,而非原生GLSL/WGSL。可在WebGL和WebGPU后端上运行。

Basic example

基础示例

js
import { texture, uv, color } from 'three/tsl';

const material = new THREE.MeshStandardNodeMaterial();
material.colorNode = texture(myTexture).mul(color(0xff0000));
js
import { texture, uv, color } from 'three/tsl';

const material = new THREE.MeshStandardNodeMaterial();
material.colorNode = texture(myTexture).mul(color(0xff0000));

NodeMaterial classes (for TSL)

基于节点的材质类(用于TSL)

Use node-based material variants when writing TSL shaders:
  • MeshBasicNodeMaterial
  • MeshStandardNodeMaterial
  • MeshPhysicalNodeMaterial
  • LineBasicNodeMaterial
  • SpriteNodeMaterial
编写TSL着色器时,请使用基于节点的材质变体:
  • MeshBasicNodeMaterial
  • MeshStandardNodeMaterial
  • MeshPhysicalNodeMaterial
  • LineBasicNodeMaterial
  • SpriteNodeMaterial

When to use TSL

何时使用TSL

  • Custom animated materials (color cycling, vertex displacement)
  • Procedural textures (noise, patterns)
  • Compute shaders for particle systems or physics
  • Cross-backend compatibility (same code on WebGL and WebGPU)
For the full TSL specification, functions, and node types, see
reference/llms-full.txt
.
  • 自定义动画材质(颜色循环、顶点位移)
  • 程序化纹理(噪声、图案)
  • 粒子系统或物理的计算着色器
  • 跨后端兼容性(WebGL和WebGPU上运行相同代码)
有关TSL的完整规范、函数和节点类型,请查看
reference/llms-full.txt
。

Performance Rules

性能规则

  • Use
    renderer.setAnimationLoop()
    instead of manual
    requestAnimationFrame
    . It pauses when the tab is hidden and handles WebGPU async correctly.
  • Cap delta time:
    Math.min(clock.getDelta(), 0.1)
    to prevent death spirals
  • Cap pixel ratio:
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
    — avoids GPU overload on high-DPI screens
  • Object pooling: Reuse
    Vector3
    ,
    Box3
    , temp objects in hot loops to minimize GC. Avoid per-frame allocations — preallocate and reuse.
  • Disable shadows on first pass — Only enable shadow maps when specifically needed and tested on mobile. Dynamic shadows are the single most expensive rendering feature.
  • Keep draw calls low — Fewer unique materials and geometries = fewer draw calls. Merge static geometry where possible. Use instanced meshes for repeated objects.
  • Prefer simple materials — Use
    MeshBasicMaterial
    or
    MeshStandardMaterial
    . Avoid
    MeshPhysicalMaterial
    , custom shaders, or complex material setups unless specifically needed.
  • No postprocessing by default — Skip bloom, SSAO, motion blur, and other postprocessing passes on first implementation. These tank mobile performance. Add only after gameplay is solid and perf budget allows.
  • Keep geometry/material count small — A game with 10 unique materials renders faster than one with 100. Reuse materials across objects with the same appearance.
  • 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
  • **使用
    renderer.setAnimationLoop()
    **而非手动
    requestAnimationFrame
    。它在标签页隐藏时暂停,且能正确处理WebGPU异步。
  • 限制delta时间:
    Math.min(clock.getDelta(), 0.1)
    以防止恶性循环
  • 限制像素比:
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
    ——避免高DPI屏幕上的GPU过载
  • 对象池化:在热循环中重用
    Vector3
    、
    Box3
    等临时对象,以减少垃圾回收。避免每帧分配——预分配并重用。
  • 首次实现时禁用阴影——仅在特别需要且在移动端测试通过后,才启用阴影贴图。动态阴影是最耗费性能的渲染功能。
  • 降低绘制调用——独特材质和几何体越少,绘制调用越少。尽可能合并静态几何体。对重复对象使用实例化网格。
  • 优先使用简单材质——使用
    MeshBasicMaterial
    或
    MeshStandardMaterial
    。除非特别需要,否则避免使用
    MeshPhysicalMaterial
    、自定义着色器或复杂材质设置。
  • 默认不使用后处理——首次实现时跳过 bloom、SSAO、运动模糊等后处理步骤。这些会严重影响移动端性能。仅在游戏玩法稳定且性能预算允许时添加。
  • 减少几何体/材质数量——拥有10种独特材质的游戏比拥有100种的游戏渲染速度更快。对外观相同的对象重用材质。
  • 在渲染器上设置
    powerPreference: 'high-performance'
  • 正确释放资源:移除对象时调用
    .dispose()
    释放几何体、材质、纹理
  • 视锥体剔除:让Three.js处理(默认启用),但要在自定义几何体上设置包围球

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
js
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const loader = new GLTFLoader();

function loadModel(path) {
  return new Promise((resolve, reject) => {
    loader.load(
      path,
      (gltf) => resolve(gltf.scene),
      undefined,
      (error) => reject(error),
    );
  });
}
  • 将静态资源放在Vite的
    /public/
    目录下
  • 3D模型使用GLB格式(体积更小,单文件)
  • 使用
    three/addons
    中的
    THREE.TextureLoader
    、
    GLTFLoader
  • 通过回调向UI展示加载进度
js
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const loader = new GLTFLoader();

function loadModel(path) {
  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
除非明确指定,否则所有游戏必须同时支持桌面端和移动端。在做取舍时,将60%的精力放在移动端,40%放在桌面端。为每个游戏概念选择最佳的移动端输入方式:
游戏类型主要移动端输入方式备选方案
弹珠/倾斜/平衡类陀螺仪(DeviceOrientation)虚拟摇杆
跑酷/无尽类点击区域(左右半屏)滑动手势
益智/回合制类点击目标(最小44px)拖放
射击/瞄准类虚拟摇杆 + 点击射击双摇杆
平台跳跃类虚拟方向键 + 跳跃按钮倾斜控制移动

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:
js
class InputSystem {
  constructor() {
    this.keys = {};
    this.moveX = 0;  // -1..1
    this.moveZ = 0;  // -1..1
    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() {
    // 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() {
    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));
  }
}
使用专用的InputSystem,将键盘、陀螺仪和触摸输入合并为单一模拟接口。游戏逻辑仅读取
moveX
/
moveZ
(-1..1),无需关心输入来源:
js
class InputSystem {
  constructor() {
    this.keys = {};
    this.moveX = 0;  // -1..1
    this.moveZ = 0;  // -1..1
    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; });
  }

  /** 从用户手势(如PLAY按钮)调用,以初始化陀螺仪/摇杆。 */
  async initMobile() {
    // 请求陀螺仪权限(iOS 13+需要)
    // 如果被拒绝/不可用,显示虚拟摇杆备选方案
  }

  /** 每帧调用一次。将所有输入源合并为moveX/moveZ。 */
  update() {
    let mx = 0, mz = 0;
    // 键盘(始终激活,作为覆盖)
    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) {
      // 读取陀螺仪或摇杆输入(以激活的为准)
    }
    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):
js
class GyroscopeInput {
  constructor() {
    this.available = false;
    this.moveX = 0;
    this.moveZ = 0;
    this.calibBeta = null;
    this.calibGamma = null;
  }

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

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

  update() {
    // Apply deadzone, normalize to -1..1, smooth with EMA
  }
}
适用于倾斜控制的游戏(弹珠、平衡、赛车):
js
class GyroscopeInput {
  constructor() {
    this.available = false;
    this.moveX = 0;
    this.moveZ = 0;
    this.calibBeta = null;
    this.calibGamma = null;
  }

  async requestPermission() {
    // iOS 13+: DeviceOrientationEvent.requestPermission()
    // 必须从用户手势处理程序中调用
  }

  recalibrate() {
    // 捕获当前方向作为中立位置
  }

  update() {
    // 应用死区,归一化为-1..1,使用EMA平滑
  }
}

Virtual Joystick Pattern

虚拟摇杆模式

DOM-based circle-in-circle touch joystick for non-gyro devices:
js
class VirtualJoystick {
  constructor() {
    this.active = false;
    this.moveX = 0;  // -1..1
    this.moveZ = 0;  // -1..1
  }

  show() {
    // 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() { /* Remove DOM, reset values */ }
}
基于DOM的圆圈套圆圈触摸摇杆,适用于不支持陀螺仪的设备:
js
class VirtualJoystick {
  constructor() {
    this.active = false;
    this.moveX = 0;  // -1..1
    this.moveZ = 0;  // -1..1
  }

  show() {
    // 创建外圈 + 内圈旋钮DOM元素
    // 通过标识符跟踪触摸,以正确处理多点触摸
    // 将旋钮移动限制在中心的最大距离内
    // 将位移归一化为-1..1
  }

  hide() { /* 移除DOM,重置数值 */ }
}

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
  1. 在移动端:优先尝试陀螺仪(从PLAY按钮点击请求权限)
  2. 如果陀螺仪被拒绝/不可用:显示虚拟摇杆
  3. 键盘在任何平台上始终作为备选/覆盖选项
  4. 游戏逻辑仅使用
    input.moveX
    和
    input.moveZ
    ——无需知道输入来源

When Adding Features

添加功能时的步骤

  1. Create a new module in the appropriate
    src/
    subdirectory
  2. Define new events in
    EventBus.js
    Events object using
    domain:action
    naming
  3. Add configuration to
    Constants.js
  4. Add state to
    GameState.js
    if needed
  5. Wire it up in
    Game.js
    orchestrator
  6. Communicate with other systems ONLY through EventBus
  1. 在
    src/
    的相应子目录中创建新模块
  2. 在
    EventBus.js
    的Events对象中使用
    领域:操作
    命名方式定义新事件
  3. 将配置添加到
    Constants.js
    中
  4. 如果需要,在
    GameState.js
    中添加状态
  5. 在
    Game.js
    协调器中进行连接
  6. 仅通过EventBus与其他系统通信

Pre-Ship Validation Checklist

发布前验证清单

Before considering a game complete, verify:
  • Core loop works — Player can start, play, lose/win, and see the result
  • Restart works cleanly —
    GameState.reset()
    restores a clean slate, all Three.js resources disposed
  • Touch + keyboard input — Game works on mobile (gyro/joystick/tap) and desktop (keyboard/mouse)
  • Responsive canvas — Renderer resizes on window resize, camera aspect updated
  • All values in Constants — Zero hardcoded magic numbers in game logic
  • EventBus only — No direct cross-module imports for communication
  • Resource cleanup — Geometries, materials, textures disposed when removed from scene
  • No postprocessing — Unless explicitly needed and tested on mobile
  • Shadows disabled — Unless explicitly needed and budget allows
  • Delta-capped movement —
    Math.min(clock.getDelta(), 0.1)
    on every frame
  • Mute toggle — Audio can be muted/unmuted;
    isMuted
    state is respected
  • Build passes —
    npm run build
    succeeds with no errors
  • No console errors — Game runs without uncaught exceptions or WebGL failures
在认为游戏完成之前,请验证以下内容:
  • 核心循环正常工作——玩家可以开始游戏、进行游戏、胜利/失败,并查看结果
  • 重启功能正常——
    GameState.reset()
    恢复到干净状态,所有Three.js资源已释放
  • 支持触摸 + 键盘输入——游戏在移动端(陀螺仪/摇杆/点击)和桌面端(键盘/鼠标)均可正常运行
  • 画布响应式——渲染器在窗口大小变化时调整尺寸,相机宽高比已更新
  • 所有数值都在Constants中——游戏逻辑中没有硬编码的魔法数值
  • 仅通过EventBus通信——模块之间没有直接导入用于通信
  • 资源已清理——从场景中移除对象时,几何体、材质、纹理已释放
  • 未使用后处理——除非明确需要且在移动端测试通过
  • 阴影已禁用——除非明确需要且性能预算允许
  • 移动逻辑已限制delta值——每帧都使用
    Math.min(clock.getDelta(), 0.1)
  • 支持静音切换——音频可静音/取消静音;
    isMuted
    状态已被尊重
  • 构建通过——
    npm run build
    成功,无错误
  • 控制台无错误——游戏运行时无未捕获异常或WebGL失败