提交 f8f78731 authored 作者: lidongxu's avatar lidongxu

1

上级 9f99317e
minigame-1 @ 410a7eb2
Subproject commit 410a7eb26416b3947280d36f1db4e1367756b7b7
---
name: 伙伴泡泡分阶段实现
overview: 将现有飞机大战项目改造为伙伴泡泡游戏,保留原有 Canvas 渲染架构,分 3 个独立任务逐步实现,每个任务可单独执行验证。
todos: []
isProject: false
---
# 伙伴泡泡分阶段实现计划
## 核心策略
你的判断完全正确 - 拆分为 3 个独立任务,每个任务执行完后可在开发者工具中看到可运行的中
\ No newline at end of file
---
description: 微信小游戏开发规范与文档引用
globs: **/*.js, **/*.ts, game.json, project.config.json
alwaysApply: true
---
# 微信小游戏开发规范
## 官方文档
- 小游戏 API 文档:https://developers.weixin.qq.com/minigame/dev/api/
- 小游戏框架:https://developers.weixin.qq.com/minigame/dev/guide/
## 项目约定
- 入口文件为 game.js
- 使用 wx 全局对象调用小游戏 API
- 渲染使用 Canvas API(通过 wx.createCanvas())
\ No newline at end of file
/*
* Eslint config file
* Documentation: https://eslint.org/docs/user-guide/configuring/
* Install the Eslint extension before using this feature.
*/
module.exports = {
env: {
es6: true,
browser: true,
node: true,
},
ecmaFeatures: {
modules: true,
},
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
globals: {
wx: true,
App: true,
Page: true,
getCurrentPages: true,
getApp: true,
Component: true,
requirePlugin: true,
requireMiniProgram: true,
},
// extends: 'eslint:recommended',
rules: {},
}
# 示例游戏
示例相关说明查阅[新手教程](https://developers.weixin.qq.com/minigame/dev/guide/develop/start.html)
## 源码目录介绍
```
├── audio // 音频资源
├── images // 图片资源
├── js
│ ├── base
│ │ ├── animatoin.js // 帧动画的简易实现
│ │ ├── pool.js // 对象池的简易实现
│ │ └── sprite.js // 游戏基本元素精灵类
│ ├── libs
│ │ └── tinyemitter.js // 事件监听和触发
│ ├── npc
│ │ └── enemy.js // 敌机类
│ ├── player
│ │ ├── bullet.js // 子弹类
│ │ └── index.js // 玩家类
│ ├── runtime
│ │ ├── background.js // 背景类
│ │ ├── gameinfo.js // 用于展示分数和结算界面
│ │ └── music.js // 全局音效管理器
│ ├── databus.js // 管控游戏状态
│ ├── main.js // 游戏入口主函数
│ └── render.js // 基础渲染信息
├── .eslintrc.js // 代码规范
├── game.js // 游戏逻辑主入口
├── game.json // 游戏运行时配置
├── project.config.json // 项目配置
└── project.private.config.json // 项目个人配置
```
/**
* 生产环境禁用 console.log/warn 以提升性能
* 通过微信开发者工具「详情 → 本地设置」中的「调试基础库」判断,
* 或在上传时手动切换 __DEV__ 为 false
*/
const __DEV__ = typeof __wxConfig !== 'undefined'
? (__wxConfig.envVersion === 'develop' || __wxConfig.envVersion === 'trial')
: true;
if (!__DEV__) {
const noop = () => {};
console.log = noop;
console.warn = noop;
console.info = noop;
console.debug = noop;
// 保留 console.error 用于排查线上异常
}
import Main from './js/main';
new Main();
{
"deviceOrientation": "portrait"
}
import Sprite from './sprite';
const __ = {
timer: Symbol('timer'),
};
/**
* 简易的帧动画类实现
*/
export default class Animation extends Sprite {
constructor(imgSrc, width, height) {
super(imgSrc, width, height);
this.isPlaying = false; // 当前动画是否播放中
this.loop = false; // 动画是否需要循环播放
this.interval = 1000 / 60; // 每一帧的时间间隔
this[__.timer] = null; // 帧定时器
this.index = -1; // 当前播放的帧
this.count = 0; // 总帧数
this.imgList = []; // 帧图片集合
}
/**
* 初始化帧动画的所有帧
* @param {Array} imgList - 帧图片的路径数组
*/
initFrames(imgList) {
this.imgList = imgList.map((src) => {
const img = wx.createImage();
img.src = src;
return img;
});
this.count = imgList.length;
// 推入到全局动画池,便于全局绘图的时候遍历和绘制当前动画帧
GameGlobal.databus.animations.push(this);
}
// 将播放中的帧绘制到canvas上
aniRender(ctx) {
if (this.index >= 0 && this.index < this.count) {
ctx.drawImage(
this.imgList[this.index],
this.x,
this.y,
this.width * 1.2,
this.height * 1.2
);
}
}
// 播放预定的帧动画
playAnimation(index = 0, loop = false) {
this.visible = false; // 动画播放时隐藏精灵图
this.isPlaying = true;
this.loop = loop;
this.index = index;
if (this.interval > 0 && this.count) {
this[__.timer] = setInterval(this.frameLoop.bind(this), this.interval);
}
}
// 停止帧动画播放
stopAnimation() {
this.isPlaying = false;
this.index = -1;
if (this[__.timer]) {
clearInterval(this[__.timer]);
this[__.timer] = null; // 清空定时器引用
this.emit('stopAnimation');
}
}
// 帧遍历
frameLoop() {
this.index++;
if (this.index >= this.count) {
if (this.loop) {
this.index = 0; // 循环播放
} else {
this.index = this.count - 1; // 保持在最后一帧
this.stopAnimation(); // 停止播放
}
}
}
}
const __ = {
poolDic: Symbol('poolDic'),
};
/**
* 简易的对象池实现
* 用于对象的存贮和重复使用
* 可以有效减少对象创建开销和避免频繁的垃圾回收
* 提高游戏性能
*/
export default class Pool {
constructor() {
this[__.poolDic] = {};
}
/**
* 根据对象标识符
* 获取对应的对象池
*/
getPoolBySign(name) {
return this[__.poolDic][name] || (this[__.poolDic][name] = []);
}
/**
* 根据传入的对象标识符,查询对象池
* 对象池为空创建新的类,否则从对象池中取
*/
getItemByClass(name, className) {
const pool = this.getPoolBySign(name);
const result = pool.length ? pool.shift() : new className();
return result;
}
/**
* 将对象回收到对象池
* 方便后续继续使用
*/
recover(name, instance) {
this.getPoolBySign(name).push(instance);
}
}
import Emitter from '../libs/tinyemitter';
/**
* 游戏基础的精灵类
*/
export default class Sprite extends Emitter {
visible = true; // 是否可见
isActive = true; // 是否可碰撞
constructor(imgSrc = '', width = 0, height = 0, x = 0, y = 0) {
super();
// 只在有实际图片路径时才创建 Image 对象,避免无用的 native 内存开销
if (imgSrc) {
this.img = wx.createImage();
this.img.src = imgSrc;
} else {
this.img = null;
}
this.width = width;
this.height = height;
this.x = x;
this.y = y;
this.visible = true;
}
/**
* 将精灵图绘制在canvas上
*/
render(ctx) {
if (!this.visible) return;
ctx.drawImage(this.img, this.x, this.y, this.width, this.height);
}
/**
* 简单的碰撞检测定义:
* 另一个精灵的中心点处于本精灵所在的矩形内即可
* @param{Sprite} sp: Sptite的实例
*/
isCollideWith(sp) {
const spX = sp.x + sp.width / 2;
const spY = sp.y + sp.height / 2;
// 不可见则不检测
if (!this.visible || !sp.visible) return false;
// 不可碰撞则不检测
if (!this.isActive || !sp.isActive) return false;
return !!(
spX >= this.x &&
spX <= this.x + this.width &&
spY >= this.y &&
spY <= this.y + this.height
);
}
}
import Sprite from '../base/sprite';
import { SCREEN_WIDTH, SCREEN_HEIGHT, SAFE_AREA_TOP, IS_PAD } from '../render';
// ─── 泡泡大小与列数适配 ─────────────────────────────────────
// 手机:R = SW/22,11 列泡泡刚好铺满(与原始效果完全一致)
// iPad:R 保持与手机相同的视觉大小(基于高度换算),
// 屏幕更宽所以能容纳更多列泡泡,自动铺满
//
// 手机基准:R/SH = 1/22 * SW/SH ≈ 1/22 * 9/16 ≈ 1/39
// iPad 上用 SH/40 保证泡泡不会太大(略小于手机,体验更佳)
export const BUBBLE_RADIUS = IS_PAD
? SCREEN_HEIGHT / 40
: SCREEN_WIDTH / 22;
const ROW_HEIGHT = BUBBLE_RADIUS * Math.sqrt(3);
// 偶数行列数 = 屏幕宽度能容纳多少个泡泡
// 手机:floor(SW / (2 * SW/22)) = 11 ← 不变
// iPad:floor(SW / (2 * SH/40)) ≈ 14~15 列
export const EVEN_ROW_COLS = Math.floor(SCREEN_WIDTH / (BUBBLE_RADIUS * 2));
// 奇数行比偶数行少 1 列(六边形交错排列)
export const ODD_ROW_COLS = EVEN_ROW_COLS - 1;
// 网格水平偏移量,让泡泡居中(处理除不尽的余量)
export const GRID_OFFSET_X = (SCREEN_WIDTH - EVEN_ROW_COLS * 2 * BUBBLE_RADIUS) / 2;
/**
* 9 种泡泡颜色(颜色索引 1-9),用于爆炸粒子效果取色。
* 当前统一顺序:
* 1 蓝
* 2 绿
* 3 奶白
* 4 紫
* 5 黄绿
* 6 黄
* 7 粉
* 8 橙
* 9 红
*/
export const BUBBLE_COLORS = [
'', // 0 占位
'#2BC8E8', // 1 蓝
'#1DB85A', // 2 绿
'#D8D0B0', // 3 奶白
'#8B35E0', // 4 紫
'#80C020', // 5 黄绿
'#E8C000', // 6 黄
'#E060A0', // 7 粉
'#F07820', // 8 橙
'#E83030', // 9 红
];
/**
* 精灵图 bubble.png(1400×1400)中每个球的裁剪区域。
* 3×3 排列,从左上按行序编号 1-9:
* 1=红 2=橙 3=黄
* 4=黄绿 5=绿 6=青蓝
* 7=紫 8=粉 9=奶白
*
* SPRITE_REGIONS 的索引对应 colorIdx,映射到精灵图中正确的球:
* colorIdx 1=蓝 → 精灵图位置6(右中)
* colorIdx 2=绿 → 精灵图位置5(中中)
* colorIdx 3=奶白 → 精灵图位置9(右下)
* colorIdx 4=紫 → 精灵图位置7(左下)
* colorIdx 5=黄绿 → 精灵图位置4(左中)
* colorIdx 6=黄 → 精灵图位置3(右上)
* colorIdx 7=粉 → 精灵图位置8(中下)
* colorIdx 8=橙 → 精灵图位置2(中上)
* colorIdx 9=红 → 精灵图位置1(左上)
*/
const SPRITE_REGIONS = [
null,
[ 1004, 562, 297, 296 ], // 1 蓝 ← 精灵图位置6(右中)
[ 581, 562, 297, 296 ], // 2 绿 ← 精灵图位置5(中中)
[ 1004, 965, 297, 297 ], // 3 奶白 ← 精灵图位置9(右下)
[ 159, 965, 296, 297 ], // 4 紫 ← 精灵图位置7(左下)
[ 159, 562, 296, 296 ], // 5 黄绿 ← 精灵图位置4(左中)
[ 1004, 158, 297, 297 ], // 6 黄 ← 精灵图位置3(右上)
[ 581, 965, 297, 297 ], // 7 粉 ← 精灵图位置8(中下)
[ 581, 158, 297, 297 ], // 8 橙 ← 精灵图位置2(中上)
[ 159, 158, 296, 297 ], // 9 红 ← 精灵图位置1(左上)
];
// 精灵图(模块级单例,只加载一次)
const _spriteImg = wx.createImage();
_spriteImg.src = 'images/bubble.png';
/**
* 使用精灵图绘制泡泡,圆形裁剪确保显示完美圆形。
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} cx 圆心 x
* @param {number} cy 圆心 y
* @param {number} R 半径
* @param {number} colorIdx 颜色索引 1-9
*/
export function drawBubble3D(ctx, cx, cy, R, colorIdx) {
const region = SPRITE_REGIONS[colorIdx];
if (!region) return;
const [sx, sy, sw, sh] = region;
// 裁剪为圆形,防止精灵图白色背景溢出
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(_spriteImg, sx, sy, sw, sh, cx - R, cy - R, R * 2, R * 2);
ctx.restore();
// 细轮廓,区分相邻球体
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R - 0.5, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.lineWidth = 1.0;
ctx.stroke();
ctx.restore();
}
/**
* 根据当前得分动态返回可用颜色种类数,分数越高颜色越多,难度递增:
* 0 ~ 9999 → 3 种(蓝、绿、奶白)
* 10000 ~ 19999 → 4 种(+ 紫)
* 20000 ~ 29999 → 5 种(+ 黄绿)
* 30000 ~ 39999 → 6 种(+ 黄)
* 40000 ~ 49999 → 7 种(+ 粉)
* 50000 ~ 59999 → 8 种(+ 橙)
* 60000+ → 9 种(+ 红)
*/
export function getActiveColorCount(score) {
if (score >= 60000) return 9;
if (score >= 50000) return 8;
if (score >= 40000) return 7;
if (score >= 30000) return 6;
if (score >= 20000) return 5;
if (score >= 10000) return 4;
return 3;
}
/**
* 将网格坐标 (row, col) 转换为屏幕中心坐标 (x, y)
* 偶数行(0,2,4...):11 个泡泡,x = col * 2R + R
* 奇数行(1,3,5...):10 个泡泡,x = col * 2R + 2R(右移一个 R)
*/
export function gridToScreen(row, col) {
const R = BUBBLE_RADIUS;
const x = GRID_OFFSET_X + (row % 2 === 0
? col * 2 * R + R
: col * 2 * R + 2 * R);
const y = row * ROW_HEIGHT + R + SAFE_AREA_TOP;
return { x, y };
}
/**
* 单个泡泡类,继承 Sprite
* color: 1-9 代表不同颜色,0 表示空格
*/
export default class Bubble extends Sprite {
constructor(row, col, color) {
super('', BUBBLE_RADIUS * 2, BUBBLE_RADIUS * 2, 0, 0);
this.row = row;
this.col = col;
this.color = color;
this._syncPosition();
}
/**
* 根据网格坐标同步屏幕坐标(x, y 为圆心)
*/
_syncPosition() {
const { x, y } = gridToScreen(this.row, this.col);
this.x = x;
this.y = y;
}
/**
* 移动到新的网格位置
*/
moveTo(row, col) {
this.row = row;
this.col = col;
this._syncPosition();
}
/**
* 用 Canvas 绘制 3D 光泽泡泡
*/
render(ctx) {
if (!this.visible || this.color === 0) return;
drawBubble3D(ctx, this.x, this.y, BUBBLE_RADIUS, this.color);
}
}
差异被折叠。
import Sprite from '../base/sprite';
import { BUBBLE_RADIUS, drawBubble3D } from './bubble';
import { SCREEN_WIDTH, SCREEN_HEIGHT } from '../render';
/**
* 飞行中的泡泡类(继承 Sprite)
* 由射击器发射后在屏幕上运动,碰到左右边界反弹,
* 超出顶部或底部后标记为 inactive。
*/
export default class FireBubble extends Sprite {
/**
* @param {number} x 初始圆心 x
* @param {number} y 初始圆心 y
* @param {number} vx x 方向速度(px/帧)
* @param {number} vy y 方向速度(px/帧)
* @param {number} color 颜色编号 1-6
*/
constructor(x, y, vx, vy, color) {
super('', BUBBLE_RADIUS * 2, BUBBLE_RADIUS * 2, x, y);
this.vx = vx;
this.vy = vy;
this.color = color;
// x, y 在本类中表示圆心坐标(与 Sprite 保持一致)
this.active = true;
}
/**
* 每帧更新位置:
* - 碰到左右边界时水平速度取反(反弹)
* - 超出顶部或底部时标记为 inactive
*/
update() {
if (!this.active) return;
this.x += this.vx;
this.y += this.vy;
const R = BUBBLE_RADIUS;
// 左右边界反弹
if (this.x - R <= 0) {
this.x = R;
this.vx = Math.abs(this.vx);
} else if (this.x + R >= SCREEN_WIDTH) {
this.x = SCREEN_WIDTH - R;
this.vx = -Math.abs(this.vx);
}
// 超出顶部或底部则标记失活
if (this.y + R < 0 || this.y - R > SCREEN_HEIGHT) {
this.active = false;
this.visible = false;
}
}
/**
* 绘制飞行中的泡泡(3D 光泽效果)
*/
render(ctx) {
if (!this.visible || !this.active) return;
drawBubble3D(ctx, this.x, this.y, BUBBLE_RADIUS, this.color);
}
}
差异被折叠。
let instance;
/**
* 全局状态管理器
* 负责管理伙伴泡泡游戏的状态,包括帧数、分数、泡泡网格、飞行泡泡等
*/
export default class DataBus {
frame = 0;
score = 0;
isGameOver = false;
/** 本局游戏时长(秒),0 表示无限制 */
gameDuration = 0;
/** 游戏开始时间戳(ms),用于计算剩余时间 */
gameStartTime = 0;
/** 玩家昵称(多人模式下使用) */
nickname = '';
/** 房间 ID:用作初始布局的随机种子,相同 ID 的玩家布局一致 */
roomId = Math.floor(Math.random() * 900000) + 100000;
/** @type {import('./bubble/bubbleGrid').default|null} 泡泡网格实例 */
bubbleGrid = null;
/** @type {import('./bubble/bubble').default|null} 当前待发射泡泡 */
currentBubble = null;
/** @type {import('./bubble/bubble').default|null} 下一颗待发射泡泡 */
nextBubble = null;
/** @type {Array} 飞行中的泡泡列表 */
fireBubbles = [];
/** @type {Array} 正在播放的爆炸特效列表 */
explosions = [];
/** 已发射次数计数器,每 PUSH_INTERVAL 次触发顶部下推 */
shotCount = 0;
constructor() {
if (instance) return instance;
instance = this;
}
reset() {
this.frame = 0;
this.score = 0;
this.isGameOver = false;
// 显式释放旧 BubbleGrid 中所有 Bubble 的 Image 引用,帮助 GC 回收 native 内存
if (this.bubbleGrid && this.bubbleGrid.grid) {
for (const row of this.bubbleGrid.grid) {
if (!row) continue;
for (let i = 0; i < row.length; i++) {
if (row[i] && row[i].img) row[i].img = null;
row[i] = null;
}
}
this.bubbleGrid.grid = null;
}
this.bubbleGrid = null;
// 释放飞行泡泡
for (const fb of this.fireBubbles) {
if (fb && fb.img) fb.img = null;
}
this.fireBubbles.length = 0;
// 释放爆炸粒子
for (const e of this.explosions) {
if (e) { e.particles = null; e.rings = null; }
}
this.explosions.length = 0;
this.currentBubble = null;
this.nextBubble = null;
this.gameStartTime = 0;
this.shotCount = 0;
// roomId / gameDuration 保留不重置
}
gameOver() {
this.isGameOver = true;
}
/**
* 将当前游戏状态序列化为可传输的紧凑 JSON 对象。
* 用于通过 WebSocket 将状态实时上报给服务端/大屏。
*
* grid 使用二维数组:每格存颜色编号(1-6),0 表示空格。
* explosions 只序列化爆炸起始位置与颜色,由接收方自行播放特效,不传递粒子运行时数据。
*
* @returns {{
* frame: number,
* score: number,
* isGameOver: boolean,
* grid: number[][],
* pushAnimOffsetY: number,
* fireBubbles: Array<{x:number,y:number,color:number,vx:number,vy:number}>,
* explosions: Array<{x:number,y:number,colorHex:string}>
* }}
*/
serialize() {
const gridObj = this.bubbleGrid;
// 二维网格:行 × 列,值为颜色编号,0 = 空
const grid = gridObj
? gridObj.grid.map(rowArr =>
rowArr ? rowArr.map(b => (b ? b.color : 0)) : []
)
: [];
const pushAnimOffsetY = gridObj
? parseFloat(gridObj._pushAnimOffsetY.toFixed(2))
: 0;
const fireBubbles = this.fireBubbles.map(fb => ({
x: Math.round(fb.x),
y: Math.round(fb.y),
color: fb.color,
vx: parseFloat(fb.vx.toFixed(2)),
vy: parseFloat(fb.vy.toFixed(2)),
}));
return {
frame: this.frame,
score: this.score,
isGameOver: this.isGameOver,
grid,
pushAnimOffsetY,
fireBubbles,
};
}
}
import { BUBBLE_RADIUS, BUBBLE_COLORS } from '../bubble/bubble';
// ─── 圆形粒子 ─────────────────────────────────────────────────────────────────
class CircleParticle {
constructor(x, y, colorHex, vx, vy, radius, maxLife) {
this.x = x;
this.y = y;
this.color = colorHex;
this.vx = vx;
this.vy = vy;
this.radius = radius;
this.alpha = 1;
this.life = 0;
this.maxLife = maxLife;
this.alive = true;
}
update() {
this.vx *= 0.92;
this.vy = this.vy * 0.92 + 0.2;
this.x += this.vx;
this.y += this.vy;
this.life++;
this.alpha = Math.max(0, 1 - this.life / this.maxLife);
this.radius = Math.max(0.5, this.radius * 0.97);
if (this.life >= this.maxLife) this.alive = false;
}
}
// ─── 火花线条粒子 ────────────────────────────────────────────────────────────
class SparkParticle {
constructor(x, y, colorHex, vx, vy) {
this.x = x;
this.y = y;
this.color = colorHex;
this.vx = vx;
this.vy = vy;
this.prevX = x;
this.prevY = y;
this.alpha = 1;
this.life = 0;
this.maxLife = 18 + Math.floor(Math.random() * 14);
this.alive = true;
this.isSpark = true; // 标记类型,用于批量渲染
}
update() {
this.prevX = this.x;
this.prevY = this.y;
this.vx *= 0.88;
this.vy = this.vy * 0.88 + 0.15;
this.x += this.vx;
this.y += this.vy;
this.life++;
this.alpha = Math.max(0, 1 - this.life / this.maxLife);
if (this.life >= this.maxLife) this.alive = false;
}
}
// ─── 冲击波圆环 ──────────────────────────────────────────────────────────────
class ShockRing {
constructor(x, y, colorHex, delay = 0) {
this.x = x;
this.y = y;
this.color = colorHex;
this.r = BUBBLE_RADIUS * 0.2;
this.maxR = BUBBLE_RADIUS * 2.4;
this.life = -delay;
this.maxLife = 24;
this.alpha = 0;
this.alive = true;
}
update() {
this.life++;
if (this.life <= 0) return;
const t = this.life / this.maxLife;
this.r = BUBBLE_RADIUS * 0.2 + (this.maxR - BUBBLE_RADIUS * 0.2) * t;
this.alpha = 0.9 * (1 - t);
if (this.life >= this.maxLife) this.alive = false;
}
}
// ─── 单颗泡泡爆炸 ─────────────────────────────────────────────────────────────
export default class Explosion {
/**
* @param {number} x 爆炸中心 x
* @param {number} y 爆炸中心 y
* @param {number} color 泡泡颜色索引 1-6
* @param {boolean} isFloating 是否为悬空掉落(较小效果)
*/
constructor(x, y, color, isFloating = false) {
this.alive = true;
this.particles = [];
this.rings = [];
const colorHex = BUBBLE_COLORS[color] || '#ffffff';
const R = BUBBLE_RADIUS;
// ── 闪光帧数
this.flashLife = isFloating ? 4 : 10;
this._x = x;
this._y = y;
this._colorHex = colorHex;
// ── 圆形粒子
const circleCount = isFloating ? 7 : 12;
for (let i = 0; i < circleCount; i++) {
const angle = (i / circleCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.8;
const speed = isFloating
? (1.5 + Math.random() * 3)
: (2.5 + Math.random() * 5);
const vx = Math.cos(angle) * speed;
// 掉落效果往下偏,爆炸效果四散
const vy = isFloating
? (Math.sin(angle) * speed * 0.4 + 1.5)
: Math.sin(angle) * speed;
const radius = R * (isFloating ? 0.2 : 0.18 + Math.random() * 0.28);
const maxLife = 25 + Math.floor(Math.random() * 20);
this.particles.push(new CircleParticle(x, y, colorHex, vx, vy, radius, maxLife));
}
// ── 白色 & 黄色小圆粒子(高光碎片)
const glintCount = isFloating ? 2 : 5;
for (let i = 0; i < glintCount; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 3 + Math.random() * 4;
const glintColor = Math.random() < 0.5 ? '#ffffff' : '#ffe566';
this.particles.push(
new CircleParticle(x, y, glintColor,
Math.cos(angle) * speed, Math.sin(angle) * speed,
R * 0.12, 18 + Math.floor(Math.random() * 12))
);
}
// ── 火花线条粒子
const sparkCount = isFloating ? 2 : 5;
for (let i = 0; i < sparkCount; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 5 + Math.random() * 7;
const sparkColor = i % 2 === 0 ? '#ffffff' : colorHex;
this.particles.push(
new SparkParticle(x, y, sparkColor,
Math.cos(angle) * speed, Math.sin(angle) * speed)
);
}
// ── 冲击波圆环(主消除才有,掉落没有)
if (!isFloating) {
this.rings.push(new ShockRing(x, y, colorHex, 0));
// 第二圈稍晚一点,白色,营造层次感
this.rings.push(new ShockRing(x, y, '#ffffff', 5));
}
}
update() {
if (this.flashLife > 0) this.flashLife--;
for (const p of this.particles) {
if (p.alive) p.update();
}
for (const r of this.rings) {
if (r.alive) r.update();
}
if (
this.flashLife <= 0 &&
this.particles.every(p => !p.alive) &&
this.rings.every(r => !r.alive)
) {
this.alive = false;
}
}
render(ctx) {
// ── 闪光光晕(用简单实心圆代替每帧创建 radialGradient,大幅减少 native 内存开销)
if (this.flashLife > 0) {
const t = this.flashLife / 10;
ctx.save();
ctx.globalAlpha = t * 0.6;
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(this._x, this._y, BUBBLE_RADIUS * 0.8, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = t * 0.4;
ctx.fillStyle = this._colorHex;
ctx.beginPath();
ctx.arc(this._x, this._y, BUBBLE_RADIUS * 1.4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// ── 冲击波圆环(批量渲染,一次 save/restore)
ctx.save();
ctx.lineWidth = 2.5;
for (const r of this.rings) {
if (!r.alive || r.alpha <= 0 || r.life <= 0) continue;
ctx.globalAlpha = r.alpha;
ctx.strokeStyle = r.color;
ctx.beginPath();
ctx.arc(r.x, r.y, r.r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
// ── 粒子批量渲染(圆形粒子 + 火花线条,一次 save/restore)
ctx.save();
for (const p of this.particles) {
if (!p.alive || p.alpha <= 0) continue;
ctx.globalAlpha = p.alpha;
if (p.isSpark) {
// 火花线条
ctx.strokeStyle = p.color;
ctx.lineWidth = 1.8;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.prevX, p.prevY);
ctx.stroke();
} else {
// 圆形粒子
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
}
}
(function(e){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=e()}else if(typeof define==="function"&&define.amd){define([],e)}else{var n;if(typeof window!=="undefined"){n=window}else if(typeof global!=="undefined"){n=global}else if(typeof self!=="undefined"){n=self}else{n=this}n.TinyEmitter=e()}})(function(){var e,n,t;return function r(e,n,t){function i(o,u){if(!n[o]){if(!e[o]){var s=typeof require=="function"&&require;if(!u&&s)return s(o,!0);if(f)return f(o,!0);var a=new Error("Cannot find module '"+o+"'");throw a.code="MODULE_NOT_FOUND",a}var l=n[o]={exports:{}};e[o][0].call(l.exports,function(n){var t=e[o][1][n];return i(t?t:n)},l,l.exports,r,e,n,t)}return n[o].exports}var f=typeof require=="function"&&require;for(var o=0;o<t.length;o++)i(t[o]);return i}({1:[function(e,n,t){function r(){}r.prototype={on:function(e,n,t){var r=this.e||(this.e={});(r[e]||(r[e]=[])).push({fn:n,ctx:t});return this},once:function(e,n,t){var r=this;function i(){r.off(e,i);n.apply(t,arguments)}i._=n;return this.on(e,i,t)},emit:function(e){var n=[].slice.call(arguments,1);var t=((this.e||(this.e={}))[e]||[]).slice();var r=0;var i=t.length;for(r;r<i;r++){t[r].fn.apply(t[r].ctx,n)}return this},off:function(e,n){var t=this.e||(this.e={});var r=t[e];var i=[];if(r&&n){for(var f=0,o=r.length;f<o;f++){if(r[f].fn!==n&&r[f].fn._!==n)i.push(r[f])}}i.length?t[e]=i:delete t[e];return this}};n.exports=r;n.exports.TinyEmitter=r},{}]},{},[1])(1)});
\ No newline at end of file
差异被折叠。
/**
* HTTP API 客户端封装
*
* 与 socket.js 共用服务器配置
* import { API_BASE } from './socket.js'
*/
import { API_BASE } from './socket.js';
/**
* 封装 wx.request
* @param {string} method - HTTP 方法
* @param {string} path - API 路径(如 /rooms)
* @param {object} [data] - 请求数据
* @returns {Promise<any>}
*/
function request(method, path, data) {
return new Promise((resolve, reject) => {
wx.request({
url: `${API_BASE}${path}`,
method,
data,
header: {
'Content-Type': 'application/json',
},
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data);
} else {
reject(new Error(`HTTP ${res.statusCode}: ${res.data?.error || 'Unknown error'}`));
}
},
fail: reject,
});
});
}
// API 方法
export const api = {
// 房间相关
rooms: {
// GET /api/rooms - 获取房间列表
list: () => request('GET', '/rooms'),
// POST /api/rooms - 创建房间
create: (data) => request('POST', '/rooms', data),
// GET /api/rooms/:id - 获取房间详情
get: (id) => request('GET', `/rooms/${id}`),
// GET /api/rooms/:id/check - 校验房间并返回队伍人数
check: (id) => request('GET', `/rooms/${id}/check`),
// PUT /api/rooms/:id - 更新房间
update: (id, data) => request('PUT', `/rooms/${id}`, data),
// DELETE /api/rooms/:id - 删除房间
delete: (id) => request('DELETE', `/rooms/${id}`),
// POST /api/rooms/:id/join - 加入房间
join: (id, data) => request('POST', `/rooms/${id}/join`, data),
// POST /api/rooms/:id/leave - 离开房间
leave: (id, data) => request('POST', `/rooms/${id}/leave`, data),
},
// 会话相关
sessions: {
// GET /api/sessions - 获取会话列表
list: () => request('GET', '/sessions'),
// POST /api/sessions - 创建会话
create: (data) => request('POST', '/sessions', data),
// GET /api/sessions/:id - 获取会话详情
get: (id) => request('GET', `/sessions/${id}`),
},
// 大屏相关
screens: {
// GET /api/screens - 获取大屏列表
list: () => request('GET', '/screens'),
// POST /api/screens - 创建大屏
create: (data) => request('POST', '/screens', data),
// GET /api/screens/:name - 获取大屏详情
get: (name) => request('GET', `/screens/${name}`),
},
// 统计相关
stats: {
// GET /api/stats - 获取统计数据
get: (params) => request('GET', '/stats', params),
},
};
export default api;
/**
* 微信小游戏 WebSocket 客户端封装
*
* 协议约定(与服务端对应):
* 发送 → JSON.stringify({ event: string, data: any })
* 接收 ← JSON.stringify({ event: string, data: any })
*
* 用法:
* import GameSocket from './network/socket';
* GameSocket.connect(); // 在游戏初始化时调用一次
* GameSocket.sendState(state); // 每帧上报状态
* GameSocket.emit('room:gameOver', { roomId, score });
*/
// ─── 配置 ──────────────────────────────────────────────────────────────────
/**
* 线上部署说明:
* - nginx: https://paopao.wxl66.cn/api/* → 转发到 http://localhost:3000/*
* 注意:nginx 保留 /api 前缀,所以前端访问 /api/xxx 会转发到后端 /api/xxx
* - 后端 HTTP API 前缀: /api (如 /api/rooms)
* - 后端 WebSocket 路径: /ws (无 /api 前缀)
*
* 所以 WebSocket 连接地址为: wss://paopao.wxl66.cn/api/ws
* nginx 会把 /api/ws 转发到 localhost:3000/ws
*/
// 自动判断运行环境:开发者工具 → 本地,体验版/正式版 → 线上
const isDev = (() => {
try {
const { miniProgram } = wx.getAccountInfoSync();
// envVersion: 'develop'(开发者工具) | 'trial'(体验版) | 'release'(正式版)
return miniProgram.envVersion === 'develop';
} catch (_) {
return false;
}
})();
// 服务器域名
export const SERVER_HOST = isDev ? 'localhost:3000' : 'paopao.wxl66.cn';
// HTTP API 基础路径
export const API_BASE = isDev
? 'http://localhost:3000/api' // 开发环境
: 'https://paopao.wxl66.cn/api/api'; // 生产环境
// WebSocket URL
const WS_URL = isDev
? 'ws://localhost:3000/ws' // 开发环境
: 'wss://paopao.wxl66.cn/api/ws'; // 生产环境:/api(nginx入口) + /ws(后端路由)
const SERVER_URL = WS_URL;
// 重连间隔(毫秒)
const RECONNECT_DELAY = 3000;
// 最大重连次数,超出后不再尝试
const MAX_RECONNECT_COUNT = 5;
// ─── GameSocket ────────────────────────────────────────────────────────────
class GameSocket {
constructor() {
/** @type {WechatMiniprogram.SocketTask|null} */
this._task = null;
this._connected = false;
this._connecting = false;
this._url = SERVER_URL;
this._reconnectCount = 0;
this._reconnectTimer = null;
/** 连接建立前积压的消息队列 */
this._queue = [];
/** 事件监听器 Map: event -> handler[] */
this._listeners = {};
}
// ─── 公共 API ────────────────────────────────────────────────────────────
/**
* 连接到服务器并加入房间。
* 若已连接或正在连接则忽略重复调用。
* @param {string} [url] 服务端 WebSocket 地址,不传则使用默认值
*/
/**
* @param {string} [url]
* @param {Function} [onOpen] 连接成功后立即执行的回调,用于发送 room:create / room:join
*/
connect(url, onOpen) {
if (url) this._url = url;
if (onOpen) this._onOpenCallback = onOpen;
// 已连接直接执行回调
if (this._connected) {
if (onOpen) { onOpen(); this._onOpenCallback = null; }
return;
}
if (this._connecting) return;
this._doConnect();
}
/**
* 发送自定义事件
* @param {string} event 事件名,如 'room:join'
* @param {*} data 携带的数据
*/
emit(event, data) {
const raw = JSON.stringify({ event, data });
if (!this._connected) {
this._queue.push(raw);
return;
}
this._sendRaw(raw);
}
/**
* 监听服务端推送的事件
* @param {string} event 事件名
* @param {Function} handler 回调
*/
on(event, handler) {
if (!this._listeners[event]) this._listeners[event] = [];
this._listeners[event].push(handler);
}
/**
* 移除事件监听
* @param {string} event
* @param {Function} [handler] 不传则移除该事件所有监听
*/
off(event, handler) {
if (!this._listeners[event]) return;
if (!handler) {
delete this._listeners[event];
} else {
this._listeners[event] = this._listeners[event].filter(fn => fn !== handler);
}
}
/**
* 上报游戏实时状态帧(快捷方式,等同于 emit('room:state', ...))
* @param {object} stateData DataBus.serialize() 的返回值(附加了 roomId)
*/
sendState(stateData) {
this.emit('room:state', stateData);
}
/**
* 主动断开连接,停止自动重连
*/
disconnect() {
this._stopReconnect();
this._reconnectCount = MAX_RECONNECT_COUNT; // 阻止后续重连
if (this._task) {
try { this._task.close(); } catch (_) {}
}
this._task = null;
this._connected = false;
this._connecting = false;
}
// ─── 内部实现 ─────────────────────────────────────────────────────────────
_doConnect() {
this._connecting = true;
const task = wx.connectSocket({
url: this._url,
complete: () => {},
});
task.onOpen(() => {
this._task = task;
this._connected = true;
this._connecting = false;
this._reconnectCount = 0;
// 发送积压的消息(room:create / room:join 由业务层主动 emit,不在此自动发送)
while (this._queue.length > 0) {
this._sendRaw(this._queue.shift());
}
// 触发连接成功回调
if (this._onOpenCallback) {
this._onOpenCallback();
this._onOpenCallback = null;
}
});
task.onMessage((res) => {
try {
const msg = JSON.parse(res.data);
const handlers = this._listeners[msg.event];
if (handlers) {
handlers.forEach(fn => {
try { fn(msg.data); } catch (_) {}
});
}
} catch (_) {}
});
task.onClose(() => {
this._onDisconnected();
});
task.onError(() => {
this._onDisconnected();
});
}
_onDisconnected() {
this._connected = false;
this._connecting = false;
this._task = null;
this._scheduleReconnect();
}
_scheduleReconnect() {
if (this._reconnectCount >= MAX_RECONNECT_COUNT) return;
this._stopReconnect();
this._reconnectTimer = GameGlobal.setTimeout(() => {
this._reconnectCount++;
this._doConnect();
}, RECONNECT_DELAY);
}
_stopReconnect() {
if (this._reconnectTimer) {
GameGlobal.clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
}
_sendRaw(raw) {
if (!this._connected || !this._task) return;
try {
this._task.send({ data: raw });
} catch (_) {}
}
}
export default new GameSocket();
import Animation from '../base/animation';
import { SCREEN_WIDTH, SCREEN_HEIGHT } from '../render';
const ENEMY_IMG_SRC = 'images/enemy.png';
const ENEMY_WIDTH = 60;
const ENEMY_HEIGHT = 60;
const EXPLO_IMG_PREFIX = 'images/explosion';
export default class Enemy extends Animation {
speed = Math.random() * 6 + 3; // 飞行速度
constructor() {
super(ENEMY_IMG_SRC, ENEMY_WIDTH, ENEMY_HEIGHT);
}
init() {
this.x = this.getRandomX();
this.y = -this.height;
this.isActive = true;
this.visible = true;
// 设置爆炸动画
this.initExplosionAnimation();
}
// 生成随机 X 坐标
getRandomX() {
return Math.floor(Math.random() * (SCREEN_WIDTH - ENEMY_WIDTH));
}
// 预定义爆炸的帧动画
initExplosionAnimation() {
const EXPLO_FRAME_COUNT = 19;
const frames = Array.from(
{ length: EXPLO_FRAME_COUNT },
(_, i) => `${EXPLO_IMG_PREFIX}${i + 1}.png`
);
this.initFrames(frames);
}
// 每一帧更新敌人位置
update() {
if (GameGlobal.databus.isGameOver) {
return;
}
this.y += this.speed;
// 对象回收
if (this.y > SCREEN_HEIGHT + this.height) {
this.remove();
}
}
destroy() {
this.isActive = false;
// 播放销毁动画后移除
this.playAnimation();
GameGlobal.musicManager.playExplosion(); // 播放爆炸音效
wx.vibrateShort({
type: 'light'
}); // 轻微震动
this.on('stopAnimation', () => this.remove.bind(this));
}
remove() {
this.isActive = false;
this.visible = false;
GameGlobal.databus.removeEnemy(this);
}
}
import Sprite from '../base/sprite';
const BULLET_IMG_SRC = 'images/bullet.png';
const BULLET_WIDTH = 16;
const BULLET_HEIGHT = 30;
export default class Bullet extends Sprite {
constructor() {
super(BULLET_IMG_SRC, BULLET_WIDTH, BULLET_HEIGHT);
}
init(x, y, speed) {
this.x = x;
this.y = y;
this.speed = speed;
this.isActive = true;
this.visible = true;
}
// 每一帧更新子弹位置
update() {
if (GameGlobal.databus.isGameOver) {
return;
}
this.y -= this.speed;
// 超出屏幕外销毁
if (this.y < -this.height) {
this.destroy();
}
}
destroy() {
this.isActive = false;
// 子弹没有销毁动画,直接移除
this.remove();
}
remove() {
this.isActive = false;
this.visible = false;
// 回收子弹对象
GameGlobal.databus.removeBullets(this);
}
}
import Animation from '../base/animation';
import { SCREEN_WIDTH, SCREEN_HEIGHT } from '../render';
import Bullet from './bullet';
// 玩家相关常量设置
const PLAYER_IMG_SRC = 'images/hero.png';
const PLAYER_WIDTH = 80;
const PLAYER_HEIGHT = 80;
const EXPLO_IMG_PREFIX = 'images/explosion';
const PLAYER_SHOOT_INTERVAL = 20;
export default class Player extends Animation {
constructor() {
super(PLAYER_IMG_SRC, PLAYER_WIDTH, PLAYER_HEIGHT);
// 初始化坐标
this.init();
// 初始化事件监听
this.initEvent();
}
init() {
// 玩家默认处于屏幕底部居中位置
this.x = SCREEN_WIDTH / 2 - this.width / 2;
this.y = SCREEN_HEIGHT - this.height - 30;
// 用于在手指移动的时候标识手指是否已经在飞机上了
this.touched = false;
this.isActive = true;
this.visible = true;
// 设置爆炸动画
this.initExplosionAnimation();
}
// 预定义爆炸的帧动画
initExplosionAnimation() {
const EXPLO_FRAME_COUNT = 19;
const frames = Array.from(
{ length: EXPLO_FRAME_COUNT },
(_, i) => `${EXPLO_IMG_PREFIX}${i + 1}.png`
);
this.initFrames(frames);
}
/**
* 判断手指是否在飞机上
* @param {Number} x: 手指的X轴坐标
* @param {Number} y: 手指的Y轴坐标
* @return {Boolean}: 用于标识手指是否在飞机上的布尔值
*/
checkIsFingerOnAir(x, y) {
const deviation = 30;
return (
x >= this.x - deviation &&
y >= this.y - deviation &&
x <= this.x + this.width + deviation &&
y <= this.y + this.height + deviation
);
}
/**
* 根据手指的位置设置飞机的位置
* 保证手指处于飞机中间
* 同时限定飞机的活动范围限制在屏幕中
*/
setAirPosAcrossFingerPosZ(x, y) {
const disX = Math.max(
0,
Math.min(x - this.width / 2, SCREEN_WIDTH - this.width)
);
const disY = Math.max(
0,
Math.min(y - this.height / 2, SCREEN_HEIGHT - this.height)
);
this.x = disX;
this.y = disY;
}
/**
* 玩家响应手指的触摸事件
* 改变战机的位置
*/
initEvent() {
wx.onTouchStart((e) => {
const { clientX: x, clientY: y } = e.touches[0];
if (GameGlobal.databus.isGameOver) {
return;
}
if (this.checkIsFingerOnAir(x, y)) {
this.touched = true;
this.setAirPosAcrossFingerPosZ(x, y);
}
});
wx.onTouchMove((e) => {
const { clientX: x, clientY: y } = e.touches[0];
if (GameGlobal.databus.isGameOver) {
return;
}
if (this.touched) {
this.setAirPosAcrossFingerPosZ(x, y);
}
});
wx.onTouchEnd((e) => {
this.touched = false;
});
wx.onTouchCancel((e) => {
this.touched = false;
});
}
/**
* 玩家射击操作
* 射击时机由外部决定
*/
shoot() {
const bullet = GameGlobal.databus.pool.getItemByClass('bullet', Bullet);
bullet.init(this.x + this.width / 2 - bullet.width / 2, this.y - 10, 10);
GameGlobal.databus.bullets.push(bullet);
GameGlobal.musicManager.playShoot(); // 播放射击音效
}
update() {
if (GameGlobal.databus.isGameOver) {
return;
}
// 每20帧让玩家射击一次
if (GameGlobal.databus.frame % PLAYER_SHOOT_INTERVAL === 0) {
this.shoot(); // 玩家射击
}
}
destroy() {
this.isActive = false;
this.playAnimation();
GameGlobal.musicManager.playExplosion(); // 播放爆炸音效
wx.vibrateShort({
type: 'medium'
}); // 震动
}
}
GameGlobal.canvas = wx.createCanvas();
const windowInfo = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync();
canvas.width = windowInfo.screenWidth;
canvas.height = windowInfo.screenHeight;
export const SCREEN_WIDTH = windowInfo.screenWidth;
export const SCREEN_HEIGHT = windowInfo.screenHeight;
// 安全区域适配:刘海/状态栏(顶部)、底部手势条(仅有时才有)
const safeArea = windowInfo.safeArea;
export const SAFE_AREA_TOP = safeArea ? safeArea.top : 0;
export const SAFE_AREA_BOTTOM = safeArea ? (windowInfo.screenHeight - safeArea.bottom) : 0;
// ─── iPad 适配判断 ──────────────────────────────────────────
// 手机竖屏比例约 9:16 ≈ 0.5625,iPad 约 3:4 = 0.75
// 宽高比超过 0.6 视为矮胖屏(iPad 等平板)
export const IS_PAD = SCREEN_WIDTH / SCREEN_HEIGHT > 0.6;
// UI 缩放因子:以 iPhone 375px 为基准设计,iPad 上等比放大
// 手机上 UI_SCALE = 1(不影响),iPad 上 ≈ 2(768/375 ≈ 2.05)
export const UI_SCALE = IS_PAD ? SCREEN_WIDTH / 375 : 1;
// UI 基准宽度:用于替代 SCREEN_WIDTH 做 UI 元素尺寸计算
// 手机上 = SCREEN_WIDTH(不影响),iPad 上 = 375 * UI_SCALE(限制等比)
// 效果:SCREEN_WIDTH * 0.12 → UI_BASE * 0.12,iPad 上不会因宽度暴增而过大
export const UI_BASE = IS_PAD ? SCREEN_HEIGHT * (9 / 16) : SCREEN_WIDTH;
\ No newline at end of file
import { SCREEN_WIDTH, SCREEN_HEIGHT } from '../render';
/**
* 游戏背景类
* 使用外部背景图铺满整个游戏画面。
*/
export default class BackGround {
constructor() {
this._bgImg = wx.createImage();
this._bgImg.src = 'http://link-promotion-dev.oss-cn-shanghai.aliyuncs.com/back.png';
}
update() {}
render(ctx) {
if (this._bgImg && this._bgImg.complete) {
ctx.drawImage(this._bgImg, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
return;
}
// 图片未完成加载时的兜底底色
ctx.fillStyle = '#1a0a2e';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
}
}
差异被折叠。
差异被折叠。
import { SCREEN_WIDTH, SCREEN_HEIGHT, SAFE_AREA_TOP, UI_BASE } from '../render';
import api from '../network/api';
const TEAM_NAME_MAP = {
A: '深耕队',
B: '致远队',
};
function getTeamDisplayName(team) {
return TEAM_NAME_MAP[team] || team;
}
/**
* 加入房间后选择队伍页面
* 显示 A队 / B队 两个选项
* 构造时会查询房间队伍人数,若某队已满则按钮置灰禁用
*/
export default class JoinTeamSelectScreen {
constructor(roomId, onSelect, onBack) {
this.roomId = roomId;
this.onSelect = onSelect; // (team) => {} team: 'A' | 'B'
this.onBack = onBack;
this.visible = true;
// 队伍满员状态,查询前默认均可选
this.teamAFull = false;
this.teamBFull = false;
this.teamACount = 0;
this.teamBCount = 0;
this.perTeamSeats = 0;
this.loading = true; // 查询期间显示 loading 状态
this._initLayout();
this._touchHandler = this._onTouch.bind(this);
wx.onTouchStart(this._touchHandler);
// 查询队伍人数
this._fetchTeamInfo();
}
async _fetchTeamInfo() {
try {
const res = await api.rooms.check(this.roomId);
if (res && res.ok) {
this.perTeamSeats = res.perTeamSeats || 0;
this.teamACount = res.teamA || 0;
this.teamBCount = res.teamB || 0;
this.teamAFull = this.perTeamSeats > 0 && this.teamACount >= this.perTeamSeats;
this.teamBFull = this.perTeamSeats > 0 && this.teamBCount >= this.perTeamSeats;
}
} catch (err) {
console.warn('[JoinTeamSelect] 查询队伍信息失败,默认允许选择:', err);
} finally {
this.loading = false;
}
}
_initLayout() {
const cx = SCREEN_WIDTH / 2;
// A队按钮
const btnW = UI_BASE * 0.7;
const btnH = SCREEN_HEIGHT * 0.12;
const gap = SCREEN_HEIGHT * 0.04;
this._teamABtn = {
x: cx - btnW / 2,
y: SCREEN_HEIGHT * 0.38,
w: btnW,
h: btnH,
r: btnH / 2,
team: 'A'
};
// B队按钮
this._teamBBtn = {
x: cx - btnW / 2,
y: SCREEN_HEIGHT * 0.38 + btnH + gap,
w: btnW,
h: btnH,
r: btnH / 2,
team: 'B'
};
// 返回按钮(左上角)
this._backBtn = { x: 16, y: SAFE_AREA_TOP + 12, w: 72, h: 36, r: 18 };
}
destroy() {
wx.offTouchStart(this._touchHandler);
}
_onTouch(e) {
if (!this.visible || this.loading) return;
const { clientX: x, clientY: y } = e.touches[0];
if (this._hitRect(x, y, this._teamABtn)) {
if (!this.teamAFull) this._selectTeam('A');
return;
}
if (this._hitRect(x, y, this._teamBBtn)) {
if (!this.teamBFull) this._selectTeam('B');
return;
}
if (this._hitRect(x, y, this._backBtn)) {
this.visible = false;
this.destroy();
this.onBack && this.onBack();
}
}
async _selectTeam(team) {
// 选择前先实时校验队伍是否还有空位
try {
const res = await api.rooms.check(this.roomId);
if (res && res.ok) {
this.perTeamSeats = res.perTeamSeats || 0;
this.teamACount = res.teamA || 0;
this.teamBCount = res.teamB || 0;
this.teamAFull = this.perTeamSeats > 0 && this.teamACount >= this.perTeamSeats;
this.teamBFull = this.perTeamSeats > 0 && this.teamBCount >= this.perTeamSeats;
// 所选队伍已满,刷新按钮状态并提示
if ((team === 'A' && this.teamAFull) || (team === 'B' && this.teamBFull)) {
wx.showToast({ title: `${getTeamDisplayName(team)}已满,请选其他队伍`, icon: 'error', duration: 3000 });
return;
}
}
} catch (err) {
console.warn('[JoinTeamSelect] 校验队伍失败,继续尝试加入:', err);
}
this.visible = false;
this.destroy();
this.onSelect && this.onSelect(team);
}
_hitRect(x, y, b) {
return x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h;
}
render(ctx) {
if (!this.visible) return;
this._drawBg(ctx);
this._drawTitle(ctx);
this._drawRoomInfo(ctx);
if (this.loading) {
this._drawLoading(ctx);
} else {
this._drawTeamBtn(ctx, this._teamABtn, getTeamDisplayName('A'), '#8B5CF6', '#A78BFA', this.teamAFull, this.teamACount);
this._drawTeamBtn(ctx, this._teamBBtn, getTeamDisplayName('B'), '#EC4899', '#F472B6', this.teamBFull, this.teamBCount);
}
this._drawBackBtn(ctx);
}
_drawBg(ctx) {
const g = ctx.createLinearGradient(0, 0, 0, SCREEN_HEIGHT);
g.addColorStop(0, '#1a0f2e');
g.addColorStop(0.5, '#251240');
g.addColorStop(1, '#1a0f2e');
ctx.fillStyle = g;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// 星点装饰
ctx.fillStyle = 'rgba(139,92,246,0.08)';
for (let i = 0; i < 22; i++) {
ctx.beginPath();
ctx.arc((i * 73) % SCREEN_WIDTH, (i * 47 + 80) % SCREEN_HEIGHT, 2 + (i % 4), 0, Math.PI * 2);
ctx.fill();
}
}
_drawTitle(ctx) {
const cx = SCREEN_WIDTH / 2;
const titleY = SCREEN_HEIGHT * 0.18;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `bold ${UI_BASE * 0.035}px Arial`;
ctx.fillStyle = 'rgba(167,139,250,0.65)';
ctx.fillText('SELECT TEAM', cx, titleY - SCREEN_HEIGHT * 0.04);
const sz = UI_BASE * 0.09;
ctx.font = `bold ${sz}px Arial`;
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.fillText('选择队伍', cx + 2, titleY + 2);
const tg = ctx.createLinearGradient(cx - 80, titleY - 20, cx + 80, titleY + 20);
tg.addColorStop(0, '#C4B5FD');
tg.addColorStop(0.5, '#A78BFA');
tg.addColorStop(1, '#8B5CF6');
ctx.fillStyle = tg;
ctx.fillText('选择队伍', cx, titleY);
}
_drawRoomInfo(ctx) {
const cx = SCREEN_WIDTH / 2;
const y = SCREEN_HEIGHT * 0.28;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `${UI_BASE * 0.032}px Arial`;
ctx.fillStyle = 'rgba(200,180,255,0.6)';
ctx.fillText('加入房间', cx, y);
ctx.font = `bold ${UI_BASE * 0.12}px Arial`;
ctx.fillStyle = '#FCD34D';
ctx.shadowColor = 'rgba(251,191,36,0.4)';
ctx.shadowBlur = 12;
ctx.fillText(this.roomId, cx, y + SCREEN_HEIGHT * 0.05);
ctx.shadowBlur = 0;
}
_drawLoading(ctx) {
const cx = SCREEN_WIDTH / 2;
const cy = SCREEN_HEIGHT * 0.52;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `${UI_BASE * 0.038}px Arial`;
ctx.fillStyle = 'rgba(196,181,253,0.6)';
ctx.fillText('查询中...', cx, cy);
}
/**
* @param {boolean} isFull 是否满员(置灰)
* @param {number} count 当前队伍人数
*/
_drawTeamBtn(ctx, btn, label, colorStart, colorEnd, isFull, count) {
const { x, y, w, h, r } = btn;
const cx = x + w / 2;
ctx.save();
if (isFull) {
// 置灰:无阴影,灰色渐变
ctx.shadowBlur = 0;
const bg = ctx.createLinearGradient(x, y, x + w, y);
bg.addColorStop(0, '#4a4a5a');
bg.addColorStop(1, '#6a6a7a');
ctx.fillStyle = bg;
} else {
ctx.shadowColor = colorStart + '80';
ctx.shadowBlur = 16;
ctx.shadowOffsetY = 4;
const bg = ctx.createLinearGradient(x, y, x + w, y);
bg.addColorStop(0, colorStart);
bg.addColorStop(1, colorEnd);
ctx.fillStyle = bg;
}
this._rr(ctx, x, y, w, h, r);
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// 高光(满员时不显示高光)
if (!isFull) {
ctx.save();
this._rr(ctx, x, y, w, h, r);
ctx.clip();
const hl = ctx.createLinearGradient(x, y, x, y + h * 0.5);
hl.addColorStop(0, 'rgba(255,255,255,0.2)');
hl.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hl;
ctx.fillRect(x, y, w, h * 0.5);
ctx.restore();
}
// 队伍名称
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `bold ${UI_BASE * 0.055}px Arial`;
ctx.fillStyle = isFull ? 'rgba(255,255,255,0.35)' : '#FFFFFF';
// 满员时在队伍名后附加"人满"提示,否则显示当前人数/上限
if (isFull) {
ctx.fillText(`${label} 已满`, cx, y + h / 2);
} else {
const countText = this.perTeamSeats > 0 ? ` ${count}/${this.perTeamSeats}` : '';
ctx.fillText(`${label}${countText}`, cx, y + h / 2);
}
ctx.restore();
}
_drawBackBtn(ctx) {
const { x, y, w, h, r } = this._backBtn;
ctx.save();
// 按钮背景
ctx.fillStyle = 'rgba(60, 40, 100, 0.6)';
this._rr(ctx, x, y, w, h, r);
ctx.fill();
// 边框
ctx.strokeStyle = 'rgba(167, 139, 250, 0.5)';
ctx.lineWidth = 1.5;
this._rr(ctx, x, y, w, h, r);
ctx.stroke();
// 文字
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `bold ${UI_BASE * 0.035}px Arial`;
ctx.fillStyle = 'rgba(221, 214, 254, 0.9)';
ctx.fillText('< 返回', x + w / 2, y + h / 2);
ctx.restore();
}
_rr(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
}
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论