前言
这是一个完整的试玩游戏广告,最终会投放到 Moloco 广告平台进行推广,游戏不算复杂,但是也是第一次接触这个类目,记录一下 😀😀😀
如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章哪里有知识点错误的话,也恳请能够告知,把错的东西理解成对的,无论在什么行业,都是致命的。
项目概览
技术栈
- PixiJS 8.x: 2D WebGL 渲染引擎,用来画游戏画面
- GSAP 3.x: 动画库,让元素动起来更流畅,强烈推荐
- TypeScript: 带类型检查的 JavaScript
- Vite: 现代化的打包工具
- @pixi/sound: 管理音效和背景音乐
项目文件结构
plain
src/
├── main.ts # 程序入口
├── types.ts # 类型定义
├── components/ # 游戏组件
│ ├── Background.ts # 背景层
│ ├── Character.ts # 左侧角色
│ ├── TopBar.ts # 顶部栏(Logo/大奖/分数)
│ ├── SlotArea.ts # 转轮区域容器
│ ├── SlotMachine.ts # 转轮核心逻辑
│ ├── MaskOverlay.ts # 引导遮罩
│ ├── BottomArea.ts # 底部按钮
│ ├── GuideHand.ts # 引导手势
│ ├── CoinRain.ts # 金币雨特效
│ └── DownloadOverlay.ts # 下载弹窗
├── utils/
│ ├── responsive.ts # 响应式适配
│ ├── textures.ts # 纹理管理
│ ├── resources.ts # 资源加载
│ ├── audio.ts # 音频管理
│ ├── ad.ts # 广告跳转
│ └── time.ts # 时间工具
└── assets/ # 资源文件
核心系统实现
程序启动流程
typescript
// 创建 PixiJS 应用
const app = new Application();
// 初始化配置
await app.init({
backgroundColor: 0xffffff,
resizeTo: window, // 自动适配窗口大小
resolution: Math.max(window.devicePixelRatio, 2), // 支持高清屏
autoDensity: true,
});
// 初始化响应式系统(基于 375x667 设计稿)
initResponsive(app, 375, 667);
// 加载所有资源
await loadResources();
// 创建游戏场景
createGameScene();
响应式适配系统
类似 CSS 的 Rem 单位:
typescript
// 设计稿上的像素值转换成实际屏幕像素
export function rem(px: number): number {
const scale = getScale();
return px * scale;
}
// 计算缩放比例
export function getScale(): number {
const { width, height } = appInstance.screen;
// 竖屏: 按宽度缩放
if (height > width) {
return width / DESIGN_WIDTH; // DESIGN_WIDTH = 375
}
// 横屏: 按高度缩放
return height / DESIGN_HEIGHT; // DESIGN_HEIGHT = 667
}
使用方法:
typescript
// 设计稿上某个元素位置是 (100, 200)
sprite.x = rem(100);
sprite.y = rem(200);
// 设计稿上字体大小是 24
text.style.fontSize = rem(24);
资源管理
纹理图集
纹理就是把很多小图片加到一个大图片里面,包含了一个 json 文件记录小图片位置,我把所有小图片打包成三张大图,这样加载更快:
typescript
// 主资源图集 (assets.png)
// 包含 UI、背景、按钮等
await loadAssetsSheet();
// 图标图集 (assets_icons.png)
// 包含转轮上的各种图标
await loadAssetsIconsSheet();
// 宣传图集 (promo_custom.png)
// 包含最终落地页素材
await loadPromoCustomSheet();
// 使用时只需要传图片名称
const texture = getAssetsTexture("Character");
const sprite = new Sprite(texture);
图集的好处:
- 一次请求下载多张图
- 减少服务器压力
- GPU 渲染更高效
音频管理
音频也是一样,用@pixi/sound 进行播放,使用单例模式管理所有音效:
typescript
class AudioManager {
public async init() {
// 加载所有音效
await sound.add("spinButton", spinButtonUrl);
await sound.add("reelClick", reelClickUrl);
await sound.add("backgroundMusic", backgroundMusicUrl);
}
public playSpinButton() {
sound.play("spinButton");
}
public playBackgroundMusic() {
sound.play("backgroundMusic", {
loop: true, // 循环播放
volume: 0.5, // 音量 50%
});
}
}
组件设计模式
所有需要适配屏幕的组件都得实现 Resizable 这个接口:
typescript
export interface Resizable {
resize(...args: unknown[]): void;
}
组件的生命周期:
- 构造: 创建精灵、设置初始位置
- Resize: 窗口变化时重新计算布局
- 交互: 处理用户点击、拖动等
- 销毁: 清理资源防止内存泄漏
游戏流程设计
场景层级
页面中各个区块层级不一样,使用 zIndex 管理不同层级,数字越大越靠前:
plain
Stage
├── Background (zIndex: 0) - 背景最底层
├── MaskOverlay (zIndex: 1) - 引导遮罩
├── Character (zIndex: 2) - 角色
├── BottomArea (zIndex: 3) - 按钮
├── SlotArea (zIndex: 4) - 转轮
├── TopBar (zIndex: 5) - 顶部UI
└── DownloadOverlay (zIndex: 100) - 弹窗最上层
游戏流程图
plain
用户进入
↓
[初始场景]
- 显示角色和引导
- Play 按钮 + 手势动画
- 播放背景音乐
↓
用户点击 Play
↓
[淡出动画]
- 角色淡出 (0.8秒)
- 遮罩淡出
- 按钮淡出
↓
[转轮旋转]
- 播放旋转音效
- 3个转轮依次启动
- 每个转 2 秒
- 依次停止
↓
[判断结果]
- 检查是否三个图标一样
- 分数增长动画
↓
[金币雨]
- 开始掉金币
- 金币下落+旋转+翻转
- 播放中奖音效
↓
[3秒后]
↓
[显示下载弹窗]
- 弹窗淡入
- 金币雨逐渐停止
核心功能实现
游戏转轮
状态机管理
我用状态机来管理转轮状态,避免出现点两次按钮之类的 bug:
typescript
enum SlotMachineState {
IDLE = "IDLE", // 空闲,可以点击
SPINNING = "SPINNING", // 正在转
STOPPING = "STOPPING", // 正在停止
STOPPED = "STOPPED", // 已停止
}
class SlotMachineStateMachine {
canSpin(): boolean {
// 只有空闲和已停止状态才能再次旋转
return [SlotMachineState.IDLE, SlotMachineState.STOPPED].includes(
this.currentState
);
}
}
转轮动画
typescript
class Reel {
async spin(
delay: number, // 延迟启动
targetIcon: number, // 目标图标
speed: number, // 速度
duration: number // 持续时间
): Promise<void> {
// 1. 延迟启动(让3个转轮错开)
await sleep(delay * 1000);
// 2. 加速阶段
gsap.to(this, {
y: `+=${speed * 3}`,
duration: 0.3,
ease: "power2.in",
});
// 3. 匀速旋转
gsap.to(this, {
y: `+=${speed * duration}`,
duration: duration,
ease: "none",
});
// 4. 减速停在目标图标
await this.stopAtIcon(targetIcon);
}
}
预设结果
因为是试玩游戏,为了控制体验,直接预设几个游戏结果,随机抽取:
typescript
const PRESET_RESULTS = [
[0, 0, 0], // 三个相同 - 中奖
[1, 1, 1],
[2, 2, 2],
// ...
[0, 1, 2], // 不同 - 不中奖
];
// 随机选一个
const resultIndex = Math.floor(Math.random() * PRESET_RESULTS.length);
const resultData = PRESET_RESULTS[resultIndex];
金币雨特效
游戏结束时金币下落效果
金币的属性
typescript
interface CoinSprite extends Sprite {
speedY: number; // 下落速度
speedX: number; // 水平漂移
speedR: number; // 旋转速度
flipSpeed: number; // 翻转速度(实现3D效果)
flipPhase: number; // 翻转相位
initialScale: number; // 初始大小
}
创建金币
typescript
private createCoin(randomY: boolean = false) {
const coin = new Sprite(this.texture) as CoinSprite;
// 随机位置
coin.x = Math.random() * this.app.screen.width;
coin.y = randomY
? (Math.random() * this.app.screen.height) / 2 - rem(200)
: rem(-50); // 从顶部开始
// 随机大小 (0.3 - 0.6)
const scale = (0.3 + Math.random() * 0.3) * getScale();
coin.scale.set(scale);
coin.initialScale = scale;
// 随机物理属性
coin.speedY = this.speedMin + Math.random() * (this.speedMax - this.speedMin);
coin.speedX = (Math.random() - 0.5) * 1; // 左右漂移
coin.speedR = (Math.random() - 0.5) * 0.1; // 旋转
coin.flipSpeed = 0.02 + Math.random() * 0.05; // 翻转快慢
coin.flipPhase = Math.random() * Math.PI * 2; // 初始相位
this.coins.push(coin);
}
动画更新
这是整个特效的核心,每帧都会执行:
typescript
private update = (ticker: Ticker) => {
// 1. 生成新金币(如果没有停止)
if (!this.isStopping && this.coins.length < 300) {
if (Math.random() < this.density) { // density = 0.2
this.createCoin();
}
}
// 2. 更新每个金币
for (let i = this.coins.length - 1; i >= 0; i--) {
const coin = this.coins[i];
// 位置更新
coin.y += coin.speedY * ticker.deltaTime;
coin.x += coin.speedX * ticker.deltaTime;
coin.rotation += coin.speedR * ticker.deltaTime;
// 3D翻转效果(通过缩放 x 轴模拟)
coin.flipPhase += coin.flipSpeed * ticker.deltaTime;
coin.scale.x = coin.initialScale * Math.sin(coin.flipPhase);
// 边界检查
if (coin.y > this.app.screen.height + rem(50)) {
if (this.isStopping) {
// 停止阶段: 销毁掉出去的金币
coin.destroy();
this.coins.splice(i, 1);
} else {
// 正常阶段: 循环利用,从顶部重新掉下来
coin.y = rem(-50);
coin.x = Math.random() * this.app.screen.width;
}
}
}
// 3. 如果停止且没有金币了,清理资源
if (this.isStopping && this.coins.length === 0) {
this.cleanup();
}
};
性能优化技巧
- 对象池思想: 金币掉出屏幕后不销毁,而是重置位置继续用
- 数量限制: 最多 300 个金币,避免内存爆炸
- 软停止 : 调用
stop()后不立即清理,让现有金币自然落完 - 禁用交互 :
coin.eventMode = "none"避免不必要的事件检测
引导手势动画
使用 GSAP Timeline 做循环动画:
typescript
private startAnimation() {
this.timeline = gsap.timeline({ repeat: -1 }); // 无限循环
this.timeline
// 手指下压 + 旋转
.to(this.mcPointer, {
y: rem(10),
rotation: -0.1,
duration: 0.3,
ease: "power2.out"
})
// 同时播放水波纹扩散
.to(this.mcRing, {
scale: 1.5,
alpha: 0, // 淡出
duration: 0.6,
ease: "power2.out"
}, "<") // "<" 表示和上一个动画同时开始
// 手指复位
.to(this.mcPointer, {
y: 0,
rotation: 0,
duration: 0.3,
ease: "power2.in"
})
// 暂停 0.5 秒
.to({}, { duration: 0.5 });
}
Moloco集成
这个项目最终要在 Moloco 广告平台上运行,按他的需求给下载按钮添加以下代码:
typescript
export function handleCTA() {
// 检查广告平台注入的全局对象
if (typeof FbPlayableAd !== "undefined" && FbPlayableAd.onCTAClick) {
FbPlayableAd.onCTAClick(); // 调用平台的跳转方法
} else {
console.log("【本地测试】模拟跳转");
}
}
打包配置
Moloco要求产物是一个单独的HTML文件,要改一下 Vite 的打包配置:
typescript
// vite.config.ts
export default defineConfig({
plugins: [viteSingleFile()],
build: {
assetsInlineLimit: 100000000, // 所有资源内联
cssCodeSplit: false, // CSS 不分割
minify: "terser", // 压缩代码
},
});
运行 npm run build 后得到一个 HTML 文件, HTML 文件包含所有代码、图片、音频
总结
尽管游戏比较简单,但整个开发过程让我对游戏开发有了初步的了解。这个项目没有涉及到碰撞检测、路由、骨骼动画以及资产包管理(AssetPack)等技术,未来有机会再去学习强化。