Canvas 2D游戏开发完整指南,涵盖游戏循环、实体系统、碰撞检测、关卡设计等核心技术。Canvas是实现Web 2D游戏的理想选择,本节将介绍游戏开发的核心概念和实践技巧。
游戏循环是游戏的核心,它不断更新游戏状态并渲染画面:
class GameLoop {
constructor(update, render) {
this.update = update
this.render = render
this.isRunning = false
this.lastTime = 0
this.deltaTime = 0
this.fps = 60
this.frameInterval = 1000 / this.fps
}
start() {
if (this.isRunning) return
this.isRunning = true
this.lastTime = performance.now()
this.loop()
}
stop() {
this.isRunning = false
}
loop(currentTime = performance.now()) {
if (!this.isRunning) return
this.deltaTime = currentTime - this.lastTime
if (this.deltaTime >= this.frameInterval) {
this.lastTime = currentTime - (this.deltaTime % this.frameInterval)
this.update(this.deltaTime / 1000)
this.render()
}
requestAnimationFrame((time) => this.loop(time))
}
}
为了确保游戏在不同设备上表现一致,使用固定时间步长:
class FixedTimeStepGame {
constructor() {
this.accumulator = 0
this.fixedDeltaTime = 1 / 60
this.maxAccumulator = 0.2
}
update(deltaTime) {
this.accumulator += deltaTime
if (this.accumulator > this.maxAccumulator) {
this.accumulator = this.maxAccumulator
}
while (this.accumulator >= this.fixedDeltaTime) {
this.fixedUpdate(this.fixedDeltaTime)
this.accumulator -= this.fixedDeltaTime
}
const alpha = this.accumulator / this.fixedDeltaTime
this.interpolateRender(alpha)
}
fixedUpdate(dt) {
// 物理更新、碰撞检测等
}
interpolateRender(alpha) {
// 使用alpha进行插值渲染
}
}
class GameObject {
constructor(x, y) {
this.x = x
this.y = y
this.vx = 0
this.vy = 0
this.width = 32
this.height = 32
this.rotation = 0
this.scale = 1
this.active = true
this.visible = true
}
update(dt) {
this.x += this.vx * dt
this.y += this.vy * dt
}
render(ctx) {
if (!this.visible) return
ctx.save()
ctx.translate(this.x, this.y)
ctx.rotate(this.rotation)
ctx.scale(this.scale, this.scale)
this.draw(ctx)
ctx.restore()
}
draw(ctx) {
ctx.fillStyle = '#3498db'
ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height)
}
getBounds() {
return {
x: this.x - this.width / 2,
y: this.y - this.height / 2,
width: this.width,
height: this.height
}
}
}
class Sprite extends GameObject {
constructor(x, y, image) {
super(x, y)
this.image = image
this.frameWidth = 32
this.frameHeight = 32
this.frameX = 0
this.frameY = 0
this.animations = new Map()
this.currentAnimation = null
this.animationTime = 0
this.frameDuration = 100
}
addAnimation(name, frames, frameDuration = 100) {
this.animations.set(name, {
frames: frames,
frameDuration: frameDuration,
loop: true
})
}
playAnimation(name) {
if (this.currentAnimation !== name) {
this.currentAnimation = name
this.animationTime = 0
}
}
update(dt) {
super.update(dt)
if (this.currentAnimation) {
const anim = this.animations.get(this.currentAnimation)
if (anim) {
this.animationTime += dt * 1000
let totalDuration = anim.frames.length * anim.frameDuration
if (this.animationTime >= totalDuration) {
if (anim.loop) {
this.animationTime = this.animationTime % totalDuration
} else {
this.animationTime = totalDuration - anim.frameDuration
}
}
const frameIndex = Math.floor(this.animationTime / anim.frameDuration)
const frame = anim.frames[frameIndex]
this.frameX = frame.x
this.frameY = frame.y
}
}
}
draw(ctx) {
if (!this.image) return
ctx.drawImage(
this.image,
this.frameX * this.frameWidth,
this.frameY * this.frameHeight,
this.frameWidth,
this.frameHeight,
-this.width / 2,
-this.height / 2,
this.width,
this.height
)
}
}
function aabbCollision(a, b) {
const boundsA = a.getBounds()
const boundsB = b.getBounds()
return boundsA.x < boundsB.x + boundsB.width &&
boundsA.x + boundsA.width > boundsB.x &&
boundsA.y < boundsB.y + boundsB.height &&
boundsA.y + boundsA.height > boundsB.y
}
function resolveAABBCollision(a, b) {
const boundsA = a.getBounds()
const boundsB = b.getBounds()
const overlapX = Math.min(
boundsA.x + boundsA.width - boundsB.x,
boundsB.x + boundsB.width - boundsA.x
)
const overlapY = Math.min(
boundsA.y + boundsA.height - boundsB.y,
boundsB.y + boundsB.height - boundsA.y
)
if (overlapX < overlapY) {
if (a.x < b.x) {
a.x -= overlapX / 2
b.x += overlapX / 2
} else {
a.x += overlapX / 2
b.x -= overlapX / 2
}
} else {
if (a.y < b.y) {
a.y -= overlapY / 2
b.y += overlapY / 2
} else {
a.y += overlapY / 2
b.y -= overlapY / 2
}
}
}
function circleCollision(a, b) {
const dx = b.x - a.x
const dy = b.y - a.y
const distance = Math.sqrt(dx * dx + dy * dy)
return distance < a.radius + b.radius
}
function resolveCircleCollision(a, b) {
const dx = b.x - a.x
const dy = b.y - a.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance === 0) return
const overlap = (a.radius + b.radius) - distance
const nx = dx / distance
const ny = dy / distance
a.x -= nx * overlap / 2
a.y -= ny * overlap / 2
b.x += nx * overlap / 2
b.y += ny * overlap / 2
const relativeVx = a.vx - b.vx
const relativeVy = a.vy - b.vy
const relativeVelocity = relativeVx * nx + relativeVy * ny
if (relativeVelocity > 0) return
const restitution = 0.8
const impulse = -(1 + restitution) * relativeVelocity / 2
a.vx += impulse * nx
a.vy += impulse * ny
b.vx -= impulse * nx
b.vy -= impulse * ny
}
class SpatialHash {
constructor(cellSize) {
this.cellSize = cellSize
this.cells = new Map()
}
clear() {
this.cells.clear()
}
getKey(x, y) {
const cellX = Math.floor(x / this.cellSize)
const cellY = Math.floor(y / this.cellSize)
return `${cellX},${cellY}`
}
insert(obj) {
const bounds = obj.getBounds()
const startX = Math.floor(bounds.x / this.cellSize)
const endX = Math.floor((bounds.x + bounds.width) / this.cellSize)
const startY = Math.floor(bounds.y / this.cellSize)
const endY = Math.floor((bounds.y + bounds.height) / this.cellSize)
for (let x = startX; x <= endX; x++) {
for (let y = startY; y <= endY; y++) {
const key = `${x},${y}`
if (!this.cells.has(key)) {
this.cells.set(key, [])
}
this.cells.get(key).push(obj)
}
}
}
getNearby(obj) {
const bounds = obj.getBounds()
const startX = Math.floor(bounds.x / this.cellSize)
const endX = Math.floor((bounds.x + bounds.width) / this.cellSize)
const startY = Math.floor(bounds.y / this.cellSize)
const endY = Math.floor((bounds.y + bounds.height) / this.cellSize)
const nearby = new Set()
for (let x = startX; x <= endX; x++) {
for (let y = startY; y <= endY; y++) {
const key = `${x},${y}`
const cell = this.cells.get(key)
if (cell) {
cell.forEach(obj => nearby.add(obj))
}
}
}
return Array.from(nearby)
}
}
class KeyboardInput {
constructor() {
this.keys = new Map()
this.justPressed = new Set()
this.justReleased = new Set()
window.addEventListener('keydown', (e) => {
if (!this.keys.get(e.code)) {
this.justPressed.add(e.code)
}
this.keys.set(e.code, true)
})
window.addEventListener('keyup', (e) => {
this.keys.set(e.code, false)
this.justReleased.add(e.code)
})
}
isDown(code) {
return this.keys.get(code) === true
}
isUp(code) {
return !this.isDown(code)
}
isJustPressed(code) {
return this.justPressed.has(code)
}
isJustReleased(code) {
return this.justReleased.has(code)
}
update() {
this.justPressed.clear()
this.justReleased.clear()
}
}
class GamepadInput {
constructor() {
this.gamepads = new Map()
window.addEventListener('gamepadconnected', (e) => {
this.gamepads.set(e.gamepad.index, e.gamepad)
console.log('手柄已连接:', e.gamepad.id)
})
window.addEventListener('gamepaddisconnected', (e) => {
this.gamepads.delete(e.gamepad.index)
})
}
update() {
const gamepads = navigator.getGamepads()
for (const gamepad of gamepads) {
if (gamepad) {
this.gamepads.set(gamepad.index, gamepad)
}
}
}
getButton(index, buttonIndex) {
const gamepad = this.gamepads.get(index)
if (gamepad && gamepad.buttons[buttonIndex]) {
return gamepad.buttons[buttonIndex].pressed
}
return false
}
getAxis(index, axisIndex) {
const gamepad = this.gamepads.get(index)
if (gamepad) {
const value = gamepad.axes[axisIndex]
return Math.abs(value) > 0.1 ? value : 0
}
return 0
}
}
class GameStateMachine {
constructor() {
this.states = new Map()
this.currentState = null
}
addState(name, state) {
this.states.set(name, state)
}
changeState(name, ...args) {
if (this.currentState) {
this.currentState.exit()
}
this.currentState = this.states.get(name)
if (this.currentState) {
this.currentState.enter(...args)
}
}
update(dt) {
if (this.currentState) {
this.currentState.update(dt)
}
}
render(ctx) {
if (this.currentState) {
this.currentState.render(ctx)
}
}
}
class MenuState {
constructor(game) {
this.game = game
}
enter() {
console.log('进入菜单')
}
exit() {
console.log('退出菜单')
}
update(dt) {
// 菜单逻辑
}
render(ctx) {
ctx.fillStyle = '#2c3e50'
ctx.fillRect(0, 0, this.game.width, this.game.height)
ctx.fillStyle = '#ecf0f1'
ctx.font = '48px Arial'
ctx.textAlign = 'center'
ctx.fillText('游戏标题', this.game.width / 2, this.game.height / 2 - 50)
ctx.font = '24px Arial'
ctx.fillText('按 Enter 开始游戏', this.game.width / 2, this.game.height / 2 + 20)
}
}
class PlayState {
constructor(game) {
this.game = game
this.entities = []
}
enter() {
this.entities = []
this.initLevel()
}
exit() {
this.entities = []
}
initLevel() {
// 初始化关卡
}
update(dt) {
this.entities.forEach(entity => entity.update(dt))
}
render(ctx) {
this.entities.forEach(entity => entity.render(ctx))
}
}
class Particle {
constructor(x, y, options = {}) {
this.x = x
this.y = y
this.vx = options.vx || (Math.random() - 0.5) * 100
this.vy = options.vy || (Math.random() - 0.5) * 100
this.life = options.life || 1
this.decay = options.decay || 0.02
this.size = options.size || 5
this.color = options.color || '#fff'
this.gravity = options.gravity || 0
}
update(dt) {
this.x += this.vx * dt
this.y += this.vy * dt
this.vy += this.gravity * dt
this.life -= this.decay
}
render(ctx) {
ctx.globalAlpha = Math.max(0, this.life)
ctx.fillStyle = this.color
ctx.beginPath()
ctx.arc(this.x, this.y, this.size * this.life, 0, Math.PI * 2)
ctx.fill()
ctx.globalAlpha = 1
}
isDead() {
return this.life <= 0
}
}
class ParticleSystem {
constructor() {
this.particles = []
}
emit(x, y, count, options = {}) {
for (let i = 0; i < count; i++) {
this.particles.push(new Particle(x, y, options))
}
}
update(dt) {
for (let i = this.particles.length - 1; i >= 0; i--) {
this.particles[i].update(dt)
if (this.particles[i].isDead()) {
this.particles.splice(i, 1)
}
}
}
render(ctx) {
this.particles.forEach(particle => particle.render(ctx))
}
}
class ObjectPool {
constructor(createFn, initialSize = 10) {
this.createFn = createFn
this.pool = []
this.active = []
for (let i = 0; i < initialSize; i++) {
this.pool.push(this.createFn())
}
}
get() {
let obj = this.pool.pop()
if (!obj) {
obj = this.createFn()
}
this.active.push(obj)
return obj
}
release(obj) {
const index = this.active.indexOf(obj)
if (index > -1) {
this.active.splice(index, 1)
this.pool.push(obj)
}
}
releaseAll() {
while (this.active.length > 0) {
this.pool.push(this.active.pop())
}
}
}
class AssetLoader {
constructor() {
this.assets = new Map()
this.totalAssets = 0
this.loadedAssets = 0
}
loadImages(images) {
return new Promise((resolve) => {
this.totalAssets = Object.keys(images).length
for (const [key, src] of Object.entries(images)) {
const img = new Image()
img.onload = () => {
this.assets.set(key, img)
this.loadedAssets++
if (this.loadedAssets === this.totalAssets) {
resolve()
}
}
img.src = src
}
})
}
get(key) {
return this.assets.get(key)
}
getProgress() {
return this.totalAssets > 0 ? this.loadedAssets / this.totalAssets : 0
}
}
class DebugOverlay {
constructor(game) {
this.game = game
this.enabled = false
this.fps = 0
this.frameCount = 0
this.lastTime = performance.now()
window.addEventListener('keydown', (e) => {
if (e.code === 'F3') {
this.enabled = !this.enabled
e.preventDefault()
}
})
}
update() {
this.frameCount++
const now = performance.now()
if (now - this.lastTime >= 1000) {
this.fps = this.frameCount
this.frameCount = 0
this.lastTime = now
}
}
render(ctx) {
if (!this.enabled) return
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'
ctx.fillRect(5, 5, 150, 60)
ctx.fillStyle = '#fff'
ctx.font = '12px monospace'
ctx.textAlign = 'left'
ctx.fillText(`FPS: ${this.fps}`, 10, 20)
ctx.fillText(`Entities: ${this.game.entities.length}`, 10, 35)
ctx.fillText(`Press F3 to toggle`, 10, 50)
}
}