最近折腾了一个周末,把小时候玩的那种横版平台跳跃游戏用 HTML5 Canvas 从零撸了一个 Demo 出来。不是套 Phaser,没引第三方游戏引擎,纯原生 Canvas 2D API + 原生 JavaScript,实现了类似早期马里奥那种“跑、跳、踩敌人、顶砖块、吃金币”的核心闭环。
这篇东西我会按照实际开发顺序来写,从游戏循环、物理参数、瓦片地图、碰撞检测到敌人 AI,把每个关键节点的设计思路和踩坑点都讲清楚。项目本身是「HTML5 Canvas 平台跳跃游戏」,所以你只要目标是想搞懂 2D 平台游戏的底层实现,或者说前端方向想转游戏开发、想理解游戏引擎内部原理的朋友,这篇应该能给你省下不少试错时间。
先说结论:平台跳跃游戏难的不是“画一个角色”,而是背后那一整套物理、碰撞和状态管理逻辑。Marvel 一类的游戏看起来简单,但要把“手感”调出来,非常考验对参数和帧循环的理解。我会把每一块拆开讲,代码可以抄,但更重要的是理解为什么这么写。
1. 项目整体设计与核心机制拆解
1.1 平台跳跃游戏的“最小可玩系统”
平台跳跃类游戏的核心定义,可以用一句话概括:玩家控制角色在二维场景中移动、跳跃,通过平台和障碍物组成的关卡,到达终点。这个定义里包含了几个绕不开的子系统:
- 玩家输入:左右移动、跳跃
- 物理模拟:重力、速度、加速度
- 碰撞检测:角色和地面、砖块、敌人之间的互动
- 摄像机:跟随角色移动的视口
- 游戏状态:待机、奔跑、跳跃、死亡、过关
换句话说,这五个子系统缺任何一个,游戏都不成立。比如没有重力,角色跳起来就不会落回地面;没有摄像机,关卡只能做成一屏大小。
我这次做的是类似复古 2D 平台跳跃的横版视角游戏,关卡长度从一屏扩展到 2000 像素左右,角色从屏幕左侧出发,向右奔跑跳跃到达终点旗帜。玩法不复杂,但为了让它“像个游戏”,我加了:金币收集、敌人踩踏、问号砖块顶出金币、过关旗帜检测。
1.2 为什么用原生 Canvas 而不是游戏框架
收到不少朋友问:你都做游戏了,为什么不直接用 Phaser?官方都帮你封装好了 Sprite、物理引擎、Tilemap 那些东西。
我的想法是这样的:用框架做游戏是“搭积木”,用原生 Canvas 做游戏是“造积木”。如果你想快速出一个游戏成品,生态成熟的框架当然是最优解;但如果你想搞懂平台跳跃游戏背后的原理——比如重力参数为什么是 1500 而不是 100、碰撞检测为什么容易穿墙、摄像机怎么跟随角色——那你必须手写一遍逻辑,才知道引擎帮你做了什么。
另外还有一个实际原因:基于 Canvas 实现的 2D 游戏,在性能调优上是最灵活的。角色绘制、背景绘制、粒子效果都可以用 Canvas API 直接控制,不像 DOM 动画那样容易被浏览器重排卡住。后续如果要做 HTML5 动画效果、响应式网页游戏,甚至网页设计中的交互游戏模块,这套技术栈都非常吃香。
1.3 核心玩法循环
我实现的游戏主循环,每帧做四件事:
- 处理输入,更新玩家状态
- 更新所有实体(玩家、敌人、金币)
- 执行碰撞检测和响应
- 渲染背景、地图、实体、UI
这四步看起来简单,但每一步都有不少细节。比如输入处理要区分“按下”和“按住”,跳跃状态要判断“下落”还是“上升”,碰撞检测要区分“碰地”“撞头”“贴墙”——这些都会直接影响操作手感,属于投入时间最多、最容易卡住的环节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Canvas 初始化与游戏循环
2.1 画布的尺寸和像素比适配
编码的第一步,是初始化 Canvas。很多刚接触 Canvas 的朋友一上来就写 <canvas id="game"></canvas>,然后设定 width 和 height 就完事了。实际上这么做,在高 DPI(2K、Retina)屏幕上会出现明显的模糊问题。
原因是 Canvas 的 width 和 height 属性代表绘图缓冲区的实际像素数,CSS 中设定的宽高代表显示尺寸。当两者不一致时,浏览器会把缓冲区像素拉伸显示,因此显示效果就模糊了。解决办法是让缓冲区像素数是显示尺寸的 devicePixelRatio 倍。
我用的初始化代码大致是这样:
javascript复制const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// 逻辑分辨率:960x540
const VIEW_WIDTH = 960;
const VIEW_HEIGHT = 540;
function setupCanvas() {
const dpr = window.devicePixelRatio || 1;
canvas.style.width = VIEW_WIDTH + 'px';
canvas.style.height = VIEW_HEIGHT + 'px';
canvas.width = VIEW_WIDTH * dpr;
canvas.height = VIEW_HEIGHT * dpr;
ctx.scale(dpr, dpr);
}
稍微解释一下这里为什么要用 960x540 而不是 1920x1080 作为逻辑分辨率。平台跳跃游戏的逻辑运算量跟宽度相关,如果把逻辑分辨率设太高,物理计算和碰撞检测的数值都会更大,调试起来反而麻烦。960x540 是标准的 16:9 比例,角色尺寸在 40~60 像素之间,既能看清细节,又不至于让地图太大。
2.2 requestAnimationFrame 与时间步长
Canvas 动画的驱动方式有两种:setInterval 和 requestAnimationFrame(简称 RAF)。这里一定不要用 setInterval 做游戏循环,因为 setInterval 的触发频率在某些浏览器标签页后台会不准确,而且无法跟屏幕刷新率同步。
RAF 会在屏幕每次刷新前执行回调,常规显示器的频率是 60Hz,即每帧约 16.67ms。如果显示器是 120Hz,RAF 每秒会执行 120 次,如果不处理时间差值,游戏速度就会变成两倍快。
标准的 RAF 循环写法是记录每次回调之间经过的实际毫秒数,也就是 deltaTime,然后用 deltaTime 去结算位移和速度:
javascript复制let lastTime = 0;
function gameLoop(timestamp) {
const dt = (timestamp - lastTime) / 1000; // 转换为秒
lastTime = timestamp;
// 防止切后台回来时 dt 过大导致玩家飞出地图
const clampedDt = Math.min(dt, 1 / 30);
update(clampedDt);
render();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
关于 Math.min(dt, 1 / 30) 这一步,里面有个实际问题:当用户切换浏览器标签页,再切回来时,timestamp 的差值可能会很大。如果不加限制,在这个“跳变”的 dt 中,玩家速度会被结算成一个超大值,角色瞬间飞出场景边界。限制了最大步长之后,游戏时间流逝恢复正常,角色位置不会异常。
2.3 玩家对象与状态机
很多初学者会把玩家状态写在全局变量里,比如 playerX、playerY、playerSpeedX。
短小的 Demo 没问题,但只要功能一多,全局变量就会“四面漏风”:这个函数改了 X,那个函数改了 Y,调试时很难定位是谁改坏了状态。
我习惯用一个 Player 对象集中管理所有状态:
javascript复制const player = {
x: 80,
y: 300,
vx: 0,
vy: 0,
width: 30,
height: 44,
onGround: false,
facing: 1, // 1右 -1左
state: 'idle', // idle run jump dead
jumpHeld: false,
jumpBuffered: false
};
这里 state 表示玩家的表现阶段,不同状态下的物理行为和渲染方式不同。
平台跳跃游戏里还有一种很常见的状态机设计:场景级状态机。也就是“主菜单”、“游戏中”、“过关动画”、“死亡黑屏”等场景切换。我在 demo 中实现了一个简单的 scene 变量,切换场景时重置玩家位置和地图状态:
javascript复制let scene = 'title'; // title | playing | dead | win
function switchScene(next) {
scene = next;
if (next === 'playing') {
resetPlayer();
resetEnemies();
resetCamera();
}
}
这么做的好处是,UI 按钮、键盘监听、死亡动画的逻辑分得非常干净,不会出现玩家已经死亡了还在继续响应移动键的问题。
3. 玩家控制与物理系统实现
3.1 键盘输入与跳跃缓冲
这是手感差异最明显的部分。平台跳跃里,玩家对“跳跃是否生效”极其敏感。如果按键判断写得太严格,会遇到一种情况:角色跑动中脚尖刚好离开地面,或者刚落下时按了跳跃键,但系统判定“不在可跳跃状态”,于是明明按了键却没有任何反应。这在玩家体验上是非常糟的。
业界常用的解决方案叫“跳跃缓冲(Jump Buffer)”。思路是:当玩家按下跳跃键时,记录一个时间戳,例如 0.1 秒内如果角色碰到地面,自动触发跳跃。代码大约长这样:
javascript复制let jumpBufferTimer = 0;
function handleKeyDown(e) {
if (e.code === 'Space' || e.code === 'ArrowUp') {
jumpBufferTimer = 0.1; // 跳跃缓冲时间 100ms
}
}
// 在 update 中
if (jumpBufferTimer > 0) {
jumpBufferTimer -= dt;
if (player.onGround) {
player.vy = -JUMP_VELOCITY;
player.onGround = false;
jumpBufferTimer = 0;
}
}
另一个提升手感的设计是“可变跳跃高度”:玩家按下跳跃键时以较小的重力系数上升,松开跳跃键后把重力系数拉高,这样短按是小跳,长按是大跳。这就是“按住跳得更高”的机制:
javascript复制if (player.vy < 0) { // 上升中
player.vy += GRAVITY * (player.jumpHeld ? 0.5 : 1.0) * dt;
} else { // 下落中
player.vy += GRAVITY * dt;
}
这里把向上的重力系数调低,效果是按住跳跃键时角色上升阶段被“顶”得更久。老玩家应该能明显感知到这两者的手感差别。
3.2 重力与跳跃参数的计算
平台跳跃游戏里,重力、跳跃初速度、最大移动速度这几个参数相互关联。它们不只是一个直觉调的“魔法数字”,可以用简单的物理学公式来验证手感。
我用的核心参数是:
| 参数 | 值 | 说明 |
|---|---|---|
| 重力加速度 GRAVITY | 1500 px/s² | 每秒速度增加 1500px/s |
| 跳跃初速度 JUMP_VELOCITY | -550 px/s | 负号表示向上 |
| 最大水平速度 | 220 px/s | 超过后不再加速 |
| 水平加速度 | 2000 px/s² | 手感偏“灵敏” |
从初速度 -550 到最高点(vy = 0)所需时间是 t = 550 / 1500 ≈ 0.367 秒,最高跳跃高度 h = v² / (2g) = 550² / (2 × 1500) ≈ 100 像素。这个高度大约是角色身高的两倍多,配合操作足够跨过一个 48px 高的砖块,也不会一跳就飞到屏幕外。
水平加速度 2000 表示从静止到最大速度 220 只需要 0.11 秒,角色响应非常快,没有明显的“拖泥带水”感。如果想让角色动作更“滑”(比如冰面),就把加速度调低到 800~1200,并加上阻尼系数。
3.3 玩家移动与地面检测逻辑
玩家左右移动的基本逻辑是:加速到最大速度,松开方向键后减速。我采用的是“先加速度后阻尼”的方式——移动过程中按加速度增加速度,松开后每帧把速度乘以 0.82 左右,形成类似摩擦力减速的效果。
javascript复制const ACCEL = 2000;
const FRICTION = 0.82;
if (keys['ArrowLeft']) {
player.vx -= ACCEL * dt;
player.facing = -1;
} else if (keys['ArrowRight']) {
player.vx += ACCEL * dt;
player.facing = 1;
} else {
player.vx *= FRICTION;
}
player.vx = Math.max(-MAX_SPEED, Math.min(MAX_SPEED, player.vx));
player.x += player.vx * dt;
在移动代码中,把速度限制拆成两个方向类应用是有讲究的。水平方向用最大值限制,垂直方向的 vy 则只被重力影响,不被限制,否则玩家下落速度太快时会“封顶”,穿墙问题反而更严重。
绘制角色时,facing 变量用于水平翻转图像。简单画个矩形测试时可能无所谓,但一旦换成角色贴图,左右转向不翻转会很奇怪。
4. 瓦片地图、视差滚动与碰撞检测
4.1 用二维数组设计关卡
平台跳跃的地图不适合放在一张大图里让玩家乱走。最实用的方案是“瓦片地图(Tilemap)”:把场景切成网格,每个格子里装一个方块 ID。
我定义了 48x48 像素的瓦片尺寸。一个宽 30 格的关卡,对应数组长度是 30。写在 JS 里大概是:
javascript复制const TILE_SIZE = 48;
// 0: 空 1: 地面 2: 砖块 3: 问号砖
const map = [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
// ...中间大量省略...
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
];
手写二维数组很累,所以我通常是先用文本编辑器按行画一个地图草图,每行字符串里的字符代表不同方块,然后写一个小函数解析成数组:
javascript复制function parseMapText(text) {
const rows = text.trim().split('\n');
return rows.map(row => row.split('').map(ch => {
if (ch === '#') return 1;
if (ch === 'B') return 2;
if (ch === '?') return 3;
return 0;
}));
}
这样做的好处是可视化程度极高。设计关卡时能直接看到 ASCII 画出来的地形长什么样,不用反复打开编辑器。
4.2 AABB 碰撞检测:分轴处理
碰撞检测是平台跳跃游戏里坑最多的地方。如果做完 Physics、输入系统后发现角色经常穿墙或者被卡在墙角,90% 是碰撞检测写得太粗糙。
我用的碰撞检测方式是 AABB(Axis-Aligned Bounding Box,轴对齐包围盒)碰撞。即每个实体都用一个不旋转的矩形包围盒表示,然后判断两个矩形是否重叠。
最简单的检测函数是:
javascript复制function rectCollide(ax, ay, aw, ah, bx, by, bw, bh) {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
但这只是“检测是否碰撞”,还不能解决“碰撞之后怎么处理”。平台跳跃游戏一般需要分轴处理:先移动 X 轴,检测并修正;再移动 Y 轴,检测并修正。不能直接把物体移到一个位置再统一修正,那样会导致角色卡在墙壁的斜角处不断向上弹跳,或者从侧面直接进入砖块内部。
我的处理逻辑是这样的:
javascript复制function moveWithCollision(entity, map) {
// 尝试按 vx 移动 X
entity.x += entity.vx * dt;
const xTiles = getOverlappingTiles(entity, map);
if (hitSolidWalls(xTiles)) {
// 根据玩家朝向把 x 吸附到砖块边缘
if (entity.vx > 0) entity.x = Math.floor((entity.x + entity.width) / TILE_SIZE) * TILE_SIZE - entity.width - 0.01;
else if (entity.vx < 0) entity.x = Math.ceil(entity.x / TILE_SIZE) * TILE_SIZE + 0.01;
entity.vx = 0;
}
// 再按 vy 移动 Y
entity.y += entity.vy * dt;
const yTiles = getOverlappingTiles(entity, map);
if (hitSolidWalls(yTiles)) {
if (entity.vy > 0) {
entity.y = Math.floor((entity.y + entity.height) / TILE_SIZE) * TILE_SIZE - entity.height - 0.01;
entity.onGround = true;
} else if (entity.vy < 0) {
entity.y = Math.ceil(entity.y / TILE_SIZE) * TILE_SIZE + 0.01;
}
entity.vy = 0;
}
}
这里有个细节:吸附位置用了 0.01 的微小偏移,目的是防止下一帧仍然紧贴方块导致碰撞状态不明确。很多新手会忽略这个偏移量,结果是一帧贴上后,下一帧因为浮点误差又重叠,导致抖动或卡顿。
getOverlappingTiles 函数负责获取实体覆盖到的所有瓦片,遍历时要限制瓦片数组索引范围,防止越界时读取 undefined 导致报错:
javascript复制function getOverlappingTiles(entity, map) {
const minCol = Math.floor(entity.x / TILE_SIZE);
const maxCol = Math.floor((entity.x + entity.width - 0.01) / TILE_SIZE);
const minRow = Math.floor(entity.y / TILE_SIZE);
const maxRow = Math.floor((entity.y + entity.height - 0.01) / TILE_SIZE);
const tiles = [];
for (let row = minRow; row <= maxRow; row++) {
for (let col = minCol; col <= maxCol; col++) {
if (row < 0 || row >= map.length || col < 0 || col >= map[0].length) continue;
if (map[row][col] !== 0) tiles.push({ row, col, type: map[row][col] });
}
}
return tiles;
}
4.3 视差滚动背景:做出“纵深”
平台跳跃游戏中,背景如果和关卡速度完全一致,看起来就像贴纸贴在一个平面,非常死板。真正的 2D 游戏视觉层次感主要来自视差滚动(Parallax Scrolling),通俗讲就是远处的景物移动速度慢,近处的景物移动速度快。
我在 demo 里实现了三层背景:
- 远景层:云朵、远山,速度系数 0.2
- 中景层:树林、柱子,速度系数 0.5
- 近景层:砖墙、篱笆,速度系数 0.8
实现方式是:先算出摄像机当前的 X 偏移量 camera.x,然后对不同图层乘以不同的速度系数:
javascript复制function drawBackground(ctx, cameraX) {
// 远景
drawClouds(ctx, cameraX * 0.2);
// 中景
drawTrees(ctx, cameraX * 0.5);
// 近景
drawFence(ctx, cameraX * 0.8);
}
为了让远景无限延伸,绘制时我会把云朵图案按照背景宽度周期性重复:
javascript复制function drawClouds(ctx, offset) {
const bgWidth = 800;
const repeat = Math.ceil(VIEW_WIDTH / bgWidth) + 2;
const startIndex = Math.floor(offset / bgWidth) - 1;
for (let i = startIndex; i < startIndex + repeat; i++) {
const x = i * bgWidth - (offset % bgWidth);
// 在 (x, y) 处绘制云朵
}
}
这里最容易出的问题是取余符号,JS 的 % 对负数取余会得到负值,如果 offset 大于背景宽度后取余,可能出现重复图案跳变。所以颜色较深的调试阶段,我会先打印 offset 和取余结果,确认都是正数范围后再继续。
4.4 摄像机跟随与边界限制
摄像机就是整个渲染的“视口偏移”。在 2D 平台游戏中,通常用玩家位置和屏幕中心的差值来计算跟随目标位置:
javascript复制const targetCameraX = player.x - VIEW_WIDTH * 0.35;
camera.x += (targetCameraX - camera.x) * 0.1;
这里的 0.1 是平滑系数,数值越小摄像机运动越“软”,数值越大跟随越“硬”。一般平台跳跃游戏里我会把系数设定在 0.08~0.15 之间。太小的话,角色在快速移动时画面晃动太多,造成眩晕。
为了防止摄像机移出地图范围,渲染前要夹紧:
javascript复制const maxCameraX = mapWidthPixels - VIEW_WIDTH;
camera.x = Math.max(0, Math.min(camera.x, maxCameraX));
同理,摄像机也不会越出地图最底部,否则玩家掉到画面外时会看到一片空白。
5. 敌人 AI 与金币系统
5.1 敌人巡逻逻辑
平台跳跃里的敌人形态不需要太复杂。我第一次实现时只做了一个类似“板栗仔”的巡逻敌人,逻辑是:沿着一个方向走,遇到墙壁或前方没路时转身。
实现巡逻敌人需要知道两件事:前方是否有墙(墙检测),前方脚下是否有平台(悬崖检测)。对应的代码如下:
javascript复制function updateEnemy(enemy, dt, map) {
enemy.x += enemy.vx * dt;
// 前方有墙
const frontCol = enemy.vx > 0
? Math.floor((enemy.x + enemy.width) / TILE_SIZE)
: Math.floor(enemy.x / TILE_SIZE);
const row = Math.floor((enemy.y + enemy.height) / TILE_SIZE);
if (isSolidTile(map, row, frontCol)) {
enemy.vx *= -1;
}
// 前方脚下无路
const footCol = enemy.vx > 0
? Math.floor((enemy.x + enemy.width + 4) / TILE_SIZE)
: Math.floor((enemy.x - 4) / TILE_SIZE);
const footRow = Math.floor((enemy.y + enemy.height + 2) / TILE_SIZE);
if (!isSolidTile(map, footRow, footCol)) {
enemy.vx *= -1;
}
}
敌人速度我设的是 80px/s,不算快。速度太高会让玩家来不及反应,太低又让关卡没有威胁。仔细调整到 80 之后,玩家在普通跑速下能轻松追上并绕路,但在跳跃腾空时被碰到就会受伤,风险感刚好合适。
5.2 踩头判定与碰撞方向判断
平台跳跃游戏中玩家和敌人的交互有一个核心规则——踩头可以消灭敌人,侧面碰撞则玩家受伤。这个判定如果只靠整体 AABB 碰撞做,会得到错误的结果:踩着敌人头顶时也判定为受伤。
正确的做法是判断“玩家的下降速度和碰撞发生的相对位置”。我先实现一个简化版:只有玩家处于下落状态且玩家底部坐标小于敌人顶部坐标加一个阈值时,才认为击中敌人:
javascript复制const overlapBottom = player.y + player.height - enemy.y;
if (player.vy > 0 && overlapBottom < 14) {
// 踩头
enemy.dead = true;
player.vy = -300; // 玩家弹跳
} else {
// 玩家受伤
damagePlayer();
}
overlapBottom < 14 这个阈值是经验值,表示玩家底部侵入敌人顶部不到 14 像素时,判为踩头。如果侵入更多,说明已经碰撞了很久,可能已经深入敌人身体里,再判踩头会给玩家一种“碰到敌人却不受伤”的错觉。
踩中敌人后,我设置玩家 vy 为 -300,也就是把小跳高度清零。这个弹跳能让玩家在连续踩多个敌人时保持连击节奏,避免落地一次再起跳的卡顿感。
5.3 金币收集与动画
金币系统的实现比敌人简单得多,但它有一个值得展示的点:小物体的动画反馈。我用方形背景加圆点来模拟一个旋转的金币,旋转动画通过改变绘制宽度实现:
javascript复制const scaleX = Math.cos(animTime * 5);
ctx.save();
ctx.translate(coin.x + coin.width / 2, coin.y + coin.height / 2);
ctx.scale(scaleX, 1);
ctx.fillStyle = '#ffd700';
ctx.beginPath();
ctx.arc(0, 0, 10, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
Math.cos(animTime * 5) 会在 -1 到 1 之间变化,乘以缩放后,金币看起来就在绕竖轴旋转。如果想让旋转动画更细腻,还可以同时改变高光的位置和颜色,我这里只是打了一个亮斑。
金币收集检测很简单,用整体 AABB 和玩家矩形做碰撞:
javascript复制if (rectCollide(player.x, player.y, player.width, player.height,
coin.x, coin.y, coin.width, coin.height)) {
coin.collected = true;
score += 100;
}
收集后我会上抛金币编号,让 UI 层的金币计数刷新。如果你要做更精致的反馈,可以在收集位置添加一个粒子爆炸效果,画一些黄色小方块向外飞散,再随机消失。
6. 常见问题与优化技巧
6.1 高频踩坑排查速查表
我把自己调游戏时反复踩过的坑整理成了表格,照着排查效率很高:
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| Canvas 字体/图形模糊 | 画出来的文字和图形边缘有毛刺 | 未处理 devicePixelRatio | 用 2.1 节的 setupCanvas 同步像素比 |
| 键盘按住后角色一直抖动 | 角色左右反复弹跳 | 移动逻辑同时响应了按下和弹起事件 | 用 key state 记录按键状态,不要用 keydown/keyup 直接驱动运动 |
| 角色瞬间穿越砖块 | 从高速下落时穿过地板 | 单帧位移量大于砖块尺寸 | 限制 dt 最大值,或改用射线检测 |
| 角色卡在墙角出不来 | 碰到墙体侧面时身体嵌入墙壁 | 碰撞修正没有分轴处理 | 按 X、Y 分轴移动和修正 |
| 恢复标签页后角色飞出地图 | 回来时 dt 巨大导致速度结算过度 | RAF 时间跨度过大 | Math.min(dt, 1/30) 钳制时间步长 |
| 金币吃了一半但计数不准 | UI 的 score 和实际金币数不一致 | 收集时多次触发 | 设置 coin.collected 标记,检测前先判断 |
| 敌人踩死后依旧能伤害玩家 | 玩家和死亡敌人仍有碰撞盒 | 没有单独处理 enemy.dead 状态 | 检测前先判断 if (enemy.dead) 跳过碰撞 |
| this 指向丢失 | 对象方法作为回调传进 RAF 时 this 变成 window | 箭头函数或 bind | 所有回调函数改用箭头函数或显式 this 绑定 |
6.2 性能优化:离屏 Canvas 缓存静态地图
平台跳跃游戏的场景中,瓦片地图是静止不动的,每一帧重新遍历所有可见瓦片并绘制,有性能浪费。更优做法是把整张关卡静态部分先绘制到一个离屏 Canvas 上,然后在每帧渲染时直接 drawImage 画出来。
javascript复制const offscreen = document.createElement('canvas');
offscreen.width = mapWidthPixels;
offscreen.height = mapHeightPixels;
const offCtx = offscreen.getContext('2d');
// 仅初始化时遍历一次,把所有瓦片绘制到 offCtx 中
renderStaticMap(offCtx);
// 每帧渲染
gameCtx.drawImage(offscreen, camera.x, 0);
这样做的收益非常明显。一个几百格的关卡,每帧绘制瓦片数量从几千个降为一次 drawImage 大图拷贝。CPU 占用率在低配笔记本上差异极大。
但要注意:如果关卡中包含可破坏的砖块、会变化的问号砖、掉落的道具,这些不能全部缓存到离屏 Canvas,否则动态变化无法反映出来。我的做法是:地面和地形砖完全缓存,动态的敌人和金币每帧重新绘制。这样兼顾性能和动态性。
6.3 用可视化调试模式快速定位问题
调碰撞和手感时,最有效的工具是“可视化调试模式”。按下一个快捷键,把实体周围包络盒绘制出来:
javascript复制function renderDebug() {
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
ctx.strokeRect(player.x - camera.x, player.y, player.width, player.height);
enemies.forEach(e => {
ctx.strokeRect(e.x - camera.x, e.y, e.width, e.height);
});
coins.forEach(c => {
ctx.strokeRect(c.x - camera.x, c.y, c.width, c.height);
});
}
碰撞盒子画出来后,很多问题一目了然。比如“明明视觉效果已经踩到敌人了,但代码没判定”,很可能是敌人图画比碰撞盒大或者小;再比如砖块边缘的吸附偏移,肉眼看不清,但画线后就能确认是否有重叠。
我在开发过程中会把这条调试信息始终打开,直到所有逻辑调通后才注释掉。如果你也做类似项目,强烈推荐加这一步,比单纯摆数值猜测效率高太多。
6.4 关于“手感”的调参心得
最后聊一个有点玄但实际影响很大的东西——手感。平台跳跃游戏的手感很难用公式定义,但有几条经验:
- 重力不能太小也不能太大。重力 1200~1800 px/s² 之间,跳跃最高点的停留时间大约 0.2~0.35 秒,这个区间玩家反应起来最舒适。
- 水平加速度要远大于重力带来的减速。不然转弯时角色会像溜冰,影响精准落点。
- 跳跃缓冲时长设 0.08~0.12 秒。太短没作用,太长会让玩家在离开地面很久后仍能出跳,显得不真实。
- 角色落地时如果检测到有轻微磕碰,不要立刻取消 onGround 状态。可以加一个“地面锁定时间”:从碰撞地面后 0.03 秒内,即使检测到悬空也仍保持 onGround,防止因为边缘摩擦导致抖动。
说实话,参数没有绝对正确与否,只有“在你这个关卡里有没有操作感”。调参时我建议一次只改一个参数,改完立即跑一局,记录了手感变化后再继续,不要几个参数一起乱调。
我实际操作下来的体会是,老老实实从最原始的矩形方块开始跑通“移动+跳跃+碰撞”这三件事,比一开始就纠结用什么素材、画什么角色重要得多。等你把核心循环跑顺,再加入敌人、金币、视差滚动,层次一下子丰富起来。后续如果想继续扩展,可以考虑加存档点、不同关卡切换、Boss 战,或者把瓦片地图编辑器做成可视化工具——这个项目能继续玩的空间非常充足。
