DINO GAME GUIDE

Chrome Dino 恐龙游戏 — 指导技术手册

适合人群:刚接触前端、想学习「如何用代码做游戏」的初学者
前置知识:知道 HTML 是什么、JS 变量和函数、见过 CSS 怎么写
看完你能:理解这个恐龙游戏从零到一的完整制作思路,并能动手做一个属于自己的简单精灵图小游戏

前言:这个游戏到底是什么

Chrome 断网时你会看到的那个小恐龙跑酷,有人把它拆出来做成了独立网页游戏。它就是一个文件 —— 一个 HTML 文件,里面包含:

没有任何框架,没有 React/Vue,没有游戏引擎——只有浏览器自带的三个东西:HTML、CSS、JavaScript + Canvas。

我们分析的就是这个游戏是怎么用这几个最基础的技术搭出来的。

建议:边看边对照 reference/ 目录里的文件 —— 打开 game.html 在浏览器里实际玩玩,感受一下你要理解的东西长什么样。

第 1 课:Canvas —— 浏览器里的画板

🎯 什么是 Canvas

想象你小时候用的那种可以画画的磁性画板 — 你画一笔,板上就多一笔。Canvas 就是网页里的那块画板。

<canvas id="game" width="600" height="150"></canvas>
var canvas = document.getElementById('game');
var ctx = canvas.getContext('2d');

ctx.fillStyle = 'red';
ctx.fillRect(10, 20, 50, 50);

💡 为什么用 Canvas 做游戏

📝 这个游戏是怎么用的

关键细节:这个游戏的 HTML 里根本没有 <canvas> 标签!Canvas 是 JS 运行时动态创建的:

var canvas = document.createElement('canvas');
canvas.className = 'runner-canvas';
document.querySelector('.runner-container').appendChild(canvas);

这样做的好处:JS 完全掌控 Canvas 的尺寸和属性,不受 HTML 写死。Canvas 的属性通过 CSS 定义尺寸:

.runner-canvas {
  height: 150px;
  max-width: 1000px;
  position: absolute;
  top: 0;
  z-index: 2;
}

Canvas = 画板,ctx = 画笔。游戏里所有你能看到的东西全都是用 ctx.xxx() 方法画上去的。Canvas 可以动态创建,不一定要写在 HTML 里。

第 2 课:精灵图 Sprite Sheet —— 把画都挤到一张纸上

🎯 什么是精灵图

假设你要做一本人物走路的翻页动画书,每页画一张图。精灵图的做法就是把所有页平铺在一张大纸上

┌────┬────┬────┬────┬────┬────┬────┬────────────┐ │闲置 │跑1 │跑2 │跳起│蹲1 │蹲2 │仙人│ ... 地面条 │ └────┴────┴────┴────┴────┴────┴────┴────────────┘ ←───────────── 一张 1233×68 像素的长条图 ──────────→

💡 为什么要挤在一起

好处解释
一次下载浏览器只要请求 1 个 PNG 文件,不是 50 个
加载快1 个 HTTP 请求比 50 个快得多
管理简单所有美术资源在一张图上,不会漏文件
性能好Canvas 切换帧时不用换图片源,只改坐标

📝 这个游戏的精灵图实际规格

版本文件名实际尺寸为什么有两张
1x100-offline-sprite.png1233 × 68 px普通屏幕用
2x200-offline-sprite.png2441 × 130 pxRetina 高清屏用

2x 图是 1x 图的精确 2 倍 — 内容一样,像素翻倍。

💡 JS 怎么选该用哪张

var useHighRes = window.devicePixelRatio > 1;

var spriteImg = useHighRes
  ? document.getElementById('offline-resources-2x')
  : document.getElementById('offline-resources-1x');

💡 精灵图怎么放到页面里预加载

<div id="offline-resources">
  <img id="offline-resources-1x" src="100-offline-sprite.png">
  <img id="offline-resources-2x" src="200-offline-sprite.png">
</div>
#offline-resources { display: none; }

关键理解display: none 只是不显示,浏览器照样会下载 <img src>。等 JS 要用的时候,图片已经在内存里解码好了,传给 Canvas 直接用。

精灵图 = 把所有角色和场景的每一帧画在同一张 PNG 上。只做 1 次 HTTP 请求,Canvas 按坐标裁切显示不同部分。DPR 决定选 1x 还是 2x。自己玩只需要一张 1x 图即可。

第 3 课:drawImage —— 从纸上剪下来贴到画板上

🎯 把精灵图想象成一张贴纸

你有一张大贴纸(精灵图),上面印满了各种图案。现在你要做的是:

  1. 用剪刀从大贴纸上一个 44×47 的恐龙下来
  2. 把剪下来的小贴纸到画板的 (120, 200) 这个位置

Canvas 的 drawImage 就是在帮你做这件事 — 它有 9 个参数:

ctx.drawImage(
  spriteImg,    // ① 大贴纸本身
  892, 5,       // ②③ 从大贴纸的哪个像素点开始剪
  44, 47,       // ④⑤ 剪多宽、多高
  120, 200,     // ⑥⑦ 贴到画板的哪个位置
  132, 141      // ⑧⑨ 贴多宽、多高(可缩放!)
);

💡 9 个参数分组记忆法

drawImage(
  贴纸,                        ← 选图
  剪的起点 x, 剪的起点 y,     ← 在精灵图上切哪里
  剪的宽, 剪的高,              ← 切多大
  贴的位置 x, 贴的位置 y,     ← 画到 Canvas 的哪里
  贴的宽, 贴的高               ← 画多大(可缩放)
);

共三组:选图 → 源(切)→ 目标(贴)

💡 像素放大的秘密

为什么精灵图只有 68 像素高,游戏里看起来却很正常?

// 精灵图上的恐龙只有 44×47 像素
// 但画到 Canvas 时放大 3 倍:132×141
ctx.drawImage(sprite, 892, 5, 44, 47,
                     120, 200,
                     44*3, 47*3);

配合 CSS:

canvas { image-rendering: pixelated; }

pixelated 告诉浏览器:用最近邻算法放大,不要用平滑插值。这就是像素游戏那种锐利边缘的来源。

💡 数字怎么办 —— 精灵图上的数字

精灵图里预先画好了数字 0-9。显示分数其实就是:把分数的每一位数字,从精灵图上裁出来贴到右上角。

// 分数是 42
// '4':精灵图上第 4 个数字在 x=654 + 4*9 = 690 处
ctx.drawImage(sprite, 690, 2, 9, 13, canvasW-30, 20, 9, 13);
// '2':精灵图上第 2 个数字在 x=654 + 2*9 = 672 处
ctx.drawImage(sprite, 672, 2, 9, 13, canvasW-20, 20, 9, 13);

drawImage 的本质是「剪贴」操作。精灵图上画多小都可以,通过目标宽高参数任意放大。image-rendering: pixelated 是保持锐利的关键。同一个精灵图可以给游戏里所有东西用。

第 4 课:游戏主循环 —— 翻页动画书每秒翻 60 页

🎯 翻页动画书的原理

游戏是这样运行的:

  1. 清空画板
  2. 画出当前帧的背景、人物、障碍物……
  3. 等 1/60 秒
  4. 回到第 1 步

每一「页」叫一帧(frame),每秒翻 60 页 = 60 FPS

💡 浏览器提供了一个完美的定时器

function gameLoop() {
  update();    // 计算
  draw();      // 画画
  requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
requestAnimationFramesetInterval(16)
刷新率和显示器同步(60/120fps自动适配)固定60fps
切标签页自动暂停继续跑
流畅度浏览器最优时机可能卡顿

💡 处理时间差(delta time)

function gameLoop(timestamp) {
  var dt = (timestamp - lastTime) / 16.667;  // 归一化60fps=1
  dt = Math.min(dt, 3);  // 上限3:防切标签页瞬移

  player.y += vy * dt;
  obstacle.x -= speed * dt;

  lastTime = timestamp;
  requestAnimationFrame(gameLoop);
}

游戏 =「清空→计算→画→等待→重复」。requestAnimationFrame 是最好的循环工具。dt 确保不同性能电脑上速度一致。

第 5 课:人物动起来 —— 切换坐标就是动画

🎯 动画的本质

不是让恐龙真的在动,而是每隔一段时间换一张图

var runFrames = [
  { x: 892, y: 5, w: 44, h: 47 },  // 左脚前
  { x: 936, y: 5, w: 44, h: 47 },  // 右脚前
];

var idx = Math.floor(time / 100) % runFrames.length;
var frame = runFrames[idx];
ctx.drawImage(sprite, frame.x, frame.y, frame.w, frame.h,
              playerX, playerY, frame.w * scale, frame.h * scale);

Math.floor(time / 100) % 2 这条公式:time=0→帧0, time=100→帧1, time=200→帧0…循环往复。

💡 不同东西换帧速度不同

var runIdx  = Math.floor(time / 100) % 2;   // 跑步:100ms
var flapIdx = Math.floor(time / 80) % 2;    // 翅膀:80ms 快
var moonIdx = Math.floor(score / 100) % 7;  // 月亮:每100分

动画 = 循环切换源坐标。Math.floor(time/interval) % frameCount 是切换公式。不同对象用不同切换速度。

第 6 课:地面滚动 —— 传送带永远跑不完

🎯 传送带的原理

恐龙定在屏幕左边不动,地面(和障碍物)向左移动。

💡 怎么让地面无限循环

两段,首尾相接

var tileW = 1200;
var offset = -(scrollOffset % tileW);
// offset 始终在 [-tileW, 0]

ctx.drawImage(sprite, 2, 54, tileW, 12, offset, groundY, tileW, 12);
ctx.drawImage(sprite, 2, 54, tileW, 12, offset + tileW, groundY, tileW, 12);

当 offset 滚出屏幕,取模让它自动回到右边——无缝循环。

跑酷游戏里角色不动,世界向左滚。-(offset % tileW) 是核心公式。画两段首尾相接。

第 7 课:让角色跳起来 —— 重力和跳跃

🎯 跳跃就是「向上抛球」

vy=-12 ↑ vy=-9 ↑ vy=-5 ↑ vy=0 · ←最高点 vy=+5 ↓ vy=+9 ↓ vy=+12 ↓ ═══ 地面 ═══
var player = { y: GROUND_Y, vy: 0, grounded: true };

function jump() {
  if (player.grounded) {
    player.vy = -12;
    player.grounded = false;
  }
}

function updatePhysics() {
  if (!player.grounded) {
    player.vy += 0.65;        // 重力拉回
    player.y  += player.vy;   // 位置改变
    if (player.y >= GROUND_Y) {
      player.y = GROUND_Y;    // 落地
      player.vy = 0;
      player.grounded = true;
    }
  }
}
参数调大调小
jumpForce (-12)跳更高跳更矮
gravity (0.65)落更快飘得久

跳跃 = 向上初速度 + 每帧重力下拉。两个数字控制手感:jumpForce 和 gravity。

第 8 课:碰撞检测 —— 两个盒子碰上了吗

🎯 两个矩形是否重叠

如果 ① 恐龙右边 > 仙人掌左边 并且 ② 恐龙左边 < 仙人掌右边 并且 ③ 恐龙底边 > 仙人掌顶边 并且 ④ 恐龙顶边 < 仙人掌底边 → 碰上了!

这就是 AABB(轴对齐包围盒)碰撞检测。

function checkCollision(player, obstacle) {
  var pl = player.x + 6, pr = player.x + player.w - 6;
  var pt = player.y - player.h + 6, pb = player.y - 4;

  var ol = obstacle.x - obstacle.w/2 + 3;
  var or = obstacle.x + obstacle.w/2 - 3;
  var ot = obstacle.y;
  var ob = obstacle.y + obstacle.h;

  if (pr > ol && pl < or && pb > ot && pt < ob) {
    gameOver();
  }
}

💡 蹲下有什么用 —— 碰撞盒会变小

按下 ↓ 键时,恐龙蹲下的精灵图更高更宽(59×25),但碰撞盒高度从 47 变成 25。可以躲过高飞的翼龙。

碰撞检测 = 判断两个矩形是否重叠。碰撞盒应比图片略小。蹲下切换碰撞盒高度。

第 9 课:游戏状态 —— 红绿灯控制流程

🎯 游戏有三种「状态」

┌──────────┐ 按空格 ┌──────────┐ 撞障碍 ┌──────────┐ │ waiting │ ────────→ │ playing │ ────────→ │ over │ └──────────┘ └──────────┘ └──────────┘
var gameState = 'waiting';

function update() {
  if (gameState !== 'playing') return;
  // 物理、碰撞、计分……
}

function onSpace() {
  if (gameState === 'waiting') startGame();
  else if (gameState === 'playing') jump();
}

💡 死亡动画怎么实现

撞到障碍物后不是立刻弹窗,而是播放 25 帧死亡动画:

var deathTimer = 25;

function draw() {
  if (gameState === 'over' && deathTimer > 0) {
    deathTimer--;
    ctx.globalAlpha = deathTimer / 25;
    ctx.scale(deathTimer / 25, deathTimer / 25);
    drawDino();
    ctx.globalAlpha = 1;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
  }
}

游戏状态机 = 一个变量 + 三个值。每个函数先判断状态。死亡动画 25 帧后弹窗。

第 10 课:声音 —— 把音效藏在网页里

🎯 两种做法

这个游戏用 Base64 MP3 嵌入做音效:

<template id="audio-resources">
  <audio src="data:audio/mpeg;base64,//uQxAAAAA..."></audio>
</template>

💡 为什么用 <template> 标签

<template> 包裹后,内容完全不被解析。要播放时 JS 拆开取出:

function playSound(soundId) {
  var tmpl = document.getElementById('audio-resources');
  var audio = tmpl.content.querySelector('#' + soundId).cloneNode(true);
  audio.play();
}

💡 另一种做法(Web Audio API 合成,更省空间)

var osc = audioCtx.createOscillator();
osc.type = 'square';
osc.frequency.setValueAtTime(330, now);
osc.frequency.rampTo(660, now + 0.06);
osc.start(); osc.stop(now + 0.08);
方案优点缺点
Base64 MP3音质好增大体积~20KB×3
Web Audio 合成零文件体积音色单一

音效可内嵌(Base64)或 JS 合成。先做可玩的游戏,再加声音。

第 11 课:搭建脚手架 —— 从零做一个最小游戏

🎯 目标:一个能跑的极简精灵图游戏

📝 第一步:准备你的精灵图

用任何画图软件画一张这样的图:

┌──────┬──────┬──────┬──────┐ │ 闲置 │ 跑1 │ 跑2 │ 障碍 │ │44×47 │44×47 │44×47 │20×40 │ └──────┴──────┴──────┴──────┘ 高度 ≈ 50px,总宽 ≈ 150px,PNG 透明背景

📝 第二步:HTML 骨架

<!DOCTYPE html>
<html><head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  <title>My Dino Game</title>
  <style>
    body { margin: 0; min-height: 100vh; display: flex; align-items: center;
           justify-content: center; background: #f7f7f7; }
    canvas { width: 100%; max-width: 600px; image-rendering: pixelated; cursor: pointer; }
    body { touch-action: manipulation; }
  </style>
</head>
<body>
  <img id="sprite" src="sprite.png" style="display:none">
  <canvas id="game"></canvas>
  <script>/* ═══ 游戏代码写在这里 ═══ */</script>
</body></html>

📝 第三步:核心 JavaScript(完整 150 行)

// ── 1. 初始化 ──
var canvas = document.getElementById('game');
var ctx = canvas.getContext('2d');
var sprite = document.getElementById('sprite');
canvas.width = 600; canvas.height = 150;
var W = 600, H = 150, GROUND = H - 20;

// ── 2. 精灵坐标定义 ──
var SPRITES = {
  idle:   { x: 0,  y: 0, w: 44, h: 47 },
  run1:   { x: 44, y: 0, w: 44, h: 47 },
  run2:   { x: 88, y: 0, w: 44, h: 47 },
  cactus: { x: 132,y: 10,w: 20, h: 40 },
};

// ── 3. 游戏状态 ──
var state = 'waiting', score = 0, speed = 3, frameCount = 0;

// ── 4. 玩家 ──
var player = { x: 80, y: GROUND, w: 44, h: 47, vy: 0, grounded: true };
var obstacles = [];

// ── 5. 跳跃 ──
function jump() {
  if (player.grounded) { player.vy = -9; player.grounded = false; }
}

// ── 6. 更新 ──
function update(dt) {
  if (state !== 'playing') return;
  frameCount++;
  score += Math.floor(speed * dt * 0.1);
  speed += 0.001;

  if (!player.grounded) {
    player.vy += 0.5; player.y += player.vy;
    if (player.y >= GROUND) { player.y = GROUND; player.vy = 0; player.grounded = true; }
  }

  obstacles.forEach(function(o) { o.x -= speed * dt; });
  obstacles = obstacles.filter(function(o) { return o.x > -50; });

  var last = obstacles[obstacles.length - 1];
  if (!last || last.x < W - (150 + Math.random() * 250)) {
    obstacles.push({ x: W + 30, y: GROUND - 40, w: 20, h: 40 });
  }

  for (var i = 0; i < obstacles.length; i++) {
    var o = obstacles[i];
    var pl = player.x + 6, pr = player.x + player.w - 6;
    var pt = player.y - player.h + 4, pb = player.y - 4;
    var ol = o.x - o.w/2 + 3, or = o.x + o.w/2 - 3;
    if (pr > ol && pl < or && pb > o.y && pt < o.y + o.h) state = 'over';
  }
}

// ── 7. 绘制 ──
function draw() {
  ctx.clearRect(0, 0, W, H);
  ctx.fillStyle = '#f7f7f7'; ctx.fillRect(0, 0, W, H);
  ctx.strokeStyle = '#555'; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(0, GROUND); ctx.lineTo(W, GROUND); ctx.stroke();

  obstacles.forEach(function(o) {
    ctx.drawImage(sprite, SPRITES.cactus.x, SPRITES.cactus.y,
                  SPRITES.cactus.w, SPRITES.cactus.h,
                  o.x - o.w/2, o.y, o.w, o.h);
  });

  var frame;
  if (!player.grounded) frame = SPRITES.idle;
  else if (state === 'playing')
    frame = (Math.floor(frameCount/6)%2===0) ? SPRITES.run1 : SPRITES.run2;
  else frame = SPRITES.idle;
  ctx.drawImage(sprite, frame.x, frame.y, frame.w, frame.h,
                player.x, player.y - player.h, player.w, player.h);

  ctx.fillStyle = '#333'; ctx.font = '14px sans-serif';
  ctx.textAlign = 'right'; ctx.fillText(score + ' m', W - 20, 24);

  if (state === 'waiting') {
    ctx.textAlign = 'center'; ctx.fillText('Press Space to Start', W/2, H/2);
  }
  if (state === 'over') {
    ctx.textAlign = 'center'; ctx.fillText('Game Over - Tap Retry', W/2, H/2);
  }
}

// ── 8. 主循环 ──
var lastTime = 0;
function loop(ts) {
  var dt = (ts - lastTime) / 16.667 || 1;
  dt = Math.min(dt, 3); lastTime = ts;
  update(dt); draw();
  requestAnimationFrame(loop);
}

// ── 9. 输入 ──
document.addEventListener('keydown', function(e) {
  if (e.code === 'Space' || e.code === 'ArrowUp') {
    e.preventDefault();
    if (state === 'waiting') state = 'playing';
    else if (state === 'over') { state = 'playing'; score = 0; speed = 3; obstacles = []; player.y = GROUND; }
    else jump();
  }
});
canvas.addEventListener('click', function() {
  if (state === 'waiting') state = 'playing';
  else if (state === 'over') { state = 'playing'; score = 0; speed = 3; obstacles = []; player.y = GROUND; }
  else jump();
});
canvas.addEventListener('touchstart', function(e) { e.preventDefault(); canvas.click(); });

requestAnimationFrame(loop);

💡 这个 150 行的代码就是一个完整游戏

复制到 HTML 文件,配上一张精灵图就能跑。从这里改:

一个可玩的精灵图游戏最少只要 150 行代码。核心结构永远不变:Canvas → 精灵图 → drawImage → 主循环 → 碰撞 → 输入。最难的步骤在画图

第 12 课:进阶概念速览

读完前 11 课已经能做一个基础游戏了。以下按推荐学习顺序排列:

12.1 iframe 嵌入

index.html<iframe>game.html 嵌入到更大页面。首页负责 SEO、说明,游戏页只运行游戏。自己玩不需要 iframe。

12.2 背景视差

云朵慢、远山中等、地面快——三层不同速度创造景深:每层 speed * layerFactor

12.3 粒子效果

灰尘、火花——数组里维护小圆点,每帧移动+缩小+透明后删除。

12.4 音效(Base64 嵌入)

<template><audio src="data:audio/mpeg;base64,..."> 把 MP3 藏进 HTML。

12.5 localStorage 存最高分

localStorage.setItem('hi', score) ——关浏览器重开分数还在。一行代码。

12.6 移动端适配

viewport meta、touch-actiontouchstartmatchMedia("orientation")

12.7 CSS 动画做 UI

加载圈、弹窗淡入——不用 Canvas 画,纯 CSS 更简单(走 GPU 合成层)。

12.8 代码混淆

发布时 JS 压缩成乱码——减小文件大小,防抄袭。自己学不用混淆。

12.9 SEO 结构化数据

ld+json、OpenGraph 标签给搜索引擎看的。自己做来玩不用管。

附录 A:Sprite Sheet 完整坐标参考表

所有坐标基于 1x 版本 (100-offline-sprite.png, 1233×68px)。2x 版本所有坐标 ×2。

上层 (y ≈ 2-5)

精灵名称xywh说明
重启按钮5153632圆形箭头
恐龙-闲置4454447等待时静态帧
云朵8654815背景
翼龙-翅膀下134124634帧1
翼龙-翅膀上18024630帧2
大仙人掌-133222550单柱
大仙人掌-235725050双柱
大仙人掌-340727550三柱
数字 0-9 + HI654212013各宽约9-10px
恐龙-跑步189254447左脚前
恐龙-跑步293654447右脚前

下层 (y ≈ 15-30)

精灵名称xywh说明
小仙人掌-1228151735单柱
小仙人掌-2245153435双柱
小仙人掌-3279155135三柱
小仙人掌-最矮482281522极短
星星48459333颗竖排
月亮-1494152040新月
月亮-25141520401/4
月亮-3534154040满月
月亮-45741520403/4
月亮-5594152040残月
月亮-6614152040细残月
月亮-7634152040细残月
恐龙-死亡84854447眼睛叉叉
"GAME OVER"6541819113像素字体
恐龙-蹲1980305925蹲下帧1
恐龙-蹲21039305925蹲下帧2
恐龙-蹲死1098305925蹲下碰撞

底边 (y = 54)

精灵名称xywh说明
地面条254120012地面纹理

角色核心尺寸速查

角色宽×高帧数
恐龙站立/跑步44 × 473
恐龙蹲下59 × 253
翼龙46 × 342

附录 B:文件清单与各自职责

reference/
├── index.html              首页(SEO、FAQ、操作说明、iframe)
├── game.html               游戏页面(加载 dinorun.js)
├── dinorun.js              ★核心游戏 JS(混淆压缩)
├── run.js                  首页交互(键盘防滚、FAQ折叠)
├── rate.js                 评分组件 AJAX 提交
├── dinosaurgame.css        游戏页面 CSS
├── styleNew.css            首页 CSS
├── 100-offline-sprite.png  1x 精灵图(1233×68px)
├── 200-offline-sprite.png  2x 精灵图(2441×130px)
├── 100-error-offline.png   静态图标 1x
├── 200-error-offline.png   静态图标 2x
├── favicon.png / ico.png   网站图标
├── turnScreen.png          横屏旋转提示
├── stars.svg               评分星星 SVG
├── DinoGamePlay.gif        首页演示 GIF
└── 技术手册.md / 指导技术手册.md  文档

附录 C:DOM 结构参考图

game.html <body>
├── #DINO_loading (z-index: 9999)
│   └── .DINO_spinner   ← 纯 CSS 旋转加载动画
├── #DINO_orientationAlert (z-index: 99999)
│   └── <img src="turnScreen.png">  ← 竖屏提示
├── .DINO_game-container
│   └── #t.offline
│       ├── #messageBox
│       └── #main-frame-error
│           ├── #main-content
│           │   └── .icon-offline  ← CSS 静态图标
│           └── #offline-resources (display: none)
│               ├── <img 1x sprite>  ← 隐藏预加载
│               ├── <img 2x sprite>
│               └── <template audio>  ← 按需克隆
├── #DINO_dialogs (z-index: 1000)
│   └── #DINO_gameOverDialog
│       ├── Game Over
│       ├── 得分
│       └── 再玩一次
└── <script src="dinorun.js">  ← 游戏全部逻辑

关键:Canvas 不在 HTML 里!JS 动态创建并插入 #t.offline 中。

附录 D:完整启动流程时间线

T=0s    HTML 加载完成
        ├── <img> 开始后台下载精灵图 PNG
        ├── 显示加载转圈
        └── dinorun.js 开始执行

T=0.1s  dinorun.js 初始化
        ├── 判断 DPR → 选 1x 或 2x
        ├── createElement('canvas') → 插入 DOM
        ├── gameState = 'waiting'
        └── requestAnimationFrame → 画第一帧

T=0.5s  精灵图 PNG 下载+解码完成

T=2.0s  加载转圈隐藏 + 竖屏检测

T=?s    用户按 Space / 点击
        ├── gameState = 'playing'
        ├── 地面开始滚动 + 障碍物生成
        ├── 恐龙开始跑步动画
        └── 分数累加

运行时  每帧循环
        ├── 分数递增 + 速度递增(每300帧+0.25)
        ├── 障碍物左移 + 生成新障碍物
        ├── 碰撞检测
        └── 逐层绘制

碰撞时
        ├── gameState = 'over'
        ├── 播放碰撞音效 + GameOver 音效
        ├── 死亡动画 ×25 帧(缩小+透明)
        └── 25帧后弹出 GameOver 对话框

附录 E:性能优化清单

技术原理效果
精灵图合并多张图合成一张1次HTTP替代N次
display:none预加载隐藏但浏览器仍下载图片在JS执行前就绪
<template>延迟解析不解析不执行音频不解码直到需要
requestAnimationFrame浏览器VSync同步60fps丝滑
image-rendering:pixelatedGPU最近邻采样放大不模糊
CSS transform/transitionGPU合成层动画流畅
<script defer>延迟执行不阻塞首屏
极小文件体积精灵图15KB×2慢网秒加载

附录 F:关键 API 速查卡

Canvas 绘制

API作用
ctx.drawImage(img,sx,sy,sw,sh,dx,dy,dw,dh)精灵图裁切绘制
ctx.clearRect(0,0,w,h)清空画布
ctx.fillRect(x,y,w,h)画纯色矩形
ctx.fillText(str,x,y)写文字
ctx.globalAlpha = 0.5全局透明度

CSS 关键属性

属性作用
image-renderingpixelated像素放大不模糊
touch-actionmanipulation禁止双击缩放
-webkit-tap-highlight-colortransparent禁止点击高亮

JavaScript 关键 API

API作用
requestAnimationFrame(fn)注册下一帧回调
document.createElement('canvas')JS创建Canvas
localStorage.getItem/setItem存取最高分
window.devicePixelRatio获取设备像素比
window.matchMedia('(orientation: portrait)')检测屏幕方向
template.content.cloneNode(true)克隆模板内容
audio.play()播放音频
Math.floor() / %取整/取模

附录 G:常见疑问解答 FAQ

Q1:精灵图一定要在一行吗?可以多行吗?

可以多行。但单行最简单,第一次做就单行。

Q2:精灵图用什么软件画?

Aseprite(付费,推荐)、Piskel(免费网页版)、Photoshop(铅笔工具)、GIMP、甚至 Windows 画图。

Q3:2x 精灵图是必须的吗?

不是。自己玩只要一张 1x 图就行。写 pixelated 后 1x 也够清晰。

Q4:为什么不用 CSS 动画而要用 Canvas?

CSS 动画适合 UI。Canvas 适合游戏渲染——每帧精确控制位置、碰撞检测、多层绘制。CSS 做不到。

Q5:这个游戏为什么把 JS 搞得那么乱?

代码混淆(packer),防抄袭+减小体积。自己学完全不需要混淆

Q6:为什么要用 requestAnimationFrame 而不是 setInterval

setInterval(16) 是盲目的。requestAnimationFrame 智能匹配刷新率,切标签页暂停。任何游戏都用它

Q7:我可以直接用这个游戏的精灵图吗?

不可以。Chrome Dino 美术是 Google 版权。画一套自己的

Q8:电脑和手机都能玩,需要两套代码吗?

不需要。Canvas + 同一份 JS + 响应式 CSS 即可。额外加 touch 事件、防缩放、竖屏检测,几行的事。

Q9:障碍物太密/太松怎么调?

// 太密→调大
var minGap = 200; var maxGap = 500;
// 太松→调小
var minGap = 80;  var maxGap = 200;

这是整个游戏难度调节的核心参数。

Q10:看完这个手册我接下来该做什么?

  1. 打开第 11 课的骨架代码,复制一个 HTML 跑起来
  2. 画一张自己的简单精灵图(一个小人、一个障碍物,够了)
  3. SPRITES 坐标表匹配你的图
  4. 调代码玩:改跳跃高度、改速度、改障碍物密度
  5. 加新东西:第二个障碍物、背景云朵、音效
  6. 给别人玩,听听反馈

先跑起来再改进,不要试图第一次就做完美。

— END —
Design: Pixel Art / Gaming Dark Theme
Fonts: Press Start 2P + VT323 · Google Fonts