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失败