图示是本文游戏循环和对象生命周期的技术示意图。
写给完全没做过游戏的新人。跟着做完,你会得到一个:飞机跟着手指动、自动开火、敌机往下掉、打中就消失的飞机大战。全程原生 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
- 下载微信开发者工具(稳定版即可),安装后用微信扫码登录。
- 点”新建项目”时会要 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 步:运行,看到画面
回到微信开发者工具:
- 确认编译模式是小游戏,点”编译”。
- 模拟器里应看到深蓝背景,一架蓝色三角飞机停在底部。
- 用鼠标在画面上拖动(模拟手指),飞机跟着走。
- 飞机自动每隔一小段射出金色子弹往上飞,顶部不断掉下红色敌机,子弹撞上敌机双双消失。
一个能动能打的飞机大战就跑起来了。接下来你可以自己加:爆炸特效、计分、玩家被撞减命、把三角形换成图片精灵。但骨架你已经掌握了——主循环 + 精灵 + 对象池 + 碰撞,这就是绝大多数 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,大量临时对象会增加垃圾回收压力。对象池提前分配固定数量的对象:
acquire()找到 inactive 对象。reset(x, y, speed)重置状态。- 对象飞出屏幕或命中后
release()。 - 下一次射击复用同一个对象。
对象池大小需要根据设计上限选择;池满时应该丢弃本次生成,而不是无限扩容。爆炸、敌机和掉落物也可以复用同一套生命周期接口。
碰撞检测的复杂度
当前矩形相交算法是暴力两两检测,子弹数为 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 持续下降,先检查每帧是否有大数组创建、重复图片解码或未清理的事件监听器。
可继续演进的功能
- 增加关卡配置文件,把敌机速度、血量、生成间隔从代码中抽离。
- 增加难度曲线,随着时间提高生成密度而不是突然提高速度。
- 增加音效池和资源预加载。
- 增加排行榜时,服务端必须校验分数提交,不能信任客户端分数。
- 增加埋点时只记录必要事件,避免影响游戏主循环。
评论
还没有评论,来留第一条吧。