跳到主要内容

游戏Tilemap

2D 游戏地图是 整数网格 + 相机裁剪 + 图块图集,不是一张大背景 PNG。实体(玩家、子弹)另层画。Canvas 2D 能撑住的是中小正交地图;粒子/灯光/超大滚动再上 Pixi/WebGL。

1. 数据

export interface Tileset {
bitmap: ImageBitmap;
tileWidth: number;
tileHeight: number;
columns: number;
}

export interface Tilemap {
columns: number;
rows: number;
tileset: Tileset;
/** 行主序,0 = 空 */
cells: Uint16Array;
}

export function cellAt(map: Tilemap, tx: number, ty: number): number {
if (tx < 0 || ty < 0 || tx >= map.columns || ty >= map.rows) return 0;
return map.cells[ty * map.columns + tx] ?? 0;
}

export function blitTile(
ctx: CanvasRenderingContext2D,
set: Tileset,
gid: number,
dx: number,
dy: number,
): void {
if (gid <= 0) return;
const index = gid - 1;
const sx = (index % set.columns) * set.tileWidth;
const sy = Math.floor(index / set.columns) * set.tileHeight;
ctx.drawImage(
set.bitmap,
sx,
sy,
set.tileWidth,
set.tileHeight,
dx,
dy,
set.tileWidth,
set.tileHeight,
);
}

世界单位 = 瓦片像素(tileWidth)。相机 pan 用浮点,绘制时 不要 对每个 tile 再 round 到 CSS 像素——在 scale(dpr) 后对 整个相机平移 对齐一次,避免缝:

export function cameraToCtmSnapped(camera: Camera, dpr: number): DOMMatrix {
const e = Math.round(-camera.panX * camera.scale * dpr) / dpr;
const f = Math.round(-camera.panY * camera.scale * dpr) / dpr;
return new DOMMatrix().scale(dpr, dpr).translate(e, f).scale(camera.scale, camera.scale);
}

imageSmoothingEnabled = false:像素风。整数缩放(2、3)才干净;1.5x 仍会抖。

2. 视口裁剪(每帧必做)

export function visibleTileRange(
camera: Camera,
viewW: number,
viewH: number,
map: Tilemap,
): { tx0: number; ty0: number; tx1: number; ty1: number } {
const a = viewToWorld(camera, 0, 0);
const b = viewToWorld(camera, viewW, viewH);
const tw = map.tileset.tileWidth;
const th = map.tileset.tileHeight;
return {
tx0: Math.max(0, Math.floor(Math.min(a.x, b.x) / tw) - 1),
ty0: Math.max(0, Math.floor(Math.min(a.y, b.y) / th) - 1),
tx1: Math.min(map.columns - 1, Math.ceil(Math.max(a.x, b.x) / tw) + 1),
ty1: Math.min(map.rows - 1, Math.ceil(Math.max(a.y, b.y) / th) + 1),
};
}

export function paintTilemap(
ctx: CanvasRenderingContext2D,
map: Tilemap,
range: ReturnType<typeof visibleTileRange>,
): void {
const tw = map.tileset.tileWidth;
const th = map.tileset.tileHeight;
ctx.imageSmoothingEnabled = false;
for (let ty = range.ty0; ty <= range.ty1; ty += 1) {
for (let tx = range.tx0; tx <= range.tx1; tx += 1) {
blitTile(ctx, map.tileset, cellAt(map, tx, ty), tx * tw, ty * th);
}
}
}

100×100 地图若不裁,每帧 1 万次 drawImage,2D 会先死在 JS 调用上。可见 40×25 才是预算。

3. Chunk 缓存

静态层(地面)按 16×16 格烘 ImageBitmap,视口只 blit chunk。动画格、可破坏墙 不要进 chunk,留动态层。改一块静态砖:只废对应 chunk。

多层:ground / deco / collision 可视化分 canvas 或分 pass。碰撞数据是 Uint8Array 或多边形,不要 从像素读「这格能不能走」。

4. 实体与相机跟随

export function followDeadzone(
camera: Camera,
targetX: number,
targetY: number,
viewW: number,
viewH: number,
margin: number,
): Camera {
const view = worldToView(camera, targetX, targetY);
let { panX, panY } = camera;
if (view.x < margin) panX -= (margin - view.x) / camera.scale;
if (view.x > viewW - margin) panX += (view.x - (viewW - margin)) / camera.scale;
if (view.y < margin) panY -= (margin - view.y) / camera.scale;
if (view.y > viewH - margin) panY += (view.y - (viewH - margin)) / camera.scale;
return { ...camera, panX, panY };
}

物理积分用 rAF dt 夹紧。精灵帧用 精灵表 的时间基准,不要 frame++ 每帧。

输入:键盘状态表 + 指针走状态机。游戏 canvas tabIndex=0 聚焦;方向键不要让页面滚(preventDefault 仅在聚焦时)。

5. 何时离开 Canvas 2D

信号行动
可见 tile 仍掉帧(阴影、每格独立 filter)去掉每格效果,改 chunk
实体过千还要混合光照Pixi / WebGL
需要 shader 水/法线不是 2D 的活
只要正交 RPG2D 足够

6. 失败形态

症状原因
格子间 1px 缝浮点相机 + smoothing,未 snap
地图越大越卡没按可见 range 画
破坏一块全图闪整张烘成一张 bitmap
角色陷进墙碰撞用了绘制像素
高刷角色变快动画按帧不按时间

权威资料

核对日期:2026-08-26