尧图精选

微信小程序游戏开发实战:从Canvas渲染到马里奥式横版闯关

🕒 发布时间:2026/10/2 15:01:44 📁 来源:尧图网络
1. 把横版闯关塞进小程序先想清楚这几个硬边界做微信小程序游戏开发的朋友应该都碰到过这类需求老板指着手机说“我要一个马里奥救公主的小游戏就像红白机那样”。这话听着简单真落到代码里第一反应是先别急着写渲染得先回答一个根本问题——你到底选“小程序”还是“小游戏”这个入口。1.1 小游戏与小程序组件的分岔路口很多人以为“小程序游戏”就是把游戏页面嵌入小程序这是一个非常普遍的误解。微信生态里正经带Canvas高性能渲染的游戏应该走“微信小游戏”的类目而普通小程序里用canvas组件也可以做简单游戏但性能、API能力、包体策略完全不同。如果你只是做一个小玩法demo比如按键控制马里奥跳几下那小程序原生canvas足够。但如果要做一个完整关卡、带BGM、有几十个敌人和金币我建议你在微信公众平台直接创建“小游戏”项目。小游戏的运行环境是独立的GameGlobal没有DOM、没有window页面概念被弱化游戏循环逻辑更接近Cocos。我的实际建议是先想清楚“公主”要救到什么程度。如果只是“点击屏幕跳过障碍物救公主”的轻量互动小程序原生页面加Canvas就够如果是横版跳跃、有敌人、有碰撞、有多个关卡那一定要走小游戏。标题里那个“⭐”暗示了这是一个带趣味性的完整玩法所以下面的内容我全部按“能用、能跳、能救公主的横版闯关”来写。1.2 Canvas渲染、帧率与触摸事件的组合方案小游戏里第一件事是拿到主Canvas并建立游戏循环最常见的写法是用requestAnimationFrame做一个主循环const canvas wx.createCanvas() const ctx canvas.getContext(2d) let lastTime 0 function frame(time) { const delta time - lastTime lastTime time update(delta / 1000) render() requestAnimationFrame(frame) } requestAnimationFrame(frame)我见过不少新手直接把update和render写在一起帧率一波动角色就走得忽快忽慢。正确的做法是给物理更新传入delta让移动、重力都基于“每帧经过的真实时间”而不是假设固定60FPS。另一个非常关键的点微信小游戏的canvas坐标系原点在左上角物理世界里的“向上”是屏幕的负Y方向。这个反直觉的设定会让新人在写跳跃时把Y轴方向搞反——你按跳跃键马里奥反而往下掉。我当时就因为这个问题排查了半天最后发现是player.y - velocity.y写成了。这种方向感问题建议在公共常量里定义清楚const GRAVITY 1200 // 重力加速度单位像素/秒² const JUMP_VELOCITY -520 // 跳跃初速度负值表示向上 const MOVE_SPEED 240 // 水平移动速度像素/秒关于触摸事件小游戏没有DOM事件统一用wx.onTouchStart、wx.onTouchMove、wx.onTouchEnd。这意味着你需要自己维护一套虚拟按键状态机。很多人把这套状态直接塞进全局变量结果多个手指同时按住时逻辑就乱了后面我会专门讲这个坑。2. 像素水管工搬进微信生态主角移动、跳跃与碰撞判定马里奥最核心的体验不是画面而是“手感”。而手感由三个东西决定重力常数、跳跃初速度、碰撞检测的精度。这三者在微信小游戏里都需要针对手机触摸屏重新调。2.1 重力常数怎么算跳跃曲线才不像踩弹簧红白机上的马里奥跳跃高度大约是自身高度的4到5倍。换算到手机屏幕如果角色高度是32像素跳跃最大高度就应该在128到160像素之间。这里有一个中学物理公式可以用H (v²) / (2g)其中H是最大跳跃高度v是跳跃初速度g是重力加速度。想让角色跳得“跟脚”我一般先定一个跳跃高度然后反推参数。例如角色高度32px期望跳跃高度140px重力取1200px/s²代入公式v √(2 × 1200 × 140) ≈ 580px/s考虑到按下跳跃键后需要一点缓冲时间初速度可以取-520到-550之间留出上升余量。为什么我不直接套用游戏引擎的物理常量因为手机屏幕的像素密度和视觉比例跟红白机差异太大数值必须“为屏幕服务”。我在调参时会做一个隐藏调试面板把重力、初速度、移动速度三个变量用滑块实时调整站在真机上一遍遍跳直到“按多久跳多高”符合直觉。另一个容易被忽视的点长按跳跃键和短按要有区别。经典马里奥里长按跳得更高短按则是小跳。这个手感靠“跳跃键松开后削减上升速度”实现if (!isJumpKeyPressed player.vy 0) { player.vy * 0.6 // 松开跳跃键削减上升速度 }这个0.6是调出来的太小会显得角色突然“卡住”太大会失去短按轻跳的效果。建议在0.5到0.7之间试。2.2 碰撞检测用“九宫格法”代替物理引擎小程序里不要轻易引入物理引擎。Box2D虽然在小游戏环境能跑但一个只做横版跳跃的小游戏用物理引擎反而会让踩怪判定变得不可控。我更推荐“九宫格碰撞检测法”——把地图切成一格一格的瓦片tile每帧只检测角色周边的9个格子有没有障碍物。具体做法是地图数据用二维数组表示const MAP [ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], [1,0,0,0,0,0,0,0,0,0,0,0,0,0,1], [1,0,0,0,3,0,0,0,0,0,3,0,0,0,1], [1,0,0,0,0,0,0,0,0,0,0,0,0,0,1], [1,0,0,0,0,0,0,2,2,2,0,0,0,0,1], [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], ]0空1实心土地2问号砖块3敌人刷新点每一帧先根据角色坐标算出它落在哪个格子然后只遍历以它为中心的9个格子逐个做AABB矩形相交判断。这个方案的性能极其稳定就算地图很大也不会因为碰撞计算拖慢帧率。碰撞检测里的一个常见Bug是“角色卡进砖缝”。解决办法是把移动拆成X方向和Y方向两次独立检测先移动X轴并检测再移动Y轴并检测不要同时移动两个轴。这样马里奥撞墙时能自然停下而不是斜着卡进墙里。2.3 地图数据用数组写关卡比拖素材快十倍如果你要用可视化地图编辑器画关卡那当然更专业。但小游戏包体有限做一个中型项目时用代码写地图反而灵活得多。我会把每一关设计成类似上面的数组再配一个图例说明。关卡设计时还有一个小技巧把“公主”和“旗杆终点”保存在独立对象中不要混进障碍物数组。因为公主需要播放动画、触发通关逻辑而障碍物只需要碰撞判定。开头提到的那串热搜词里“微信小程序项目实例”经常被人当成“要做一个完整项目”但真正完整的项目是数据结构干净到每个模块都能单独测试。我写马里奥救公主时地图数据只用纯数组而角色、敌人、道具全部独立成类这样后续加关卡只改数组不需要动代码逻辑。3. 让公主值得被救敌人AI、金币和机关的设计马里奥的“拯救公主”之所以有代入感是因为中间有敌人、有金币、有陷阱玩家每一秒都在做决策。这部分如果做成“走过场”游戏就废了。我把这一章拆成三个点敌人AI、金币计分系统和通关机关。3.1 板栗仔的巡逻AI与踩怪判定蘑菇怪板栗仔的行为很简单在一个水平区间内来回走遇到障碍或者悬崖边缘就回头。这个AI用状态机实现最适合class Mushroom { constructor(x, y, leftBound, rightBound) { this.x x this.y y this.leftBound leftBound this.rightBound rightBound this.dir -1 this.dead false } update(delta) { if (this.dead) return this.x this.dir * ENEMY_SPEED * delta if (this.x this.leftBound) { this.x this.leftBound this.dir 1 } else if (this.x this.rightBound) { this.x this.rightBound this.dir -1 } } }踩怪的判定是马里奥系列最精髓的手感只有“马里奥在下落过程中脚底碰到敌人头顶”才判定踩死其他方向碰触都判定受伤。这个逻辑不能简单用AABB重叠判断要看相对位置和位移方向function checkStomp(player, enemy) { if (!enemy.dead player.prevBottom enemy.top player.vy 0) { // 踩中敌人头顶 enemy.dead true player.vy -350 // 踩怪后弹跳 } }注意player.prevBottom是上一帧的角色底部坐标。如果只判断当前帧角色下落速度过快时会“穿透”敌人直接掉进敌人内部看起来就是无缘无故死了。3.2 金币动画、计分和命数管理金币的核心是“吃到时的正反馈”。小程序的wx.createCanvas().getContext(2d)绘制简单但想让金币有旋转动画需要做精灵帧。最简单的方式是画一个椭圆横向缩放模拟旋转function renderCoin(ctx, coin) { const scaleX Math.cos(coin.time * 3) ctx.save() ctx.translate(coin.x, coin.y) ctx.scale(scaleX, 1) ctx.beginPath() ctx.arc(0, 0, COIN_RADIUS, 0, Math.PI * 2) ctx.fillStyle #F7D842 ctx.fill() ctx.restore() }每帧更新coin.time金币就有了旋转感。这个技巧比准备一整套精灵图省资源观感也不差。命数管理上我遇到过最坑的问题是小程序的本地存储wx.setStorageSync写入太频繁会卡顿。如果你每吃一个金币都写一次存储数量一多掉帧明显。我的习惯是存储只在游戏结束和通关时写游戏过程中的分数放在内存变量里。只有当分数展示到UI或需要保留时才统一写一次。3.3 旗杆终点与多关卡数据流转“救公主”需要一个清晰的终点目标。经典做法是终点放一根旗杆马里奥碰到旗杆后触发“下降→过关→下一关”的动画序列。这个序列用状态机实现别直接在update里写死const GameState { PLAYING: playing, CLEARING: clearing, // 过关动画中 GAME_OVER: gameOver, WIN: win }当角色触碰到旗杆顶部区域时切换CLEARING播放一段角色下滑动画然后加载下一关数据。多关卡的数据流转可以封装成LevelManager负责读取当前关卡数组、初始化敌人和道具、重置角色出生点。不要让关卡数据散落在各个类里否则追加新关卡时你要同时改五六个文件这跟“项目实例”期待的可维护性就背道而驰了。4. 微信平台的那堆“隐藏Boss”触摸、音频、适配和审核这部分才是微信小程序特有的挑战。同样的代码在浏览器里跑没问题一上小程序就出现各种平台层的“隐藏Boss”。我把真正踩过的坑按优先级列出来。4.1 虚拟十字键与onTouch事件冲突处理手机上玩横版游戏屏幕左边放虚拟方向键、右边放跳跃键是最常见的布局。但触摸事件是全局的你必须自己分辨“这个touch属于哪个键”。别用单一数组存所有触点而是要给触点绑定身份const touchState { left: false, right: false, jump: false } wx.onTouchStart(e { e.changedTouches.forEach(touch { if (isInRect(touch.clientX, touch.clientY, leftBtnRect)) { touchState.left true bindTouchId(touch.identifier, left) } if (isInRect(touch.clientX, touch.clientY, rightBtnRect)) { touchState.right true bindTouchId(touch.identifier, right) } if (isInRect(touch.clientX, touch.clientY, jumpBtnRect)) { touchState.jump true bindTouchId(touch.identifier, jump) } }) })关键点是使用touch.identifier作为唯一ID在onTouchEnd时按ID移除对应按键状态。如果只判断clientX是否在按钮区域会出现“手指从跳跃键滑到方向键区域角色就自己乱跑”的问题。正确做法是“按下时绑定移动时看是否移出大热区抬起时解除绑定”而不是实时用坐标判断。虚拟按钮的UI绘制要注意和游戏Canvas分层。可以在主Canvas上面叠加一层UI Canvas用wx.createCanvas()创建第二个Canvas实例并设置zIndex更高。这样重绘游戏画面时不需要每次都清掉按钮区域性能更好。4.2 音频播放的几个坑和内嵌BGM方案小游戏音频有两个坑第一wx.createInnerAudioContext()创建的音频实例在iOS上如果不调用play()前设置src会静默失败。建议先赋值src再监听onCanplay在回调里执行play。第二音频资源不能太大。BGM建议压到128kbps的mp3单曲控制在1MB以内音效用短wav或m4a控制在几十KB。包体限制是主包4MB超过就得走分包加载策略所以能不塞大音频就别塞。我做BGM时用了一个简单循环策略一个10秒左右的轻快旋律用InnerAudioContext.loop true循环播放音量调到0.2左右作为背景避免盖住音效。音效单独用一组音频实例跳跃、踩怪、吃金币、过关各一个实例复用而不是每次new。4.3 自定义顶部导航栏与不同机型高度适配这是热搜词里“微信小程序顶部导航栏高度”背后的真实痛点。在小游戏环境里没有navigationBar可配置全屏Canvas必须自己处理安全区。不同手机的刘海屏、灵动岛、底部横条高度都不一样如果绘制UI时硬编码Y坐标游戏标题和计分板就可能被刘海“吃掉”。我的处理方式是先读取系统信息算出可视区域和上下安全距离const info wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() const safeTop info.safeArea.top const safeBottom info.screenHeight - info.safeArea.bottom const designWidth 750 // 以rpx为基准 const scale info.windowWidth / designWidth游戏逻辑坐标统一用“设计分辨率”比如750×1334再根据屏幕比例做同比缩放。所有UI元素避开safeTop和safeBottom区域。注意wx.getSystemInfoSync()在新版本有被废弃迹象新的基础库建议用wx.getWindowInfo()但为了兼容旧版本我会写个兜底判断。还有一个细节横屏游戏要调用wx.setDeviceOrientation({ value: landscape })并且需要在game.json里配置deviceOrientation: landscape。如果不配游戏在手机上还是竖屏渲染画面被压扁。这个配置项容易被忘却直接影响体验。4.4 “列表加载更多”式的地图分块加载思路热搜词里“微信小程序页面列表加载更多”常指列表页的分页但游戏里同样存在“加载更多”的需求一个超长关卡的地图数据不能一次全渲染。小游戏的Canvas虽然能绘制长图但性能会随着绘制区域变大而下降。我的做法是“可视区瓦片裁剪”只渲染当前屏幕可见范围内的瓦片。地图数组哪怕有几千格每帧只根据摄像机坐标计算可见区间取对应的子数组绘制const visibleStartCol Math.floor(camera.x / TILE_SIZE) const visibleEndCol Math.ceil((camera.x screenWidth) / TILE_SIZE)这和列表加载更多其实是同一个思想物尽其用不加载不需要的东西。地图数据可以全量存在内存中但渲染和碰撞只处理可视区。这样设计后一个横跨几十屏的地图关卡也不会产生掉帧。5. 工具链实战从开发者工具跑到真机的一路暗坑代码写完了后面才是真正折磨人的地方。微信开发者工具、真机调试、审核发布每一步都有它自己的“脾气”。这一章我把跑通整个项目的经验踩成可复用的清单。5.1 开发者工具里的Canvas调试技巧开发者工具里最容易出现“模拟器正常、真机白屏”的问题。先说调试技巧打开控制台在Sources面板里能看到小游戏的逻辑代码可以打断点。但有两点很坑开发者工具的Canvas渲染实现跟真机不完全一致渐变、阴影、ctx.globalCompositeOperation等API在工具里看着正常真机可能不支持或效果异常。requestAnimationFrame在开发者工具里的节流逻辑和真机不同工具里可能稳定60FPS真机低帧率时未按delta计算的逻辑会明显变慢。所以我会先在开发者工具里把逻辑跑通然后用“真机调试2.0”扫码在真机上再看一遍。真机调试可以边跑边看调用栈这是排查白屏的利器。5.2 真机预览的帧率与内存监控小游戏发布前我习惯在真机上打开“性能监控”面板里面能看帧率、CPU占用、内存。如果你的内存曲线持续上涨多半是对象泄漏。最容易泄漏的地方是每帧创建新对象// 反例每帧都new一个数组 function render() { const visibleEnemies enemies.filter(e e.isVisible) // 每帧创建新数组 }这种代码在运行几分钟后会卡顿明显。正确做法是复用数组const visibleEnemies [] function render() { visibleEnemies.length 0 enemies.forEach(e { if (e.isVisible) visibleEnemies.push(e) }) }另一个内存杀手是音频实例。createInnerAudioContext()创建的实例如果不destroy()每次进入新场景都会增加内存占用。我在切换场景前会统一销毁旧音频实例而不是让它们常驻。5.3 微信登录、订阅消息这些“不搭边”能力怎么处理做游戏并不一定需要微信登录但如果你要做排行榜就绕不开wx.login()换取openid的流程。小游戏里也可以用wx.getUserProfile()获取头像昵称但需要注意这个接口只有在用户点击按钮后调用才有效不能在游戏启动时直接弹。订阅消息是小游戏中做“游戏结束后提醒”的常用能力。请求订阅要靠wx.requestSubscribeMessage而且必须由用户点击触发。我的做法是在“救公主失败再来一次”的按钮上带一个订阅勾选项让用户主动选择而不是开局就弹。既提高授权通过率也不会因为频繁打扰被用户反感。还有包体内的分包加载如果你既有游戏关卡又有多个UI页面可以用wx.loadSubpackage()做分包。加载过程中可以放一个“正在加载公主的地图...”的过渡动画避免用户以为卡死了。注意“微信开发者工具里的小程序怎么发给其他人试用”这个问题其实就是在预览里生成带有效期的二维码微信扫码后需要打开“开发版”并添加体验成员。这个流程我在多个项目里都遇到过建议提早把团队成员加进体验成员列表不然每改一次都要对方重新扫码效率极低。最后再分享一点我个人的体会把一个经典玩法搬到新的平台价值不在于复制而在于“适配后的全新体验”。马里奥手感好是因为任天堂在硬件上做了极其细致的调校而小程序里能不能复现那种手感取决于你肯花多少时间在真机上逐帧调参数。我前后用了两周打磨重力、跳跃和敌人碰撞的数值踩碎的坑比写代码的时间多得多。如果你也正在做同类的小程序游戏记住一句话先让角色跳得“跟脚”再谈画面和剧情。公主能不能被救出来往往由一次跳跃的起跳时机决定。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →