1. 游戏选关系统的设计初衷
在开发休闲游戏时,我们常常会遇到这样的需求:游戏核心玩法相同,但需要提供多个不同的地图场景来丰富玩家的体验。传统做法是让玩家按顺序解锁关卡,或者提供一个静态的关卡选择界面。但这种方式缺乏趣味性,尤其对于休闲类游戏而言。
我最近在开发一款消除类游戏时,就遇到了这个问题。游戏有3个不同主题的地图(森林、沙漠、海洋),但玩法完全一致。为了让关卡选择过程更有趣,我决定设计一个类似Bingo抽奖的随机选关系统。玩家点击开始后,三个地图图标会快速滚动,最终随机停在一个位置上,选中的地图会略微抬高,给玩家一种"中奖"的愉悦感。
这种设计有几个明显优势:
- 增加了游戏的趣味性和随机性
- 避免了静态选择界面的单调感
- 可以很自然地扩展到更多关卡
- 给玩家一种"命运选择"的新鲜体验
2. 场景搭建与层级结构
2.1 基础场景创建
首先在LayaAir IDE中创建一个新场景,我将其命名为selectView。场景的基本结构如下:
code复制selectView (Scene)
├── bg (Sprite) - 半透明黑色背景,用于遮挡下层内容
└── di (Sprite) - 主要内容容器
├── levelBox (Box) - 关卡选择容器
│ ├── levelList (Box) - 关卡列表容器
│ │ ├── level0 (Image) - 第一个关卡图标
│ │ ├── level1 (Image) - 第二个关卡图标
│ │ └── level2 (Image) - 第三个关卡图标
│ └── select (Image) - 选中状态指示器
└── startBtn (Button) - 开始选择按钮
提示:使用Box作为容器而不是Sprite,因为Box更适合自动布局和子元素管理。levelList使用Box可以方便地通过numChildren获取子元素数量。
2.2 关键位置参数设置
为了实现滚动后随机停止的效果,我们需要精确控制各个元素的位置:
- levelList的三个子元素初始y坐标均为-130
- 选中状态时,目标关卡应该停在y=46的位置
- 未选中的两个关卡分别停在y=-62和y=154
- 滚动范围控制在-130到194之间,超出这个范围会重置位置
这种不对称的布局设计是为了让选中关卡明显突出,同时另外两个关卡保持在视野内但不会喧宾夺主。
3. 核心逻辑实现
3.1 基础滚动动画
首先实现关卡图标的连续滚动效果:
typescript复制let num = 0;
let time = 3.9; // 初始速度
const rang = setInterval(() => {
if (num == 500 || time <= 0) {
clearInterval(rang);
// 停止后的处理逻辑
return;
}
// 更新所有关卡位置
for (let i = 0; i < this.levelList.numChildren; i++) {
const level = this.levelList.getChildAt(i);
level.y += time;
// 位置重置
if (level.y > 194) {
level.y = -130;
}
}
num++;
time -= 0.003; // 速度递减
}, 1);
这段代码实现了:
- 使用setInterval创建16ms左右一次的动画循环(约60FPS)
- 每次循环所有关卡图标向下移动
- 移动速度随时间递减,模拟减速效果
- 当图标移出底部边界时,从顶部重新进入
3.2 随机停止与位置校准
当动画停止时(循环500次或速度降到0),我们需要随机选择一个关卡作为结果:
typescript复制// 随机选择目标索引(0/1/2)
const randomTargetIndex = Math.floor(Math.random() * 3);
// 定义三种可能的位置组合
let targetPosList = [];
if (randomTargetIndex === 0) {
targetPosList = [46, -62, 154]; // 节点0在46
} else if (randomTargetIndex === 1) {
targetPosList = [-62, 46, 154]; // 节点1在46
} else {
targetPosList = [-62, 154, 46]; // 节点2在46
}
// 应用最终位置
for (let i = 0; i < this.levelList.numChildren; i++) {
const level = this.levelList.getChildAt(i);
level.y = targetPosList[i];
if (i === randomTargetIndex) {
console.log('选中关卡:', i);
selectIndex = i;
}
}
这个算法的精妙之处在于:
- 预先定义了三种可能的位置组合
- 根据随机结果选择对应的组合
- 确保选中关卡总是停在突出的中间位置
- 其他两个关卡停在对称但不同的位置
3.3 性能优化技巧
在实际测试中,我发现几个可以优化的点:
- 减少DOM操作:将频繁操作的节点缓存起来,避免每次循环都调用getChildAt
- 使用时间戳控制:改用requestAnimationFrame可以获得更平滑的动画
- 对象池管理:如果场景会被频繁打开关闭,应该使用对象池管理节点
优化后的核心循环:
typescript复制// 预先缓存节点
const levels = [];
for (let i = 0; i < this.levelList.numChildren; i++) {
levels.push(this.levelList.getChildAt(i));
}
let lastTime = 0;
const animate = (timestamp) => {
if (!lastTime) lastTime = timestamp;
const delta = timestamp - lastTime;
if (delta >= 16) { // 约60FPS
lastTime = timestamp;
// 更新逻辑...
levels.forEach(level => {
level.y += time;
if (level.y > 194) level.y = -130;
});
time -= 0.003;
num++;
}
if (num < 500 && time > 0) {
requestAnimationFrame(animate);
} else {
// 停止处理...
}
};
requestAnimationFrame(animate);
4. 场景复用方案
4.1 导出场景为Prefab
为了在其他项目中复用这个选关系统,我们可以将场景导出为Prefab:
- 在LayaAir IDE中,右键场景选择"导出"
- 生成selectView.json文件
- 同时导出使用的图片资源
4.2 动态加载Prefab
在其他项目中,可以通过以下代码动态加载和使用这个选关场景:
typescript复制Laya.loader.load("selectView.json", Laya.Handler.create(this, (res) => {
// 创建Prefab实例
const selectView = new Laya.Prefab();
selectView.json = res;
// 从对象池获取或创建新实例
const scene = Laya.Pool.getItemByCreateFun(
"selectView",
selectView.create,
selectView
);
Laya.stage.addChild(scene);
// 防止点击穿透
scene.getChildByName('bg').on(Laya.Event.CLICK, this, () => {});
// 获取关键节点引用
const di = scene.getChildByName('di');
const levelBox = di.getChildByName('levelBox');
const levelList = levelBox.getChildByName('levelList');
// 这里可以添加你自己的控制逻辑...
}));
4.3 复用时的注意事项
- 资源路径:确保json文件和关联图片资源在正确路径下
- 尺寸适配:不同项目的舞台尺寸可能不同,需要适当调整场景元素位置
- 事件监听:记得移除事件监听防止内存泄漏
- 对象池管理:使用完后应该将场景放回对象池
5. 常见问题与解决方案
5.1 动画卡顿问题
现象:滚动动画不流畅,有卡顿感
可能原因:
- 设备性能不足
- 同时运行的动画太多
- 代码逻辑过于复杂
解决方案:
- 降低动画帧率到30FPS
- 使用更轻量级的图片资源
- 采用上面提到的性能优化技巧
5.2 随机性不够自然
现象:玩家能感觉到选择结果有规律
解决方案:
- 增加随机种子变化
- 引入更复杂的随机算法
- 让动画持续时间也有一定随机性
改进后的随机逻辑:
typescript复制// 使用时间戳作为随机种子
const seed = Date.now();
const randomTargetIndex = Math.floor((Math.sin(seed) + 1) * 1.5) % 3;
// 随机动画持续时间
const maxLoop = 400 + Math.floor(Math.random() * 200);
5.3 跨项目复用时的样式问题
现象:在其他项目中使用时,样式与预期不符
解决方案:
- 使用相对位置而非绝对位置
- 将样式相关参数提取为变量
- 提供样式配置接口
例如,可以修改为:
typescript复制interface ISelectViewStyle {
selectY: number; // 选中位置的y坐标
otherY: [number, number]; // 其他两个位置的y坐标
minY: number; // 最小y值
maxY: number; // 最大y值
}
const defaultStyle: ISelectViewStyle = {
selectY: 46,
otherY: [-62, 154],
minY: -130,
maxY: 194
};
6. 扩展与进阶玩法
6.1 多关卡支持
当前实现是针对3个关卡,如果要支持更多关卡:
- 修改随机算法生成更大范围的索引
- 调整位置计算逻辑
- 可能需要修改UI布局
例如,对于5个关卡的情况:
typescript复制// 5个关卡的位置计算
const positions = [];
const selectedIndex = Math.floor(Math.random() * 5);
const centerY = 100;
const spacing = 80;
for (let i = 0; i < 5; i++) {
if (i === selectedIndex) {
positions.push(centerY);
} else {
positions.push(centerY + (i - selectedIndex) * spacing);
}
}
6.2 添加特殊效果
为了让选关过程更炫酷,可以添加:
- 粒子特效
- 音效反馈
- 震动效果
- 光影变化
例如添加选中时的闪光效果:
typescript复制// 创建闪光效果
const light = new Laya.Image("light.png");
light.centerX = 0;
light.centerY = 0;
light.alpha = 0;
selectedLevel.addChild(light);
// 动画效果
Laya.Tween.to(light, {
alpha: 1,
scaleX: 1.5,
scaleY: 1.5
}, 300, null, Laya.Handler.create(this, () => {
Laya.Tween.to(light, {alpha: 0}, 500);
}));
6.3 数据驱动配置
将关卡信息配置为JSON,实现完全数据驱动:
json复制{
"levels": [
{
"name": "森林",
"icon": "level1.png",
"scene": "forest.json"
},
{
"name": "沙漠",
"icon": "level2.png",
"scene": "desert.json"
},
{
"name": "海洋",
"icon": "level3.png",
"scene": "ocean.json"
}
]
}
然后在代码中动态创建关卡选择项:
typescript复制config.levels.forEach((level, index) => {
const levelItem = new Laya.Image(level.icon);
levelItem.name = `level_${index}`;
levelItem.y = -130;
this.levelList.addChild(levelItem);
});
这种设计让关卡配置完全与代码分离,非常适合需要频繁更新关卡的游戏。