pin_drop当前位置:知识文库 ❯ 图文
游戏开发:Canvas游戏绘图基础 - 从入门到实践详解
教程简介
Canvas是HTML5游戏开发中最核心的绘图技术。无论是简单的2D游戏还是复杂的粒子系统,Canvas 2D API都提供了丰富的绘图能力。本教程将系统讲解Canvas游戏绘图的各项基础技术,包括游戏画布设置、坐标系统、绘制游戏对象、图层管理、视口与摄像机、双缓冲技术以及Canvas状态管理,帮助你掌握游戏渲染的核心技能。
核心概念
游戏画布设置
Canvas画布是游戏渲染的载体,正确的设置能确保游戏在不同设备上都有良好的显示效果。
基础设置要点:
画布分辨率(width/height属性)与CSS显示尺寸分离
高DPI屏幕适配
全屏模式支持
响应式布局
代码示例
// 基础画布设置
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// 高DPI适配
function setupCanvas(canvas, width, height) {
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return ctx;
}坐标系统
Canvas使用左上角为原点的坐标系统,X轴向右延伸,Y轴向下延伸。
代码示例
(0,0) X
(x,y)
Y坐标变换:
translate(x, y):平移坐标系原点rotate(angle):旋转坐标系(弧度制)scale(sx, sy):缩放坐标系
变换顺序很重要:Canvas变换是按照代码顺序依次应用的,但效果是反向叠加的。
代码示例
// 变换顺序影响结果
ctx.save();
ctx.translate(100, 100); // 先平移
ctx.rotate(Math.PI / 4); // 再旋转
// 此时围绕(100,100)旋转
ctx.fillRect(-25, -25, 50, 50);
ctx.restore();绘制游戏对象
Canvas 2D API提供了丰富的绘图方法:
基本形状:
fillRect(x, y, w, h):填充矩形strokeRect(x, y, w, h):描边矩形clearRect(x, y, w, h):清除矩形区域
路径绘制:
beginPath():开始新路径closePath():关闭当前路径moveTo(x, y):移动画笔lineTo(x, y):画直线arc(x, y, r, startAngle, endAngle):画圆弧fill():填充路径stroke():描边路径
样式设置:
fillStyle:填充颜色/渐变/图案strokeStyle:描边颜色lineWidth:线宽globalAlpha:全局透明度shadowColor/shadowBlur/shadowOffsetX/shadowOffsetY:阴影
图层管理
Canvas本身不支持图层,但可以通过以下方式模拟:
绘制顺序:后绘制的覆盖先绘制的,通过控制绘制顺序实现简单的图层效果
多个Canvas叠加:使用多个Canvas元素叠加,分别渲染不同层
离屏Canvas:使用离屏Canvas预渲染静态内容
代码示例
// 多Canvas图层
const bgCanvas = document.getElementById('bgLayer'); // 背景层
const gameCanvas = document.getElementById('gameLayer'); // 游戏层
const uiCanvas = document.getElementById('uiLayer'); // UI层
// 只在需要时更新背景层
function renderBackground() { /* ... */ }
// 每帧更新游戏层
function renderGame() { /* ... */ }
// 只在UI变化时更新UI层
function renderUI() { /* ... */ }视口与摄像机
摄像机系统决定了游戏世界的哪部分显示在屏幕上:
代码示例
class Camera {
constructor(width, height) {
this.x = 0;
this.y = 0;
this.width = width;
this.height = height;
this.targetX = 0;
this.targetY = 0;
this.smoothing = 0.1;
}
follow(target) {
this.targetX = target.x - this.width / 2;
this.targetY = target.y - this.height / 2;
}
update() {
// 平滑跟随
this.x += (this.targetX - this.x) * this.smoothing;
this.y += (this.targetY - this.y) * this.smoothing;
}
apply(ctx) {
ctx.translate(-this.x, -this.y);
}
isVisible(obj) {
return obj.x + obj.width > this.x &&
obj.x < this.x + this.width &&
obj.y + obj.height > this.y &&
obj.y < this.y + this.height;
}
}双缓冲技术
双缓冲通过在离屏Canvas上完成绘制,然后一次性复制到显示Canvas,避免闪烁:
代码示例
class DoubleBuffer {
constructor(width, height) {
this.displayCanvas = document.getElementById('gameCanvas');
this.displayCtx = this.displayCanvas.getContext('2d');
this.bufferCanvas = document.createElement('canvas');
this.bufferCanvas.width = width;
this.bufferCanvas.height = height;
this.bufferCtx = this.bufferCanvas.getContext('2d');
}
getBuffer() {
return this.bufferCtx;
}
flip() {
this.displayCtx.drawImage(this.bufferCanvas, 0, 0);
}
}提示:注意:现代浏览器已经内置了双缓冲机制,通常不需要手动实现。但在某些复杂场景下,使用离屏Canvas仍然有性能优势。
Canvas状态管理
Canvas使用栈结构管理绘图状态:
save():将当前状态压入栈restore():从栈中弹出并恢复状态
保存的状态包括:变换矩阵、裁剪区域、strokeStyle、fillStyle、globalAlpha、lineWidth、lineCap、lineJoin、miterLimit、shadowOffsetX、shadowOffsetY、shadowBlur、shadowColor、globalCompositeOperation、font、textAlign、textBaseline等。
代码示例
// 正确使用save/restore
function drawPlayer(ctx, player) {
ctx.save();
ctx.translate(player.x, player.y);
ctx.rotate(player.angle);
ctx.fillStyle = player.color;
ctx.fillRect(-player.width / 2, -player.height / 2, player.width, player.height);
ctx.restore(); // 恢复到save之前的状态
}语法与用法
完整的Canvas游戏绘图框架
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas游戏绘图框架</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #111; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: 'Segoe UI', sans-serif; color: #fff; overflow: hidden; }
#gameContainer { position: relative; }
canvas { display: block; border-radius: 8px; }
#ui { position: absolute; top: 10px; left: 10px; pointer-events: none; }
.hud { background: rgba(0,0,0,0.6); padding: 8px 14px; border-radius: 6px; font-size: 13px; margin-bottom: 6px; }
</style>
</head>
<body>
<div id="gameContainer">
<canvas id="gameCanvas"></canvas>
<div id="ui">
<div class="hud" id="fpsDisplay">FPS: 0</div>
<div class="hud" id="posDisplay">位置: (0, 0)</div>
</div>
</div>
<script>
// ========== 画布管理器 ==========
class CanvasManager {
constructor(canvasId, gameWidth, gameHeight) {
this.canvas = document.getElementById(canvasId);
this.gameWidth = gameWidth;
this.gameHeight = gameHeight;
this.dpr = window.devicePixelRatio || 1;
this.setupCanvas();
window.addEventListener('resize', () => this.setupCanvas());
}
setupCanvas() {
const maxW = window.innerWidth - 20;
const maxH = window.innerHeight - 20;
const scale = Math.min(maxW / this.gameWidth, maxH / this.gameHeight, 1);
const displayW = this.gameWidth * scale;
const displayH = this.gameHeight * scale;
this.canvas.width = this.gameWidth * this.dpr;
this.canvas.height = this.gameHeight * this.dpr;
this.canvas.style.width = displayW + 'px';
this.canvas.style.height = displayH + 'px';
this.ctx = this.canvas.getContext('2d');
this.ctx.scale(this.dpr, this.dpr);
this.displayScale = scale;
}
getCtx() { return this.ctx; }
}
// ========== 摄像机 ==========
class Camera {
constructor(viewWidth, viewHeight) {
this.x = 0;
this.y = 0;
this.viewWidth = viewWidth;
this.viewHeight = viewHeight;
this.target = null;
this.smoothing = 0.08;
this.bounds = null;
}
follow(target) {
this.target = target;
}
setBounds(minX, minY, maxX, maxY) {
this.bounds = { minX, minY, maxX, maxY };
}
update() {
if (!this.target) return;
const targetX = this.target.x - this.viewWidth / 2;
const targetY = this.target.y - this.viewHeight / 2;
this.x += (targetX - this.x) * this.smoothing;
this.y += (targetY - this.y) * this.smoothing;
if (this.bounds) {
this.x = Math.max(this.bounds.minX, Math.min(this.x, this.bounds.maxX - this.viewWidth));
this.y = Math.max(this.bounds.minY, Math.min(this.y, this.bounds.maxY - this.viewHeight));
}
}
applyTransform(ctx) {
ctx.save();
ctx.translate(-Math.round(this.x), -Math.round(this.y));
}
restoreTransform(ctx) {
ctx.restore();
}
screenToWorld(screenX, screenY) {
return {
x: screenX / this.displayScale + this.x,
y: screenY / this.displayScale + this.y
};
}
isVisible(x, y, w, h) {
return x + w > this.x && x < this.x + this.viewWidth &&
y + h > this.y && y < this.y + this.viewHeight;
}
}
// ========== 游戏对象 ==========
class GameObject {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.vx = 0;
this.vy = 0;
this.rotation = 0;
this.alpha = 1;
this.scale = 1;
this.visible = true;
this.zIndex = 0;
}
update(dt) {}
render(ctx) {}
getBounds() {
return { x: this.x, y: this.y, width: this.width, height: this.height };
}
}
// ========== 星空背景 ==========
class StarField extends GameObject {
constructor(worldWidth, worldHeight) {
super(0, 0, worldWidth, worldHeight);
this.stars = [];
this.zIndex = -10;
for (let i = 0; i < 200; i++) {
this.stars.push({
x: Math.random() * worldWidth,
y: Math.random() * worldHeight,
size: Math.random() * 2 + 0.5,
brightness: Math.random() * 0.5 + 0.5,
twinkleSpeed: Math.random() * 2 + 1,
parallax: Math.random() * 0.5 + 0.3
});
}
}
render(ctx, camera, time) {
for (const star of this.stars) {
const parallaxX = star.x - camera.x * star.parallax;
const parallaxY = star.y - camera.y * star.parallax;
const screenX = ((parallaxX % this.width) + this.width) % this.width;
const screenY = ((parallaxY % this.height) + this.height) % this.height;
const twinkle = Math.sin(time * star.twinkleSpeed) * 0.3 + 0.7;
const alpha = star.brightness * twinkle;
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.beginPath();
ctx.arc(screenX + camera.x, screenY + camera.y, star.size, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ========== 玩家飞船 ==========
class PlayerShip extends GameObject {
constructor(x, y) {
super(x, y, 40, 40);
this.speed = 250;
this.thrustParticles = [];
this.zIndex = 10;
}
update(dt, keys) {
let ax = 0, ay = 0;
if (keys['ArrowLeft'] || keys['KeyA']) ax = -1;
if (keys['ArrowRight'] || keys['KeyD']) ax = 1;
if (keys['ArrowUp'] || keys['KeyW']) ay = -1;
if (keys['ArrowDown'] || keys['KeyS']) ay = 1;
// 归一化对角线移动
if (ax !== 0 && ay !== 0) {
ax *= 0.707;
ay *= 0.707;
}
this.vx += (ax * this.speed - this.vx) * 5 * dt;
this.vy += (ay * this.speed - this.vy) * 5 * dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
// 朝向跟随速度方向
if (Math.abs(this.vx) > 10 || Math.abs(this.vy) > 10) {
const targetAngle = Math.atan2(this.vy, this.vx);
let diff = targetAngle - this.rotation;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
this.rotation += diff * 8 * dt;
}
// 推进粒子
if (Math.abs(this.vx) > 20 || Math.abs(this.vy) > 20) {
this.thrustParticles.push({
x: this.x - Math.cos(this.rotation) * 20,
y: this.y - Math.sin(this.rotation) * 20,
vx: -this.vx * 0.3 + (Math.random() - 0.5) * 40,
vy: -this.vy * 0.3 + (Math.random() - 0.5) * 40,
life: 1,
size: 2 + Math.random() * 3
});
}
// 更新粒子
for (let i = this.thrustParticles.length - 1; i >= 0; i--) {
const p = this.thrustParticles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt * 3;
if (p.life <= 0) this.thrustParticles.splice(i, 1);
}
}
render(ctx) {
// 推进粒子
for (const p of this.thrustParticles) {
ctx.globalAlpha = p.life * 0.6;
ctx.fillStyle = `hsl(${30 + (1 - p.life) * 30}, 100%, 60%)`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// 飞船主体
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.rotation);
// 引擎光晕
const glow = ctx.createRadialGradient(-15, 0, 0, -15, 0, 25);
glow.addColorStop(0, 'rgba(0, 150, 255, 0.3)');
glow.addColorStop(1, 'rgba(0, 150, 255, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(-15, 0, 25, 0, Math.PI * 2);
ctx.fill();
// 船体
ctx.fillStyle = '#4a90d9';
ctx.beginPath();
ctx.moveTo(22, 0);
ctx.lineTo(-14, -14);
ctx.lineTo(-8, 0);
ctx.lineTo(-14, 14);
ctx.closePath();
ctx.fill();
// 驾驶舱
ctx.fillStyle = '#8ec5fc';
ctx.beginPath();
ctx.arc(4, 0, 5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// ========== 小行星 ==========
class Asteroid extends GameObject {
constructor(x, y, size) {
super(x, y, size * 2, size * 2);
this.size = size;
this.rotation = Math.random() * Math.PI * 2;
this.rotationSpeed = (Math.random() - 0.5) * 2;
this.vx = (Math.random() - 0.5) * 60;
this.vy = (Math.random() - 0.5) * 60;
this.vertices = this.generateVertices();
this.color = `hsl(${20 + Math.random() * 20}, ${30 + Math.random() * 20}%, ${30 + Math.random() * 20}%)`;
this.zIndex = 5;
}
generateVertices() {
const points = [];
const numVertices = 8 + Math.floor(Math.random() * 5);
for (let i = 0; i < numVertices; i++) {
const angle = (i / numVertices) * Math.PI * 2;
const r = this.size * (0.7 + Math.random() * 0.3);
points.push({ x: Math.cos(angle) * r, y: Math.sin(angle) * r });
}
return points;
}
update(dt) {
this.x += this.vx * dt;
this.y += this.vy * dt;
this.rotation += this.rotationSpeed * dt;
}
render(ctx) {
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.rotation);
ctx.fillStyle = this.color;
ctx.strokeStyle = '#555';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(this.vertices[0].x, this.vertices[0].y);
for (let i = 1; i < this.vertices.length; i++) {
ctx.lineTo(this.vertices[i].x, this.vertices[i].y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
}
}
// ========== 游戏主类 ==========
class Game {
constructor() {
this.WORLD_WIDTH = 3000;
this.WORLD_HEIGHT = 2000;
this.canvasManager = new CanvasManager('gameCanvas', 800, 600);
this.camera = new Camera(800, 600);
this.camera.setBounds(0, 0, this.WORLD_WIDTH, this.WORLD_HEIGHT);
this.player = new PlayerShip(this.WORLD_WIDTH / 2, this.WORLD_HEIGHT / 2);
this.camera.follow(this.player);
this.starField = new StarField(this.WORLD_WIDTH, this.WORLD_HEIGHT);
this.asteroids = [];
this.generateAsteroids();
this.keys = {};
this.lastTime = 0;
this.fps = 0;
this.frameCount = 0;
this.fpsTimer = 0;
this.gameTime = 0;
this.setupInput();
}
generateAsteroids() {
for (let i = 0; i < 30; i++) {
const size = 15 + Math.random() * 35;
this.asteroids.push(new Asteroid(
Math.random() * this.WORLD_WIDTH,
Math.random() * this.WORLD_HEIGHT,
size
));
}
}
setupInput() {
document.addEventListener('keydown', (e) => {
this.keys[e.code] = true;
});
document.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
});
}
start() {
this.lastTime = performance.now();
requestAnimationFrame((t) => this.loop(t));
}
loop(currentTime) {
const dt = Math.min((currentTime - this.lastTime) / 1000, 0.1);
this.lastTime = currentTime;
this.gameTime += dt;
this.frameCount++;
this.fpsTimer += dt;
if (this.fpsTimer >= 1) {
this.fps = this.frameCount;
this.frameCount = 0;
this.fpsTimer = 0;
}
this.update(dt);
this.render();
requestAnimationFrame((t) => this.loop(t));
}
update(dt) {
this.player.update(dt, this.keys);
// 限制玩家在世界范围内
this.player.x = Math.max(20, Math.min(this.player.x, this.WORLD_WIDTH - 20));
this.player.y = Math.max(20, Math.min(this.player.y, this.WORLD_HEIGHT - 20));
for (const asteroid of this.asteroids) {
asteroid.update(dt);
// 环绕世界边界
if (asteroid.x < -50) asteroid.x = this.WORLD_WIDTH + 50;
if (asteroid.x > this.WORLD_WIDTH + 50) asteroid.x = -50;
if (asteroid.y < -50) asteroid.y = this.WORLD_HEIGHT + 50;
if (asteroid.y > this.WORLD_HEIGHT + 50) asteroid.y = -50;
}
this.camera.update();
}
render() {
const ctx = this.canvasManager.getCtx();
// 清除画面
ctx.fillStyle = '#050510';
ctx.fillRect(0, 0, 800, 600);
// 应用摄像机变换
this.camera.applyTransform(ctx);
// 星空背景
this.starField.render(ctx, this.camera, this.gameTime);
// 世界边界
ctx.strokeStyle = '#1a3a5a';
ctx.lineWidth = 2;
ctx.strokeRect(0, 0, this.WORLD_WIDTH, this.WORLD_HEIGHT);
// 网格
ctx.strokeStyle = '#0a1a2a';
ctx.lineWidth = 0.5;
for (let x = 0; x < this.WORLD_WIDTH; x += 100) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, this.WORLD_HEIGHT);
ctx.stroke();
}
for (let y = 0; y < this.WORLD_HEIGHT; y += 100) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(this.WORLD_WIDTH, y);
ctx.stroke();
}
// 小行星
for (const asteroid of this.asteroids) {
if (this.camera.isVisible(asteroid.x - asteroid.size, asteroid.y - asteroid.size,
asteroid.size * 2, asteroid.size * 2)) {
asteroid.render(ctx);
}
}
// 玩家
this.player.render(ctx);
// 恢复摄像机变换
this.camera.restoreTransform(ctx);
// 小地图
this.renderMinimap(ctx);
// 更新HUD
document.getElementById('fpsDisplay').textContent = `FPS: ${this.fps}`;
document.getElementById('posDisplay').textContent =
`位置: (${Math.round(this.player.x)}, ${Math.round(this.player.y)})`;
}
renderMinimap(ctx) {
const mapW = 160;
const mapH = 107;
const mapX = 800 - mapW - 10;
const mapY = 10;
const scaleX = mapW / this.WORLD_WIDTH;
const scaleY = mapH / this.WORLD_HEIGHT;
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(mapX, mapY, mapW, mapH);
ctx.strokeStyle = '#4a90d9';
ctx.lineWidth = 1;
ctx.strokeRect(mapX, mapY, mapW, mapH);
// 小行星
ctx.fillStyle = '#666';
for (const asteroid of this.asteroids) {
ctx.fillRect(
mapX + asteroid.x * scaleX - 1,
mapY + asteroid.y * scaleY - 1,
3, 3
);
}
// 摄像机视口
ctx.strokeStyle = '#fff';
ctx.lineWidth = 0.5;
ctx.strokeRect(
mapX + this.camera.x * scaleX,
mapY + this.camera.y * scaleY,
this.camera.viewWidth * scaleX,
this.camera.viewHeight * scaleY
);
// 玩家
ctx.fillStyle = '#4a90d9';
ctx.beginPath();
ctx.arc(
mapX + this.player.x * scaleX,
mapY + this.player.y * scaleY,
3, 0, Math.PI * 2
);
ctx.fill();
}
}
const game = new Game();
game.start();
</script>
</body>
</html>Canvas状态管理与变换示例
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas状态管理与变换</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #111; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: 'Segoe UI', sans-serif; color: #fff; }
canvas { border: 2px solid #333; border-radius: 8px; }
.info { margin-top: 12px; font-size: 13px; color: #888; text-align: center; }
</style>
</head>
<body>
<canvas id="gameCanvas" width="800" height="600"></canvas>
<div class="info">演示Canvas状态管理(save/restore)、坐标变换(translate/rotate/scale)、混合模式</div>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
let time = 0;
let mouseX = 400, mouseY = 300;
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = (e.clientX - rect.left) * (800 / rect.width);
mouseY = (e.clientY - rect.top) * (600 / rect.height);
});
function drawGeometricPattern(cx, cy, time) {
const numRings = 6;
const numSegments = 12;
for (let ring = 0; ring < numRings; ring++) {
const radius = 40 + ring * 35;
const rotationOffset = time * (ring % 2 === 0 ? 1 : -1) * 0.5;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(rotationOffset);
for (let i = 0; i < numSegments; i++) {
const angle = (i / numSegments) * Math.PI * 2;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
const size = 8 + Math.sin(time * 2 + ring + i) * 4;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle + time * 2);
// 颜色随环和段变化
const hue = (ring * 40 + i * 30 + time * 50) % 360;
ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;
ctx.strokeStyle = `hsla(${hue}, 80%, 80%, 0.5)`;
ctx.lineWidth = 1;
// 交替绘制不同形状
if (i % 3 === 0) {
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.strokeRect(-size / 2, -size / 2, size, size);
} else if (i % 3 === 1) {
ctx.beginPath();
ctx.arc(0, 0, size / 2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(0, -size / 2);
ctx.lineTo(size / 2, size / 2);
ctx.lineTo(-size / 2, size / 2);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
// 连接线
ctx.strokeStyle = `hsla(${ring * 60 + time * 30}, 60%, 50%, 0.2)`;
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let i = 0; i <= numSegments; i++) {
const angle = (i / numSegments) * Math.PI * 2;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
}
}
function drawParticleTrail(cx, cy, time) {
const numParticles = 80;
for (let i = 0; i < numParticles; i++) {
const t = time + i * 0.1;
const angle = t * 0.8 + i * 0.3;
const radius = 100 + Math.sin(t * 0.5) * 80;
const x = cx + Math.cos(angle) * radius;
const y = cy + Math.sin(angle) * radius;
const size = 2 + Math.sin(t * 3) * 1.5;
const alpha = 0.3 + Math.sin(t * 2) * 0.2;
ctx.fillStyle = `hsla(${(i * 4 + time * 60) % 360}, 90%, 70%, ${alpha})`;
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fill();
}
}
function drawMouseEffect(mx, my, time) {
// 鼠标光晕
const gradient = ctx.createRadialGradient(mx, my, 0, mx, my, 80);
gradient.addColorStop(0, 'rgba(74, 144, 217, 0.15)');
gradient.addColorStop(1, 'rgba(74, 144, 217, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(mx, my, 80, 0, Math.PI * 2);
ctx.fill();
// 鼠标周围的旋转方块
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2 + time * 2;
const dist = 30 + Math.sin(time * 3 + i) * 10;
const x = mx + Math.cos(angle) * dist;
const y = my + Math.sin(angle) * dist;
ctx.save();
ctx.translate(x, y);
ctx.rotate(time * 3 + i);
ctx.fillStyle = `hsla(${i * 45 + time * 100}, 80%, 60%, 0.7)`;
ctx.fillRect(-4, -4, 8, 8);
ctx.restore();
}
}
function render(timestamp) {
time = timestamp / 1000;
// 清除画面(带拖尾效果)
ctx.fillStyle = 'rgba(5, 5, 16, 0.15)';
ctx.fillRect(0, 0, 800, 600);
// 几何图案
drawGeometricPattern(400, 300, time);
// 粒子轨迹
drawParticleTrail(400, 300, time);
// 鼠标效果
drawMouseEffect(mouseX, mouseY, time);
// 中心文字
ctx.save();
ctx.translate(400, 300);
ctx.globalAlpha = 0.3 + Math.sin(time) * 0.1;
ctx.fillStyle = '#fff';
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Canvas State & Transform', 0, 0);
ctx.restore();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
</script>
</body>
</html>离屏Canvas与图层渲染
代码示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离屏Canvas与图层渲染</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a1a; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: 'Segoe UI', sans-serif; color: #fff; }
canvas { border: 2px solid #2a2a4a; border-radius: 8px; }
.controls { margin-top: 12px; display: flex; gap: 12px; }
button { padding: 6px 16px; border: 1px solid #4a90d9; background: transparent; color: #4a90d9; border-radius: 6px; cursor: pointer; font-size: 13px; }
button:hover { background: #4a90d922; }
button.active { background: #4a90d9; color: #fff; }
</style>
</head>
<body>
<canvas id="gameCanvas" width="800" height="600"></canvas>
<div class="controls">
<button id="toggleBg" class="active">背景层</button>
<button id="toggleTerrain" class="active">地形层</button>
<button id="toggleObjects" class="active">对象层</button>
<button id="toggleEffects" class="active">特效层</button>
<button id="toggleUI" class="active">UI层</button>
</div>
<script>
class LayeredRenderer {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.width = this.canvas.width;
this.height = this.canvas.height;
// 创建离屏Canvas图层
this.layers = {
background: this.createOffscreenCanvas(),
terrain: this.createOffscreenCanvas(),
objects: this.createOffscreenCanvas(),
effects: this.createOffscreenCanvas(),
ui: this.createOffscreenCanvas()
};
this.layerVisibility = {
background: true,
terrain: true,
objects: true,
effects: true,
ui: true
};
this.terrainDirty = true;
this.time = 0;
this.particles = [];
this.player = { x: 400, y: 400, vx: 0, vy: 0, size: 20 };
this.setupControls();
this.setupInput();
this.generateTerrain();
}
createOffscreenCanvas() {
const canvas = document.createElement('canvas');
canvas.width = this.width;
canvas.height = this.height;
return canvas;
}
setupControls() {
const buttons = {
toggleBg: 'background',
toggleTerrain: 'terrain',
toggleObjects: 'objects',
toggleEffects: 'effects',
toggleUI: 'ui'
};
for (const [id, layer] of Object.entries(buttons)) {
document.getElementById(id).addEventListener('click', (e) => {
this.layerVisibility[layer] = !this.layerVisibility[layer];
e.target.classList.toggle('active');
});
}
}
setupInput() {
this.keys = {};
document.addEventListener('keydown', (e) => { this.keys[e.code] = true; });
document.addEventListener('keyup', (e) => { this.keys[e.code] = false; });
}
generateTerrain() {
const ctx = this.layers.terrain.getContext('2d');
// 地面
ctx.fillStyle = '#1a3a2a';
ctx.fillRect(0, 450, 800, 150);
// 山丘
ctx.fillStyle = '#1a4a2a';
ctx.beginPath();
ctx.moveTo(0, 450);
for (let x = 0; x <= 800; x += 5) {
const y = 450 - Math.sin(x * 0.01) * 30 - Math.sin(x * 0.03) * 15;
ctx.lineTo(x, y);
}
ctx.lineTo(800, 600);
ctx.lineTo(0, 600);
ctx.closePath();
ctx.fill();
// 草地细节
ctx.strokeStyle = '#2a5a3a';
ctx.lineWidth = 1;
for (let i = 0; i < 100; i++) {
const x = Math.random() * 800;
const baseY = 450 - Math.sin(x * 0.01) * 30 - Math.sin(x * 0.03) * 15;
ctx.beginPath();
ctx.moveTo(x, baseY);
ctx.lineTo(x + (Math.random() - 0.5) * 6, baseY - 5 - Math.random() * 10);
ctx.stroke();
}
// 树木
for (let i = 0; i < 8; i++) {
const tx = 50 + i * 100 + Math.random() * 40;
const baseY = 450 - Math.sin(tx * 0.01) * 30 - Math.sin(tx * 0.03) * 15;
// 树干
ctx.fillStyle = '#3a2a1a';
ctx.fillRect(tx - 4, baseY - 40, 8, 40);
// 树冠
ctx.fillStyle = '#1a5a2a';
ctx.beginPath();
ctx.arc(tx, baseY - 50, 20, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#2a6a3a';
ctx.beginPath();
ctx.arc(tx - 8, baseY - 42, 15, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(tx + 8, baseY - 42, 15, 0, Math.PI * 2);
ctx.fill();
}
this.terrainDirty = false;
}
renderBackground() {
const ctx = this.layers.background.getContext('2d');
// 天空渐变
const skyGrad = ctx.createLinearGradient(0, 0, 0, 450);
skyGrad.addColorStop(0, '#0a0a2a');
skyGrad.addColorStop(0.5, '#1a1a4a');
skyGrad.addColorStop(1, '#2a2a5a');
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, 800, 450);
// 星星
for (let i = 0; i < 50; i++) {
const sx = (i * 137.5) % 800;
const sy = (i * 73.7) % 350;
const brightness = 0.3 + Math.sin(this.time * 2 + i) * 0.2;
ctx.fillStyle = `rgba(255, 255, 255, ${brightness})`;
ctx.beginPath();
ctx.arc(sx, sy, 1, 0, Math.PI * 2);
ctx.fill();
}
// 月亮
ctx.fillStyle = '#ddd';
ctx.beginPath();
ctx.arc(650, 80, 30, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#0a0a2a';
ctx.beginPath();
ctx.arc(640, 75, 25, 0, Math.PI * 2);
ctx.fill();
}
renderObjects() {
const ctx = this.layers.objects.getContext('2d');
ctx.clearRect(0, 0, 800, 600);
const p = this.player;
// 玩家阴影
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.beginPath();
ctx.ellipse(p.x, 448, p.size, 6, 0, 0, Math.PI * 2);
ctx.fill();
// 玩家身体
ctx.fillStyle = '#4a90d9';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
// 眼睛
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(p.x - 6, p.y - 5, 5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(p.x + 6, p.y - 5, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#111';
ctx.beginPath();
ctx.arc(p.x - 5, p.y - 5, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(p.x + 7, p.y - 5, 2.5, 0, Math.PI * 2);
ctx.fill();
}
renderEffects() {
const ctx = this.layers.effects.getContext('2d');
ctx.clearRect(0, 0, 800, 600);
// 更新和渲染粒子
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i];
p.x += p.vx;
p.y += p.vy;
p.vy += 0.1;
p.life -= 0.02;
if (p.life <= 0) {
this.particles.splice(i, 1);
continue;
}
ctx.globalAlpha = p.life;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
renderUI() {
const ctx = this.layers.ui.getContext('2d');
ctx.clearRect(0, 0, 800, 600);
// 图层标签
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(10, 10, 150, 30);
ctx.fillStyle = '#fff';
ctx.font = '13px monospace';
ctx.textAlign = 'left';
ctx.fillText('离屏Canvas图层渲染', 18, 30);
// 图层指示器
const layerNames = ['背景', '地形', '对象', '特效', 'UI'];
const layerKeys = ['background', 'terrain', 'objects', 'effects', 'ui'];
for (let i = 0; i < layerNames.length; i++) {
const x = 10 + i * 60;
const y = 550;
ctx.fillStyle = this.layerVisibility[layerKeys[i]] ? '#4a90d9' : '#333';
ctx.fillRect(x, y, 50, 20);
ctx.fillStyle = '#fff';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(layerNames[i], x + 25, y + 14);
}
}
update(dt) {
this.time += dt;
const p = this.player;
const speed = 200;
if (this.keys['ArrowLeft'] || this.keys['KeyA']) p.vx = -speed;
else if (this.keys['ArrowRight'] || this.keys['KeyD']) p.vx = speed;
else p.vx *= 0.85;
if (this.keys['ArrowUp'] || this.keys['KeyW']) p.vy = -speed;
else if (this.keys['ArrowDown'] || this.keys['KeyS']) p.vy = speed;
else p.vy *= 0.85;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.x = Math.max(p.size, Math.min(p.x, 800 - p.size));
p.y = Math.max(p.size, Math.min(p.y, 440 - p.size));
// 移动时产生粒子
if (Math.abs(p.vx) > 20 || Math.abs(p.vy) > 20) {
this.particles.push({
x: p.x + (Math.random() - 0.5) * 10,
y: p.y + p.size,
vx: -p.vx * 0.05 + (Math.random() - 0.5) * 2,
vy: -1 - Math.random() * 2,
size: 2 + Math.random() * 3,
life: 1,
color: `hsl(${200 + Math.random() * 40}, 80%, 60%)`
});
}
}
render() {
// 渲染各图层到离屏Canvas
this.renderBackground();
// 地形层只在需要时重绘
if (this.terrainDirty) this.generateTerrain();
this.renderObjects();
this.renderEffects();
this.renderUI();
// 合成所有图层到主Canvas
this.ctx.clearRect(0, 0, 800, 600);
const layerOrder = ['background', 'terrain', 'objects', 'effects', 'ui'];
for (const layerName of layerOrder) {
if (this.layerVisibility[layerName]) {
this.ctx.drawImage(this.layers[layerName], 0, 0);
}
}
}
}
const renderer = new LayeredRenderer('gameCanvas');
let lastTime = performance.now();
function loop(currentTime) {
const dt = Math.min((currentTime - lastTime) / 1000, 0.1);
lastTime = currentTime;
renderer.update(dt);
renderer.render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>浏览器兼容性
注意事项与最佳实践
避免频繁切换Canvas状态:每次修改fillStyle、strokeStyle等属性都有开销,尽量批量绘制相同样式的对象。
合理使用save/restore:虽然方便,但频繁调用也有性能开销。在简单变换中,可以手动恢复而非使用save/restore。
整数坐标渲染:在非高DPI屏幕上,亚像素渲染会导致模糊。使用
Math.round()或位运算|0将坐标对齐到整数像素。使用离屏Canvas缓存静态内容:背景、UI等不常变化的内容应预渲染到离屏Canvas,每帧只需drawImage一次。
减少drawImage调用:使用精灵图将多个小图合并为一张大图,减少绘制调用次数。
裁剪不可见对象:在摄像机系统外的不需要渲染,使用可见性检测跳过。
注意Canvas尺寸:过大的Canvas会消耗大量内存。800x600的Canvas在2x DPI下实际像素为1600x1200,占用约7.3MB内存。
使用requestAnimationFrame而非setInterval:确保与浏览器刷新率同步。
代码规范示例
代码示例
/**
* Canvas绘图工具类
* 封装常用的绘图操作,提供链式调用
*/
class CanvasHelper {
/**
* @param {CanvasRenderingContext2D} ctx - Canvas 2D上下文
*/
constructor(ctx) {
this.ctx = ctx;
this._saved = false;
}
/** 保存状态 */
save() {
this.ctx.save();
this._saved = true;
return this;
}
/** 恢复状态 */
restore() {
this.ctx.restore();
this._saved = false;
return this;
}
/**
* 平移坐标系
* @param {number} x - X偏移
* @param {number} y - Y偏移
*/
translate(x, y) {
this.ctx.translate(x, y);
return this;
}
/**
* 旋转坐标系
* @param {number} angle - 旋转角度(弧度)
*/
rotate(angle) {
this.ctx.rotate(angle);
return this;
}
/**
* 绘制填充矩形
* @param {number} x - X坐标
* @param {number} y - Y坐标
* @param {number} w - 宽度
* @param {number} h - 高度
* @param {string} color - 填充颜色
*/
fillRect(x, y, w, h, color) {
this.ctx.fillStyle = color;
this.ctx.fillRect(x, y, w, h);
return this;
}
/**
* 绘制圆形
* @param {number} x - 圆心X
* @param {number} y - 圆心Y
* @param {number} r - 半径
* @param {string} color - 填充颜色
*/
fillCircle(x, y, r, color) {
this.ctx.fillStyle = color;
this.ctx.beginPath();
this.ctx.arc(x, y, r, 0, Math.PI * 2);
this.ctx.fill();
return this;
}
/**
* 绘制带边框的圆角矩形
* @param {number} x - X坐标
* @param {number} y - Y坐标
* @param {number} w - 宽度
* @param {number} h - 高度
* @param {number} r - 圆角半径
* @param {string} fillColor - 填充颜色
* @param {string} strokeColor - 边框颜色
* @param {number} lineWidth - 边框宽度
*/
roundRect(x, y, w, h, r, fillColor, strokeColor, lineWidth) {
const ctx = this.ctx;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
if (fillColor) {
ctx.fillStyle = fillColor;
ctx.fill();
}
if (strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = lineWidth || 1;
ctx.stroke();
}
return this;
}
}常见问题与解决方案
问题1:Canvas绘制模糊
原因:高DPI屏幕上Canvas分辨率与显示尺寸不匹配。
解决方案:根据devicePixelRatio缩放Canvas。
代码示例
function setupHiDPICanvas(canvas, w, h) {
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return ctx;
}问题2:大量对象渲染性能差
原因:每个对象单独绘制,状态切换频繁。
解决方案:按材质/颜色分组批量绘制,使用离屏Canvas缓存。
代码示例
// 按颜色分组批量绘制
function renderEntities(entities) {
const groups = new Map();
for (const e of entities) {
if (!groups.has(e.color)) groups.set(e.color, []);
groups.get(e.color).push(e);
}
for (const [color, group] of groups) {
ctx.fillStyle = color;
for (const e of group) {
ctx.fillRect(e.x, e.y, e.width, e.height);
}
}
}问题3:旋转后绘制位置偏移
原因:旋转是围绕当前坐标原点进行的,需要先平移到对象中心再旋转。
解决方案:使用translate-rotate-draw模式。
代码示例
// 正确的旋转绘制
ctx.save();
ctx.translate(obj.x + obj.width / 2, obj.y + obj.height / 2);
ctx.rotate(obj.angle);
ctx.drawImage(img, -obj.width / 2, -obj.height / 2, obj.width, obj.height);
ctx.restore();问题4:Canvas内存占用过高
原因:Canvas尺寸过大或创建了过多离屏Canvas。
解决方案:控制Canvas尺寸,及时释放不再使用的离屏Canvas,避免创建过多临时Canvas。
问题5:drawImage绘制图片跨域报错
原因:跨域图片被Canvas污染后无法读取像素数据。
解决方案:设置图片crossOrigin属性,服务器配置CORS。
代码示例
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = 'https://example.com/sprite.png';总结
本教程全面讲解了Canvas游戏绘图的基础技术。我们从画布设置和高DPI适配开始,深入理解了Canvas坐标系统和变换操作,学习了各种基本图形的绘制方法,掌握了通过多Canvas叠加和离屏Canvas实现图层管理的技术,实现了摄像机系统来控制视口显示,了解了双缓冲技术的原理和应用,以及Canvas状态管理(save/restore)的正确使用方式。
关键要点:
画布分辨率与CSS显示尺寸分离,高DPI屏幕需要按devicePixelRatio缩放
变换顺序影响最终效果,先平移到中心再旋转是常见模式
使用离屏Canvas缓存静态内容,减少每帧绘制开销
摄像机系统通过translate变换实现视口滚动
save/restore管理Canvas状态栈,避免状态污染
按材质分组批量绘制可以提升渲染性能
裁剪不可见对象减少不必要的绘制
这些知识是后续精灵动画、碰撞检测、完整游戏实现的基础,掌握好Canvas绘图技术将使你在游戏开发中游刃有余。
常见问题
什么是Canvas游戏绘图基础?
Canvas游戏绘图基础是HTML5开发中的重要技术,本教程详细介绍了其核心概念和实践方法。
如何学习Canvas游戏绘图基础的实际应用?
教程中提供了完整的代码示例和实践指导,建议结合示例代码动手练习。
Canvas游戏绘图基础有哪些注意事项?
常见注意事项包括兼容性、性能优化等,教程的注意事项与最佳实践部分有详细说明。
Canvas游戏绘图基础适合初学者吗?
本教程从基础概念讲起,循序渐进,适合有一定HTML和JavaScript基础的初学者。
Canvas游戏绘图基础的核心要点是什么?
核心要点包括教程简介等内容,建议按教程顺序逐步学习。
本文由小确幸生活整理发布,转载请注明出处
本文涉及AI创作
内容由AI创作,请仔细甄别