从零做一个微信小游戏:用 Canvas 写个飞机大战

项目实战 · 约 18 分钟 · 更新于

微信小游戏 Canvas 主循环

图示是本文游戏循环和对象生命周期的技术示意图。

写给完全没做过游戏的新人。跟着做完,你会得到一个:飞机跟着手指动、自动开火、敌机往下掉、打中就消失的飞机大战。全程原生 JavaScript,不装任何前端框架。

先搞懂:小游戏和小程序不是一回事

很多人把”微信小游戏”和”微信小程序”搞混,它们是两套不同的运行环境,别建错项目。

小程序更像”网页 App”:用 WXML/WXSS 描述一排组件(按钮、文字、图片),框架帮你排版。小游戏更像”一张画布”:整个屏幕就是一块 Canvas,画面上的一切(飞机、子弹、爆炸)都得你用代码一笔一笔画上去、自己算位置、自己刷新画面。

小程序                         小游戏
┌──────────────┐              ┌──────────────┐
│ view text btn│  ← WXML组件   │              │
│ 框架帮你排版  │              │ 一整张 Canvas │  ← 你自己画
└──────────────┘              └──────────────┘
对比项小程序小游戏
入口文件app.json + 页面game.json + game.js
界面写法WXML + WXSS没有,全是 Canvas
渲染方式框架排版组件你手动 draw 到画布
主力语言JS + WXML/WXSS纯 JavaScript
典型场景工具/商城/表单游戏

下面全程围绕小游戏展开。

第 1 步:装工具、拿 AppID

  1. 下载微信开发者工具(稳定版即可),安装后用微信扫码登录。
  2. 点”新建项目”时会要 AppID。没有正式游戏类目 AppID 没关系,选测试号(或”游客模式”),照样能本地运行、预览、真机调试。本文示例里 project.config.json 的 appid 写成 "touristappid" 就是游客模式,有自己的测试号就填进去。

第 2 步:新建项目,看懂三个文件

新建时务必选小游戏模板(不是小程序),目录里会自动生成三个关键文件,先认清楚分工:

  • game.json:运行时配置,告诉微信”竖屏还是横屏、要不要状态栏”。
  • project.config.json:项目配置,记 AppID、项目名、编译选项。
  • game.js:游戏逻辑入口,你的代码从这开始跑。

先把 game.json 改成竖屏、关掉状态栏:

{
  "deviceOrientation": "portrait",
  "showStatusBar": false,
  "networkTimeout": {
    "request": 5000,
    "connectSocket": 5000,
    "uploadFile": 5000,
    "downloadFile": 5000
  }
}

project.config.json 用这份最小配置(AppID 按需替换):

{
  "appid": "touristappid",
  "projectname": "plane-battle",
  "setting": {
    "es6": true,
    "minified": true
  }
}

游戏入口 game.js 只做一件事:启动主类。

import Main from './js/main.js'

new Main()

小游戏支持 ES6 的 import,工具会自动转码,放心用。

第 3 步:主循环——游戏的心跳

游戏之所以”动起来”,靠的是主循环:每秒重复几十次”清屏 → 更新逻辑 → 重绘画面”,速度快到人眼看着就是连续动画。驱动这个循环的 API 叫 requestAnimationFrame,它会在下一次准备重绘画面时调用你给的函数,大约每秒 60 次,是整个游戏的心跳。

目录和官方示例同款:js/main.js 是主循环,js/base/ 放 sprite.js 与 pool.js,js/player/ 放 index.js(玩家)和 bullet.js,js/npc/ 放 enemy.js。先写主循环 js/main.js(其余文件马上补上):

import Player from './player/index.js'
import BulletPool from './base/pool.js'
import Enemy from './npc/enemy.js'

// 小游戏默认提供一个全屏 Canvas,第一次 createCanvas() 拿到的就是显示画面
const canvas = wx.createCanvas()
const ctx = canvas.getContext('2d')
const { windowWidth: SW, windowHeight: SH } = wx.getSystemInfoSync()

// 矩形相交:两个矩形有重叠就算撞上(最朴素的碰撞检测)
function rectIntersect(a, b) {
  return (
    a.x < b.x + b.width &&
    a.x + a.width > b.x &&
    a.y < b.y + b.height &&
    a.y + a.height > b.y
  )
}

class Main {
  constructor() {
    this.player = new Player(SW / 2 - 25, SH - 110, 50, 70)
    this.bullets = new BulletPool(20)
    this.enemies = []
    this.lastBullet = 0
    this.lastEnemy = 0
    this.bindTouch()
    this.loop()
  }

  // 触摸:手指在屏幕上滑动时,飞机跟着手指走
  bindTouch() {
    wx.onTouchMove((e) => {
      const t = e.touches[0]
      this.player.x = t.clientX - this.player.width / 2
      this.player.y = t.clientY - this.player.height / 2
    })
  }

  // 主循环:每帧 update → draw → 排下一帧
  loop() {
    this.update()
    this.draw()
    requestAnimationFrame(() => this.loop())
  }

  update() {
    const now = Date.now()
    // 每 250ms 自动开火
    if (now - this.lastBullet > 250) {
      const b = this.bullets.acquire()
      if (b) b.fire(this.player.x + this.player.width / 2, this.player.y)
      this.lastBullet = now
    }
    // 每 800ms 刷一架敌机
    if (now - this.lastEnemy > 800) {
      this.enemies.push(new Enemy(Math.random() * (SW - 40), -40, 40, 40))
      this.lastEnemy = now
    }

    for (const b of this.bullets.pool) b.update()
    for (const e of this.enemies) e.update()

    // 碰撞:子弹撞上敌机,双双消失
    for (const b of this.bullets.pool) {
      if (!b.active) continue
      for (const e of this.enemies) {
        if (e.dead) continue
        if (rectIntersect(b, e)) {
          b.active = false
          e.dead = true
          break
        }
      }
    }

    // 清理:飞出屏幕或被击毁的敌机
    this.enemies = this.enemies.filter((e) => !e.dead && e.y < SH + 40)
  }

  draw() {
    // 清屏:先把上一帧擦掉,否则会拖出残影
    ctx.clearRect(0, 0, SW, SH)
    ctx.fillStyle = '#0f1024'
    ctx.fillRect(0, 0, SW, SH)

    this.player.draw(ctx)
    for (const b of this.bullets.pool) b.draw(ctx)
    for (const e of this.enemies) e.draw(ctx)
  }
}

export default Main

第 4 步:Sprite 基类——所有物体的爸爸

Canvas 是一块可以用代码画图的画布;精灵(Sprite) 是游戏里对”看得见、会动的物体”的统称。我们写一个基类,只管位置和尺寸,子类各自实现怎么动、怎么画:

// js/base/sprite.js
export default class Sprite {
  constructor(x = 0, y = 0, width = 0, height = 0) {
    this.x = x
    this.y = y
    this.width = width
    this.height = height
  }

  update() {}

  draw(ctx) {}
}

第 5 步:玩家飞机,跟着手指动

继承 Sprite,用三角形画一架向上的飞机。位置由触摸事件改(已在 main.js 里写好),这里只管画:

// js/player/index.js
import Sprite from '../base/sprite.js'

export default class Player extends Sprite {
  draw(ctx) {
    const { x, y, width: w, height: h } = this
    ctx.fillStyle = '#4fc3f7'
    ctx.beginPath()
    ctx.moveTo(x + w / 2, y)   // 机头
    ctx.lineTo(x, y + h)       // 左下角
    ctx.lineTo(x + w, y + h)   // 右下角
    ctx.closePath()
    ctx.fill()
  }
}

第 6 步:子弹 + 对象池

子弹每秒要射好几发,如果每发都 new Bullet()、用完又丢弃,内存反复分配回收会触发 GC(垃圾回收),画面就可能突然卡一下。

对象池(Object Pool) 的思路是:提前 new 好一批子弹放池子里,要发射就从池里挑一个空闲的,用完标记成”空闲”下次再用,不再频繁 new。

先写子弹(用 active 标记是否在飞):

// js/player/bullet.js
import Sprite from '../base/sprite.js'

export default class Bullet extends Sprite {
  constructor() {
    super(0, 0, 6, 14)
    this.speed = 7
    this.active = false
  }

  fire(x, y) {
    this.x = x
    this.y = y
    this.active = true
  }

  update() {
    if (!this.active) return
    this.y -= this.speed
    if (this.y + this.height < 0) this.active = false   // 飞出屏幕,回收
  }

  draw(ctx) {
    if (!this.active) return
    ctx.fillStyle = '#ffd700'
    ctx.fillRect(this.x, this.y, this.width, this.height)
  }
}

再写池子,预创建 20 颗子弹反复用:

// js/base/pool.js
import Bullet from '../player/bullet.js'

export default class BulletPool {
  constructor(size = 20) {
    this.pool = []
    for (let i = 0; i < size; i++) this.pool.push(new Bullet())
  }

  // 拿一个空闲子弹;全在飞就返回 null(这帧先不发射)
  acquire() {
    for (const b of this.pool) {
      if (!b.active) return b
    }
    return null
  }
}

第 7 步:敌机下落 + 碰撞检测

敌机从顶部往下掉,被子弹矩形相交撞上就标记销毁:

// js/npc/enemy.js
import Sprite from '../base/sprite.js'

export default class Enemy extends Sprite {
  constructor(x, y, width, height) {
    super(x, y, width, height)
    this.speed = 3
    this.dead = false
  }

  update() {
    this.y += this.speed
  }

  draw(ctx) {
    ctx.fillStyle = '#e53935'
    ctx.fillRect(this.x, this.y, this.width, this.height)
  }
}

碰撞用的”矩形相交”逻辑已放在 main.js 的 rectIntersect 里:逐颗子弹和每架敌机两两比对,重叠就同归于尽——子弹 active=false 回到池子,敌机 dead=true 等下帧清理。

第 8 步:运行,看到画面

回到微信开发者工具:

  1. 确认编译模式是小游戏,点”编译”。
  2. 模拟器里应看到深蓝背景,一架蓝色三角飞机停在底部。
  3. 用鼠标在画面上拖动(模拟手指),飞机跟着走。
  4. 飞机自动每隔一小段射出金色子弹往上飞,顶部不断掉下红色敌机,子弹撞上敌机双双消失。

一个能动能打的飞机大战就跑起来了。接下来你可以自己加:爆炸特效、计分、玩家被撞减命、把三角形换成图片精灵。但骨架你已经掌握了——主循环 + 精灵 + 对象池 + 碰撞,这就是绝大多数 Canvas 小游戏的内核。

运行时架构:输入、更新、碰撞、渲染

小游戏没有 DOM 和组件树,所有状态都由代码维护。一个稳定的帧循环应该保持固定顺序:

输入事件 → 更新游戏状态 → 碰撞检测 → 清理对象 → Canvas 重绘 → 下一帧

输入事件只修改玩家意图,不直接负责绘制;update() 负责时间和位置变化;draw() 只读取当前状态并绘制。这样做的好处是:逻辑可以被单独测试,渲染不会把状态改乱。

时间步长与帧率

原始示例使用固定速度,例如子弹每帧移动 7 像素。不同设备帧率不同,固定帧移动会造成快设备和慢设备速度不一致。更稳妥的写法是使用时间差:

const dt = Math.min((now - this.lastTime) / 1000, 0.05)
this.player.x += this.player.vx * dt
this.enemy.y += this.enemy.speed * dt
this.lastTime = now

Math.min 用来限制单帧最大时间,避免手机切后台后恢复时物体瞬移。碰撞检测、生成频率和动画速度都应该基于时间而不是帧数。

对象池为什么能减少卡顿

子弹和爆炸特效是高频创建、短生命周期对象。如果每次射击都 new,大量临时对象会增加垃圾回收压力。对象池提前分配固定数量的对象:

  1. acquire() 找到 inactive 对象。
  2. reset(x, y, speed) 重置状态。
  3. 对象飞出屏幕或命中后 release()。
  4. 下一次射击复用同一个对象。

对象池大小需要根据设计上限选择;池满时应该丢弃本次生成,而不是无限扩容。爆炸、敌机和掉落物也可以复用同一套生命周期接口。

碰撞检测的复杂度

当前矩形相交算法是暴力两两检测,子弹数为 B、敌人数为 E 时复杂度是 O(B × E)。小型游戏完全够用,但对象数量上升后可以优化:

  • 先做屏幕边界过滤。
  • 用网格或四叉树把对象分桶。
  • 只比较同一区域的对象。
  • 将静态背景和动态实体分层绘制。

碰撞盒可以比图片实际尺寸小一些,手感通常比像素级碰撞更好。碰撞算法和渲染图片解耦,后续换素材不会影响规则。

资源和设备适配

手机屏幕尺寸、设备像素比和刘海区域都不同。初始化时应保存逻辑宽高,并统一做坐标换算:

const info = wx.getSystemInfoSync()
const scale = info.pixelRatio
const logicalWidth = info.windowWidth
const logicalHeight = info.windowHeight

图片资源建议:

  • 使用压缩后的 WebP/PNG,避免首屏加载大图。
  • 预加载飞机、敌机、爆炸贴图,避免首次绘制卡顿。
  • 使用资源缓存,场景切换时不重复下载。
  • 对低端设备降低粒子数量和背景绘制频率。

游戏状态机

不要用多个布尔值组合表示游戏状态,例如 isStart、isPause、isOver 同时为 true 容易出现矛盾。建议使用显式状态机:

const GameState = Object.freeze({ READY: 'ready', PLAYING: 'playing', PAUSED: 'paused', OVER: 'over' })
this.state = GameState.READY

根据状态决定是否接收输入、是否更新实体、是否绘制开始页或结束页,暂停和重新开始会更容易维护。

测试与性能检查

类型检查内容
逻辑测试矩形碰撞边界、子弹回收、敌机越界清理
操作测试单指移动、快速滑动、切后台再回来
设备测试小屏、全面屏、低端 Android、iOS 真机
性能测试帧率、内存、对象数量、首屏资源加载时间
发布测试分包/资源路径、游客模式、审核配置、网络超时

开发者工具里重点观察帧率和内存曲线;如果 FPS 持续下降,先检查每帧是否有大数组创建、重复图片解码或未清理的事件监听器。

可继续演进的功能

  • 增加关卡配置文件,把敌机速度、血量、生成间隔从代码中抽离。
  • 增加难度曲线,随着时间提高生成密度而不是突然提高速度。
  • 增加音效池和资源预加载。
  • 增加排行榜时,服务端必须校验分数提交,不能信任客户端分数。
  • 增加埋点时只记录必要事件,避免影响游戏主循环。
阳熙

Java / 保险业务系统开发 · 无锡

← 返回文章列表

评论

还没有评论,来留第一条吧。

首次评论会做人机校验