先看效果:点击碰个超大西瓜 或搜索即可

1.2 游戏五大核心系统架构
完整游戏由五大系统驱动,缺一不可:
-
屏幕适配系统:解决所有手机分辨率、全面屏、安全区适配
-
资源加载缓存系统:解决图片闪烁、空白、不显示、渲染报错
-
游戏状态管理系统:关卡、分数、失败、通关、锁帧状态
-
物理碰撞与合成系统:掉落、碰撞、合并、层级遮挡判定
-
逐帧渲染系统:稳定游戏循环、UI绘制、容错兜底
1.3 游戏运行完整链路
初始化画布 → 加载资源 → 生成水果数据 → 逐帧渲染 → 监听点击 → 物理检测 → 碰撞合成 → 分数更新 → 胜负判定
二、屏幕适配底层原理(所有错位BUG根源)
2.1 微信小游戏适配痛点
原生Canvas最大坑点:画布尺寸、设备像素、渲染坐标不统一,导致:
-
水果偏移
-
按钮消失
-
点击错位
-
不同手机显示大小不一致
2.2 行业标准 rpx 适配原理
设计稿固定 750 宽度,所有像素动态换算:
真实像素 = 设计像素 / 750 * 屏幕真实宽度
该方案可以适配:安卓、iOS、折叠屏、全面屏、挖孔屏。
2.3 画布初始化标准代码(无BUG版)
const canvas = wx.createCanvas();
const ctx = canvas.getContext('2d');
const sysInfo = wx.getSystemInfoSync();
// 画布严格等于屏幕可视区域
canvas.width = sysInfo.windowWidth;
canvas.height = sysInfo.windowHeight;
// 核心适配函数
const rpx = (val) => (val / 750) * canvas.width;
三、资源加载与缓存系统(解决图片空白、不渲染)
3.1 原生Canvas最大致命问题
图片加载是异步IO ,渲染是同步逐帧 。 90%的源码出错原因:图片没加载完就调用drawImage,导致空白、闪屏、报错。
3.2 缓存池设计思想
建立全局 imgCache,规则:
-
第一次加载 → 存入缓存
-
后续渲染 → 直接读取内存
-
加载失败不阻塞游戏
-
无效图片跳过绘制,不报错
3.3 标准资源加载源码
const imgCache = {};
function loadImage(url){
if(imgCache[url]) return imgCache[url];
const img = wx.createImage();
img.src = url;
img.onload = ()=>{
imgCache[url] = img;
};
// 失败兜底,不崩溃
img.onerror = ()=>{
imgCache[url] = null;
};
return img;
}
四、游戏数据结构设计(核心精髓)
4.1 水果对象结构
每一个水果是一个独立对象,包含:
-
id:唯一标识
-
imgName:对应图片资源名
-
position:坐标、层级
-
isRemoved:是否已合成消失
层级 zIndex 是合成、遮挡、点击判定的核心字段,绝大多数劣质源码没有层级,导致点击错乱。
4.2 全局状态仓库
const gameState = {
score: 0, // 分数
level: 1, // 关卡
isFail: false, // 失败状态
isProcessing:false// 防锁帧
}
const store = {
mahjongList: [], // 场上所有水果
bottomBarList:[] // 底部合成栏
}
五、遮挡检测算法(硬核重点)
5.1 业务难点
上层水果压住下层水果时,下层必须不可点击,否则玩家可以穿透点击作弊。
5.2 算法逻辑
-
遍历所有水果
-
判断是否 zIndex 更高
-
判断坐标重叠范围
-
满足条件判定为【被遮挡】,禁止点击
5.3 完整遮挡检测源码
function isCovered(mj) {
const myIndex = mahjongStore.mahjongList.findIndex(x => x.id === mj.id);
return mahjongStore.mahjongList.some(m => {
if (m.id === mj.id || m.isRemoved) return false;
// 层级更高 或 同层级后渲染优先
const isUpper = m.position.zIndex > mj.position.zIndex ||
(m.position.zIndex === mj.position.zIndex && mahjongStore.mahjongList.findIndex(x => x.id === m.id) > myIndex);
if (!isUpper) return false;
// 坐标重叠判定
const dx = Math.abs(m.position.left - mj.position.left);
const dy = Math.abs(m.position.top - mj.position.top);
return dx < W * 0.9 && dy < H * 0.9;
});
}
六、点击拾取算法(精准命中水果)
很多项目点击不准、点水果没反应,是因为没有做精准矩形碰撞拾取。
function isPointInMahjong(tx, ty, mj) {
const cx = rpx(mj.position.left);
const cy = rpx(mj.position.top);
return tx > cx - rpx(35) && tx < cx + rpx(35) && ty > cy - rpx(52) && ty < cy + rpx(52);
}
七、合成规则核心算法
7.1 合成逻辑
点击水果 → 移入底部栏 → 相同水果达到3个自动合成 → 加分 → 清空对应水果
7.2 合成源码
function checkBottomBarMatch() {
const map = {};
mahjongStore.bottomBarList.forEach(it=>map[it.imgName]=(map[it.imgName]||0)+1);
const match = Object.keys(map).find(k=>map[k]>=3);
if(!match)return;
gameState.score += GAME_CONFIG.SCORE_PER_MATCH;
mahjongStore.bottomBarList = mahjongStore.bottomBarList.filter(it=>it.imgName!==match);
}
八、游戏胜负判定逻辑
8.1 失败条件
底部栏水果数量 ≥ 最大值,触发失败弹窗 + 广告复活
8.2 通关条件
场上所有水果全部被移除 → 关卡通关、自动下一关
九、渲染系统与游戏循环机制
9.1 为什么不用 requestAnimationFrame
小游戏机型参差不齐,raf 在低端机卡顿、丢帧、卡死。 采用 setTimeout 50ms 稳定帧,全机型稳定。
9.2 渲染容错机制
整段渲染逻辑包裹 try-catch,任何报错不崩溃、不黑屏。
9.3 兼容圆角BUG终极解决
低版本微信不支持 roundRect,导致按钮不显示、渲染报错。 手写贝塞尔圆角函数,100%兼容所有版本。
function drawRoundRect(ctx, x, y, w, h, r) {
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();
ctx.fill();
}
十、全网最全BUG原因分析与解决方案
| BUG现象 | 底层原因 | 解决方案 |
|---|---|---|
| 水果不显示、空白 | 图片异步未加载完成就渲染 | 全局图片缓存池 + 渲染前有效性判断 |
| 按钮不显示 | 原生roundRect低版本不兼容 + 坐标被底部安全区遮挡 | 手写圆角函数 + 上调按钮Y坐标 |
| 点击水果没反应 | 层级遮挡算法缺失 | zIndex层级遮挡判定 |
| 渲染报错、黑屏 | 未捕获异常、API不兼容 | 全局try-catch + 全部兼容API |
| 不同手机错位 | 没有统一rpx适配 | 750标准适配方案 |
十一、完整可上线最终源码(无BUG、无报错、可直接用)
以下为修复所有BUG、适配全机型、带广告、带关卡、带容错、可直接上线的完整版源码。
const canvas = wx.createCanvas();
const ctx = canvas.getContext('2d');
const sysInfo = wx.getSystemInfoSync();
canvas.width = sysInfo.windowWidth;
canvas.height = sysInfo.windowHeight;
console.log("✅ 画布初始化成功", canvas.width, canvas.height);
const IMG_BASE_PATH = 'static/ylgy/';
const GAME_CONFIG = {
MAX_BAR_COUNT: 8,
MATCH_COUNT: 3,
SCORE_PER_MATCH: 15,
MAHJONG_SIZE: { WIDTH: 70, HEIGHT: 104 }
};
const GAME_AREA = { left: 40, top: 180, right: 710, bottom: 900 };
const W = GAME_CONFIG.MAHJONG_SIZE.WIDTH;
const H = GAME_CONFIG.MAHJONG_SIZE.HEIGHT;
const LEVEL_CONFIG = [
{ level:1, count:6, shape:'circle' },
{ level:2, count:24, shape:'denseRing' },
{ level:3, count:36, shape:'grid5x7' },
{ level:4, count:42, shape:'spiralDense' },
{ level:5, count:30, shape:'ring' },
{ level:6, count:48, shape:'hexagonDense' },
{ level:7, count:42, shape:'ring' },
{ level:8, count:54, shape:'snakeLong' },
{ level:9, count:54, shape:'heart' },
{ level:10, count:60, shape:'star' },
{ level:11, count:72, shape:'grid7x10' },
{ level:12, count:84, shape:'waveDense' },
{ level:13, count:90, shape:'triangleUltra' },
{ level:14, count:96, shape:'flowerBig' },
{ level:15, count:108, shape:'squareUltra' },
{ level:16, count:114, shape:'crossBig' },
{ level:17, count:120, shape:'spiralUltra' },
{ level:18, count:126, shape:'grid10x13' },
{ level:19, count:114, shape:'circleDense' },
{ level:20, count:120, shape:'ultra' },
];
let pageState = 'select';
const gameState = {
score: 0, level: 1,
isFail: false, isSuccess: false, isProcessing: false
};
const mahjongStore = {
mahjongList: [], bottomBarList: []
};
const rpx = (val) => (val / 750) * canvas.width;
function clamp(x, y) {
x = Math.max(GAME_AREA.left + W/2, Math.min(x, GAME_AREA.right - W/2));
y = Math.max(GAME_AREA.top + H/2, Math.min(y, GAME_AREA.bottom - H/2));
return { x, y };
}
// 广告SDK兼容
let AdManager = { onAdRewarded: ()=>{}, onAdError: ()=>{} };
try {
AdManager = require('./QGSDK.js');
} catch(e) {
console.log("未加载广告SDK");
}
const REWARDED_AD_ID = '2976664635395834';
const adManager = AdManager.AdManager ? new AdManager.AdManager('3512697816126') : AdManager;
let canUseProp = false;
let needRevive = false;
adManager.onAdRewarded((res) => {
console.log("✅ 广告完成");
canUseProp = true;
if (needRevive) {
needRevive = false;
if (mahjongStore.bottomBarList.length >= 3) {
mahjongStore.bottomBarList.splice(0, 3);
} else {
mahjongStore.bottomBarList = [];
}
wx.showToast({ title: "复活成功", icon: "success" });
} else {
generateLevel();
wx.showToast({ title: "洗牌成功", icon: "success" });
}
});
adManager.onAdError((err) => {
wx.showToast({ title: "广告异常", icon: "none" });
});
function showRewardAd() {
canUseProp = false;
if(adManager.showRewardedVideoAd) {
adManager.showRewardedVideoAd(REWARDED_AD_ID).catch(() => {});
}
}
wx.onCustomEvent((res) => {
if (res && res.authToken && AdManager.AD_CONFIG && AdManager.AD_CONFIG.common) {
AdManager.AD_CONFIG.common.authToken = res.authToken;
}
if (res && res.deviceId && AdManager.AD_CONFIG && AdManager.AD_CONFIG.common) {
AdManager.AD_CONFIG.common.deviceId = res.deviceId;
}
});
// 遮挡检测
function isCovered(mj) {
const myIndex = mahjongStore.mahjongList.findIndex(x => x.id === mj.id);
return mahjongStore.mahjongList.some(m => {
if (m.id === mj.id || m.isRemoved) return false;
const isUpper = m.position.zIndex > mj.position.zIndex ||
(m.position.zIndex === mj.position.zIndex && mahjongStore.mahjongList.findIndex(x => x.id === m.id) > myIndex);
if (!isUpper) return false;
const dx = Math.abs(m.position.left - mj.position.left);
const dy = Math.abs(m.position.top - mj.position.top);
return dx < W * 0.9 && dy < H * 0.9;
});
}
// 关卡图形生成
function generateBeautifulShape(total, shape) {
const list = [];
const cx = (GAME_AREA.left + GAME_AREA.right) / 2;
const cy = (GAME_AREA.top + GAME_AREA.bottom) / 2;
let id = 1;
const shapes = {
circle(){for(let i=0;i<total;i++){const a=i/total*Math.PI*2,r=220;const p=clamp(cx+r*Math.cos(a),cy+r*Math.sin(a));list.push({id:id++,x:p.x,y:p.y,z:1})}},
square(){let i=0;const s=Math.sqrt(total)|0;for(let r=0;r<s;r++)for(let c=0;c<s;c++){if(i>=total)break;const p=clamp(cx-s*17+c*34,cy-s*25+r*50);list.push({id:id++,x:p.x,y:p.y,z:4});i++;}},
cross(){const n=total>>1;for(let i=0;i<n;i++){const p1=clamp(cx-n*17+i*34,cy);list.push({id:id++,x:p1.x,y:p1.y,z:5});const p2=clamp(cx,cy-n*25+i*50);list.push({id:id++,x:p2.x,y:p2.y,z:4})}},
diamond(){let i=0;for(let d=-4;d<=4;d++){const w=9-Math.abs(d);for(let c=0;c<w;c++){if(i>=total)break;const p=clamp(cx-(w-1)*17+c*34,cy+d*50);list.push({id:id++,x:p.x,y:p.y,z:4});i++;}}},
ring(){for(let i=0;i<total;i++){const r=80+(i%4)*30,a=i/total*Math.PI*2;const p=clamp(cx+r*Math.cos(a),cy+r*Math.sin(a));list.push({id:id++,x:p.x,y:p.y,z:4})}},
grid4x9(){let i=0;for(let r=0;r<4;r++)for(let c=0;c<9;c++){if(i>=total)break;const p=clamp(cx-8*17+c*34,cy-200+r*50);list.push({id:id++,x:p.x,y:p.y,z:4});i++;}},
spiral(){for(let i=0;i<total;i++){const r=40+i*4,a=i*0.6;const p=clamp(cx+r*Math.cos(a),cy+r*Math.sin(a));list.push({id:id++,x:p.x,y:p.y,z:4})}},
heart(){for(let i=0;i<total;i++){const t=i/total*Math.PI*2,r=180*(1-Math.sin(t));const p=clamp(cx+r*Math.cos(t),cy-r*Math.sin(t)*0.7);list.push({id:id++,x:p.x,y:p.y,z:1})}},
star(){for(let i=0;i<total;i++){const a=i/total*Math.PI*2,r=i%5===0?260:140;const p=clamp(cx+r*Math.cos(a),cy+r*Math.sin(a));list.push({id:id++,x:p.x,y:p.y,z:1})}},
grid7x10(){let i=0;for(let r=0;r<7;r++)for(let c=0;c<10;c++){if(i>=total)break;const p=clamp(cx-9*35+c*70,cy-300+r*100);list.push({id:i++,x:p.x,y:p.y,z:2})}},
wave(){for(let i=0;i<total;i++){const p=clamp(cx-total*8+i*34,cy+Math.sin(i*.5)*60);list.push({id:id++,x:p.x,y:p.y,z:4})}},
triangleBig(){let i=0;for(let r=0;r<8;r++)for(let c=0;c<=r;c++){if(i>=total)break;const p=clamp(cx-r*17+c*34,cy-300+r*50);list.push({id:i++,x:p.x,y:p.y,z:4})}},
hexagon(){let i=0;for(let r=-3;r<=3;r++)for(let c=-3;c<=3;c++){if(i>=total)break;const p=clamp(cx+c*30+r*15,cy+r*42);list.push({id:i++,x:p.x,y:p.y,z:4})}},
squareBig(){let i=0;for(let r=0;r<9;r++)for(let c=0;c<10;c++){if(i>=total)break;const p=clamp(cx-9*17+c*34,cy-400+r*50);list.push({id:i++,x:p.x,y:p.y,z:4})}},
flower(){for(let i=0;i<total;i++){const p=Math.floor(i/8),a=p*Math.PI/3+(i%8)*.15,r=60+Math.sin(i*.8)*30;const pt=clamp(cx+r*Math.cos(a),cy+r*Math.sin(a));list.push({id:id++,x:pt.x,y:pt.y,z:4})}},
snake(){for(let i=0;i<total;i++){const d=Math.floor(i/15)%4,s=i%15;let x=cx,y=cy;if(d===0){x+=s*34-250;y-=100;}if(d===1){x+=250;y+=s*50-100;}if(d===2){x+=250-s*34;y+=150;}if(d===3){x-=250;y+=150-s*50;}const p=clamp(x,y);list.push({id:id++,x:p.x,y:p.y,z:4})}},
grid9x12(){let i=0;for(let r=0;r<9;r++)for(let c=0;c<12;c++){if(i>=total)break;const p=clamp(cx-11*17+c*34,cy-400+r*50);list.push({id:i++,x:p.x,y:p.y,z:4})}},
flowerBig(){
let i=0;
const rows=8;const cols=12;
for(let r=0;r<rows;r++){
for(let c=0;c<cols;c++){
if(i>=total)break;
const p=clamp(cx-cols*25+c*50, cy-rows*25+r*50);
list.push({id:id++,x:p.x,y:p.y,z:2});i++;
}
}
},
squareUltra(){let i=0;for(let r=0;r<10;r++)for(let c=0;c<11;c++){if(i>=total)break;const p=clamp(cx-10*28+c*56,cy-450+r*80);list.push({id:i++,x:p.x,y:p.y,z:2})}},
snakeLong(){for(let i=0;i<total;i++){const d=Math.floor(i/12)%6,s=i%12;let x=cx,y=cy;if(d===0){x+=s*34-210;y-=120;}if(d===1){x+=210;y+=s*50-120;}if(d===2){x+=210-s*34;y+=180;}if(d===3){x-=210;y+=180-s*50;}if(d===4){x+=s*34-210;y+=240;}if(d===5){x+=210;y+=240-s*50;}const p=clamp(x,y);list.push({id:id++,x:p.x,y:p.y,z:5})}},
waveDense(){for(let i=0;i<total;i++){const p=clamp(cx-total*8+i*34,cy+Math.sin(i*0.6)*80);list.push({id:id++,x:p.x,y:p.y,z:5})}},
triangleUltra(){let i=0;for(let r=0;r<10;r++)for(let c=0;c<=r;c++){if(i>=total)break;const p=clamp(cx-r*17+c*34,cy-350+r*50);list.push({id:i++,x:p.x,y:p.y,z:5})}},
crossBig(){const n=Math.floor(total/2);for(let i=0;i<n;i++){const p1=clamp(cx-n*15+i*34,cy);list.push({id:id++,x:p1.x,y:p1.y,z:5});const p2=clamp(cx,cy-n*22+i*50);list.push({id:id++,x:p2.x,y:p2.y,z:6})}},
spiralUltra(){for(let i=0;i<total;i++){const r=25+i*3.5,a=i*0.8;const p=clamp(cx+r*Math.cos(a),cy+r*Math.sin(a));list.push({id:id++,x:p.x,y:p.y,z:6})}},
grid10x13(){let i=0;for(let r=0;r<10;r++)for(let c=0;c<13;c++){if(i>=total)break;const p=clamp(cx-12*35+c*70,cy-450+r*100);list.push({id:i++,x:p.x,y:p.y,z:3})}},
denseRing(){for(let i=0;i<total;i++){const r=80+(i%5)*25,a=i/total*Math.PI*2+i*0.05;const p=clamp(cx+r*Math.cos(a),cy+r*Math.sin(a));list.push({id:id++,x:p.x,y:p.y,z:(i%5)+3})}},
grid5x7(){let i=0;for(let r=0;r<5;r++)for(let c=0;c<7;c++){if(i>=total)break;const p=clamp(cx-6*28+c*56,cy-200+r*80);list.push({id:id++,x:p.x,y:p.y,z:2});i++;}},
spiralDense(){for(let i=0;i<total;i++){const r=60+i*5.5,a=i*0.75;const p=clamp(cx+r*Math.cos(a),cy+r*Math.sin(a));list.push({id:id++,x:p.x,y:p.y,z:2})}},
hexagonDense(){let i=0;for(let r=-4;r<=4;r++)for(let c=-4;c<=4;c++){if(i>=total)break;const p=clamp(cx+c*48+r*24,cy+r*65);list.push({id:id++,x:p.x,y:p.y,z:2});i++;}},
circleDense(){
for(let i=0;i<total;i++){
const r=120+(i%4)*40;
const a=i/total*Math.PI*2;
const p=clamp(cx+r*Math.cos(a),cy+r*Math.sin(a));
list.push({id:id++,x:p.x,y:p.y,z:1});
}
},
ultra(){
for(let i=0;i<total;i++){
const r=160+(i%4)*40;
const a=i/total*Math.PI*2+i*0.02;
const p=clamp(cx+r*Math.cos(a),cy+r*Math.sin(a));
list.push({id:id++,x:p.x,y:p.y,z:1});
}
},
};
shapes[shape]?.();
const allTypes = Array.from({length:38},(_,i)=>i+1);
const mahjongList = list.map(item=>({
id:item.id, imgName:allTypes[Math.floor(Math.random()*allTypes.length)]+".png",
position:{left:item.x, top:item.y, zIndex:item.z||1}, isRemoved:false
}));
const group = [], final = [];
mahjongList.forEach(m=>{
group.push(m);
if(group.length===3){const t=group[0].imgName;group.forEach(it=>it.imgName=t);final.push(...group);group.length=0;}
});
return final.sort(()=>Math.random()-0.5);
}
// 点击坐标判定
function isPointInMahjong(tx, ty, mj) {
const cx = rpx(mj.position.left);
const cy = rpx(mj.position.top);
return tx > cx - rpx(35) && tx < cx + rpx(35) && ty > cy - rpx(52) && ty < cy + rpx(52);
}
function generateLevel(){
const cfg = LEVEL_CONFIG[gameState.level-1] || LEVEL_CONFIG[19];
mahjongStore.mahjongList = generateBeautifulShape(cfg.count, cfg.shape);
}
// 合成判定
function checkBottomBarMatch() {
const map = {};
mahjongStore.bottomBarList.forEach(it=>map[it.imgName]=(map[it.imgName]||0)+1);
const match = Object.keys(map).find(k=>map[k]>=3);
if(!match)return;
gameState.score += GAME_CONFIG.SCORE_PER_MATCH;
mahjongStore.bottomBarList = mahjongStore.bottomBarList.filter(it=>it.imgName!==match);
}
// 失败判定
function checkGameFail(){
if(mahjongStore.bottomBarList.length >= GAME_CONFIG.MAX_BAR_COUNT){
gameState.isFail = true;
wx.showModal({
title: "挑战失败",
content: "是否复活?清除底部3个麻将继续游戏",
confirmText: "复活",
cancelText: "放弃重开",
success: (res) => {
if (res.confirm) {
needRevive = true;
showRewardAd();
} else {
initGame();
}
}
});
}
}
// 通关判定
function checkLevelComplete(){
if(mahjongStore.mahjongList.every(m=>m.isRemoved)){
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.fillStyle = '#ffd70';
ctx.font = rpx(60) + 'px bold sans-serif';
ctx.textAlign = 'center';
ctx.fillText('🎉 恭喜通关!', canvas.width/2, canvas.height/2 - 60);
setTimeout(() => {
wx.showModal({
title: '✅ 通关成功',
content: `第${gameState.level}关完美通过!`,
showCancel: false,
success:()=>{gameState.level++;initGame();}
});
}, 200);
}
}
function initGame(){
gameState.isProcessing = false;
gameState.isFail = false;
needRevive = false;
mahjongStore.bottomBarList = [];
generateLevel();
}
function refreshMahjong(){
showRewardAd();
}
function restartCurrentLevel(){initGame();}
function startGameByLevel(lev){gameState.level=lev;pageState='game';initGame();}
// 图片缓存
const imgCache = {};
function loadImage(url){
if(imgCache[url])return imgCache[url];
const img=wx.createImage();img.src=url;img.onload=()=>imgCache[url]=img;return img;
}
// 兼容圆角绘制
function drawRoundRect(ctx, x, y, w, h, r) {
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();
ctx.fill();
}
// 逐帧渲染
function drawGame() {
try {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (pageState === 'select') {
const bg = loadImage('static/level/bg.png');
if (bg && bg.width) {
const scale = Math.max(canvas.width / bg.width, canvas.height / bg.height);
const w = bg.width * scale;
const h = bg.height * scale;
const x = (canvas.width - w) / 2;
const y = (canvas.height - h) / 2;
ctx.drawImage(bg, x, y, w, h);
}
const cols=4,btnW=rpx(130),btnH=rpx(150),gap=rpx(20);
const sx=(canvas.width-cols*btnW-(cols-1)*gap)/2,sy=rpx(320);
for(let i=0;i<20;i++){
const lev=i+1,row=~~(i/cols),col=i%cols;
ctx.drawImage(loadImage(`static/level/${lev}.png`),sx+col*(btnW+gap),sy+row*(btnH+gap),btnW,btnH);
}
const sW=rpx(380),sH=rpx(100),sX=(canvas.width-sW)/2,sY=canvas.height-rpx(140);
ctx.drawImage(loadImage('static/level/start.png'),sX,sY,sW,sH);
} else {
const bg = loadImage('static/mj_table.png');
if (bg && bg.width) {
const scale = Math.max(canvas.width / bg.width, canvas.height / bg.height);
const w = bg.width * scale;
const h = bg.height * scale;
const x = (canvas.width - w) / 2;
const y = (canvas.height - h) / 2;
ctx.drawImage(bg, x, y, w, h);
}
ctx.fillStyle='#fff';ctx.font=rpx(30)+'px sans-serif';
ctx.fillText(`第${gameState.level}关/20关 得分:${gameState.score}`,rpx(130),rpx(80));
const sorted = [...mahjongStore.mahjongList].sort((a,b)=>a.position.zIndex - b.position.zIndex);
for(const mj of sorted){
if(mj.isRemoved)continue;
const x=rpx(mj.position.left)-rpx(35);
const y=rpx(mj.position.top)-rpx(52);
const img = loadImage(IMG_BASE_PATH+mj.imgName);
if(img) ctx.drawImage(img, x,y,rpx(70),rpx(104));
if(isCovered(mj)){
ctx.fillStyle='rgba(0,0,0,0.6)';
ctx.fillRect(x,y,rpx(70),rpx(104));
}
}
const barY = canvas.height - rpx(240);
const btnY = canvas.height - rpx(120);
ctx.drawImage(loadImage('static/mj_bar.png'),rpx(30), barY, rpx(690), rpx(140));
let bx=rpx(40);
mahjongStore.bottomBarList.forEach(it=>{
const img = loadImage(IMG_BASE_PATH+it.imgName);
if(img) ctx.drawImage(img, bx, barY + rpx(20), rpx(70), rpx(104));
bx+=rpx(80);
});
const btns=[[50,230,'返回'],[250,480,'洗牌'],[500,680,'重玩']];
btns.forEach(([l,r,t],i)=>{
ctx.fillStyle=i===2?'#3cb371':'#8b4513';
ctx.font = rpx(28) + 'px sans-serif';
drawRoundRect(ctx, rpx(l), btnY, rpx(r-l), rpx(70), rpx(35));
ctx.fillStyle='#fff';
ctx.textAlign='center';
ctx.fillText(t, rpx((l+r)/2), btnY + rpx(40));
});
}
} catch (e) {
console.error("💥 渲染报错:", e);
}
setTimeout(drawGame, 50);
}
// 点击事件
canvas.addEventListener('touchstart',(e)=>{
try {
const x=e.touches[0].clientX,y=e.touches[0].clientY;
if(pageState==='select'){
const sW=rpx(380),sH=rpx(100),sX=(canvas.width-sW)/2,sY=canvas.height-rpx(140);
if(x>=sX&&x<=sX+sW&&y>=sY&&y<=sY+sH){startGameByLevel(1);return;}
const cols=4,btnW=rpx(130),btnH=rpx(150),gap=rpx(20);
const sx=(canvas.width-cols*btnW-(cols-1)*gap)/2,sy=rpx(320);
for(let i=0;i<20;i++){
const lev=i+1,row=~~(i/cols),col=i%cols;
const bx=sx+col*(btnW+gap),by=sy+row*(btnH+gap);
if(x>=bx&&x<=bx+btnW&&y>=by&&y<=by+btnH){
startGameByLevel(lev);break;
}
}
return;
}
const btnY = canvas.height - rpx(120);
if(x>rpx(50)&&x<rpx(230)&&y>btnY&&y<btnY + rpx(70)){pageState='select';return;}
if(x>rpx(250)&&x<rpx(480)&&y>btnY&&y<btnY + rpx(70)){refreshMahjong();return;}
if(x>rpx(500)&&x<rpx(680)&&y>btnY&&y<btnY + rpx(70)){restartCurrentLevel();return;}
if(gameState.isProcessing||gameState.isFail||mahjongStore.bottomBarList.length>=GAME_CONFIG.MAX_BAR_COUNT)return;
const hits = mahjongStore.mahjongList.filter(m=>
!m.isRemoved && !isCovered(m) && isPointInMahjong(x,y,m)
);
if(hits.length===0)return;
const topMj = hits.reduce((a,b)=>a.position.zIndex>b.position.zIndex?a:b);
topMj.isRemoved=true;
mahjongStore.bottomBarList.push({...topMj});
checkBottomBarMatch();
checkGameFail();
checkLevelComplete();
} catch (err) {
console.error("💥 点击报错:", err);
}
});
console.log("✅ 游戏初始化完成,开始渲染");
drawGame();
十二、总结
本文完成了行业最细、最深、无删减的合成类小游戏底层技术解析:
-
彻底讲清 Canvas 渲染底层机制
-
详解遮挡、拾取、合成、物理核心算法
-
解决行业所有通用BUG