threejs-game
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseThree.js Game Development
Three.js 游戏开发
You are an expert Three.js game developer. Follow these opinionated patterns when building 3D browser games.
Reference: See(quick guide) andreference/llms.txt(full API + TSL) for official Three.js LLM documentation. Prefer patterns from those files when they conflict with this skill.reference/llms-full.txt
你是一名Three.js游戏开发专家。在构建3D浏览器游戏时,请遵循以下既定模式。
参考资料:查看(快速指南)和reference/llms.txt(完整API + TSL)获取官方Three.js LLM文档。当本技能中的内容与这些文件冲突时,优先采用文件中的模式。reference/llms-full.txt
Tech Stack
技术栈
- Renderer: Three.js (, ESM imports)
three@0.183.0+ - Build Tool: Vite
- Language: JavaScript (not TypeScript) for game templates — TypeScript optional
- Package Manager: npm
- 渲染器:Three.js(,ESM导入)
three@0.183.0+ - 构建工具: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 viteCreate :
vite.config.jsjs
import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
publicDir: 'public',
server: { port: 3000, open: true },
build: { outDir: 'dist' },
});Add to scripts:
package.jsonjson
{
"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.jsjs
import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
publicDir: 'public',
server: { port: 3000, open: true },
build: { outDir: 'dist' },
});在的scripts中添加:
package.jsonjson
{
"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
核心原则
- 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.
- 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.
- Restart-safe — Gameplay must be fully restart-safe. must restore a clean slate. Dispose geometries/materials/textures on cleanup. No stale references or leaked listeners across restarts.
GameState.reset()
- 优先实现核心循环——先实现一个相机、一个场景、一个游戏循环。在添加视觉美化之前,先添加玩家输入和终端条件(胜利/失败)。初始范围要小:1种机制、1种失败条件、1种计分系统。
- 游戏玩法清晰度 > 视觉复杂度——将3D视为一种风格选择,而非复杂度要求。一个玩法清晰、材质简单的游戏,胜过视觉复杂但令人困惑的游戏。
- 支持安全重启——游戏玩法必须完全支持安全重启。必须恢复到干净状态。清理时释放几何体/材质/纹理资源。重启过程中不能有陈旧引用或泄漏的监听器。
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 . Never hardcode values in game logic.
Constants.jsjs
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.jsjs
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 — the official Three.js pattern (handles WebGPU async correctly and pauses when the tab is hidden):
renderer.setAnimationLoop()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类初始化所有内容并运行渲染循环。使用——这是Three.js的官方模式(能正确处理WebGPU异步,且在标签页隐藏时暂停):
renderer.setAnimationLoop()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 and init is async.
'three/webgpu'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:
MeshBasicNodeMaterialMeshStandardNodeMaterialMeshPhysicalNodeMaterialLineBasicNodeMaterialSpriteNodeMaterial
编写TSL着色器时,请使用基于节点的材质变体:
MeshBasicNodeMaterialMeshStandardNodeMaterialMeshPhysicalNodeMaterialLineBasicNodeMaterialSpriteNodeMaterial
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.txtPerformance Rules
性能规则
- Use instead of manual
renderer.setAnimationLoop(). It pauses when the tab is hidden and handles WebGPU async correctly.requestAnimationFrame - Cap delta time: to prevent death spirals
Math.min(clock.getDelta(), 0.1) - Cap pixel ratio: — avoids GPU overload on high-DPI screens
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) - Object pooling: Reuse ,
Vector3, temp objects in hot loops to minimize GC. Avoid per-frame allocations — preallocate and reuse.Box3 - 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 or
MeshBasicMaterial. AvoidMeshStandardMaterial, custom shaders, or complex material setups unless specifically needed.MeshPhysicalMaterial - 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 on the renderer
powerPreference: 'high-performance' - Dispose properly: Call on geometries, materials, textures when removing objects
.dispose() - Frustum culling: Let Three.js handle it (enabled by default) but set bounding spheres on custom geometry
- **使用**而非手动
renderer.setAnimationLoop()。它在标签页隐藏时暂停,且能正确处理WebGPU异步。requestAnimationFrame - 限制delta时间:以防止恶性循环
Math.min(clock.getDelta(), 0.1) - 限制像素比:——避免高DPI屏幕上的GPU过载
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) - 对象池化:在热循环中重用、
Vector3等临时对象,以减少垃圾回收。避免每帧分配——预分配并重用。Box3 - 首次实现时禁用阴影——仅在特别需要且在移动端测试通过后,才启用阴影贴图。动态阴影是最耗费性能的渲染功能。
- 降低绘制调用——独特材质和几何体越少,绘制调用越少。尽可能合并静态几何体。对重复对象使用实例化网格。
- 优先使用简单材质——使用或
MeshBasicMaterial。除非特别需要,否则避免使用MeshStandardMaterial、自定义着色器或复杂材质设置。MeshPhysicalMaterial - 默认不使用后处理——首次实现时跳过 bloom、SSAO、运动模糊等后处理步骤。这些会严重影响移动端性能。仅在游戏玩法稳定且性能预算允许时添加。
- 减少几何体/材质数量——拥有10种独特材质的游戏比拥有100种的游戏渲染速度更快。对外观相同的对象重用材质。
- 在渲染器上设置
powerPreference: 'high-performance' - 正确释放资源:移除对象时调用释放几何体、材质、纹理
.dispose() - 视锥体剔除:让Three.js处理(默认启用),但要在自定义几何体上设置包围球
Asset Loading
资源加载
- Place static assets in for Vite
/public/ - Use GLB format for 3D models (smaller, single file)
- Use ,
THREE.TextureLoaderfromGLTFLoaderthree/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.TextureLoaderGLTFLoader - 通过回调向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 Type | Primary Mobile Input | Fallback |
|---|---|---|
| Marble/tilt/balance | Gyroscope (DeviceOrientation) | Virtual joystick |
| Runner/endless | Tap zones (left/right half) | Swipe gestures |
| Puzzle/turn-based | Tap targets (44px min) | Drag & drop |
| Shooter/aim | Virtual joystick + tap-to-fire | Dual joysticks |
| Platformer | Virtual D-pad + jump button | Tilt 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 / (-1..1) and never knows the source:
moveXmoveZjs
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,将键盘、陀螺仪和触摸输入合并为单一模拟接口。游戏逻辑仅读取/(-1..1),无需关心输入来源:
moveXmoveZjs
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
输入优先级
- On mobile: try gyroscope first (request permission from PLAY button tap)
- If gyro denied/unavailable: show virtual joystick
- Keyboard always active as fallback/override on any platform
- Game logic consumes only and
input.moveX-- never knows the sourceinput.moveZ
- 在移动端:优先尝试陀螺仪(从PLAY按钮点击请求权限)
- 如果陀螺仪被拒绝/不可用:显示虚拟摇杆
- 键盘在任何平台上始终作为备选/覆盖选项
- 游戏逻辑仅使用和
input.moveX——无需知道输入来源input.moveZ
When Adding Features
添加功能时的步骤
- Create a new module in the appropriate subdirectory
src/ - Define new events in Events object using
EventBus.jsnamingdomain:action - Add configuration to
Constants.js - Add state to if needed
GameState.js - Wire it up in orchestrator
Game.js - Communicate with other systems ONLY through EventBus
- 在的相应子目录中创建新模块
src/ - 在的Events对象中使用
EventBus.js命名方式定义新事件领域:操作 - 将配置添加到中
Constants.js - 如果需要,在中添加状态
GameState.js - 在协调器中进行连接
Game.js - 仅通过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 — restores a clean slate, all Three.js resources disposed
GameState.reset() - 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 — on every frame
Math.min(clock.getDelta(), 0.1) - Mute toggle — Audio can be muted/unmuted; state is respected
isMuted - Build passes — succeeds with no errors
npm run build - No console errors — Game runs without uncaught exceptions or WebGL failures
在认为游戏完成之前,请验证以下内容:
- 核心循环正常工作——玩家可以开始游戏、进行游戏、胜利/失败,并查看结果
- 重启功能正常——恢复到干净状态,所有Three.js资源已释放
GameState.reset() - 支持触摸 + 键盘输入——游戏在移动端(陀螺仪/摇杆/点击)和桌面端(键盘/鼠标)均可正常运行
- 画布响应式——渲染器在窗口大小变化时调整尺寸,相机宽高比已更新
- 所有数值都在Constants中——游戏逻辑中没有硬编码的魔法数值
- 仅通过EventBus通信——模块之间没有直接导入用于通信
- 资源已清理——从场景中移除对象时,几何体、材质、纹理已释放
- 未使用后处理——除非明确需要且在移动端测试通过
- 阴影已禁用——除非明确需要且性能预算允许
- 移动逻辑已限制delta值——每帧都使用
Math.min(clock.getDelta(), 0.1) - 支持静音切换——音频可静音/取消静音;状态已被尊重
isMuted - 构建通过——成功,无错误
npm run build - 控制台无错误——游戏运行时无未捕获异常或WebGL失败