游戏的开发区别于传统前端,本文记录了作者慢慢摸索该互动游戏项目过程中遇到的问题和解决的思路。
近期PD找到我,说我们的特价版小鸡送好礼要进行大改版,要让小鸡在地图上自由的走动起来,期间会遇到各种随机事件、玩法,从而提高趣味性和业务指标。交互图如下:
-
小鸡如何行走,是否需要前、后、左、右、左上、左下、右上、右下八个动画;
-
-
交互稿子上有景深的效果、有道路,小鸡如何做到在道路上走、和远景房子等融合。
![]()
行走
和设计一起看了一些人物扮演游戏,自己也想了一下DNF、三国这些2D游戏,没我们想得那么复杂需要八个方向不同的动画,不区分奔跑的话就两种动画,左下和右下就能满足大部分场景。
提供小鸡形象的spine骨骼动画是什么,包含行走(左、右)、奔跑(左、右)。
通过补间动画,将小鸡从当前位置移动到目标位置,配合spine动画一起循环播放。
▐ 方案一:基于射线投射的寻路算法
![]()
这是我一开始想到的思路,但存在不少问题。
具体的路径计算步骤为:
-
-
如果射线直达目标点而不与任何障碍物相交,那么路径就是直线,寻路结束。
-
如果射线与障碍物交叉了,确定射线与哪个障碍物边界相交,并且找到这个边界的两个顶点。
-
-
将每个顶点视为新的起点,从这些顶点发射新的射线到目标点。
-
重复步骤2至5,直至找到不被障碍物阻断的直线路径,或者确定所有可能路径都检查完毕。
-
图上所示全路径:
-
A-B-E-F-G-目标点
-
A-B-E-H-目标点
-
A-B-H-目标点
-
A-B-C-目标点
-
A-D-目标点(最优解)
-
该方案存在的问题:
▐ 方案二:基于A*的寻路算法
A*算法是一种栅格化地图上常用的高效寻路算法,利用估算的成本函数来遍历节点,从而找到一条从起点到终点的最短路径,这也是大部分游戏在使用的路径计算方法。
采用某乎上的一些分析例子和思路,A*搜索算法(A-Star Search)简介。
介绍一下概念,每个栅格即为一个Node节点,每个节点都有自己的三个属性值
-
-
h为当前格子到终点的估计成本,使用的曼哈顿距离,即为 |x1 - x2| + |y1 - y2|;
-
-
-
以一个简单的3*3的栅格介绍一下整体的A*算法的流程,初始点为(0, 0),目标点为(2, 2),格子的长宽皆为1、斜角的长度为1.4。
1. 从openList中取出(0, 0)节点,将该节点加入已选中的点closeList;
2. 找出(0, 0)坐标附近的八个节点,只有(1, 0)、(1, 1)、(0, 1)满足条件;
3. 遍历附近节点,计算f、g、h,并将此三节点加入到openList中;
4. 从openList取出F值最小的点(1, 1),循环1-3步;
5. 不同的是(0, 0)在closeList中,需要过滤掉;
6. 当遇到已经在openList中如(1, 0)、(0, 1),对比g值,大的直接忽略,按少的成本计算;
7. 循环4-6步,直到h为0时,即找到最短路径。
![]()
写了一个简单的以Javascript实现的版本:
class Node { constructor(parent = null, position = null) { this.parent = parent; this.position = position; this.g = 0; this.h = 0; this.f = 0; }
isEqual(otherNode) { return this.position[0] === otherNode.position[0] && this.position[1] === otherNode.position[1]; }}
function heuristic(nodeA, nodeB) { const d1 = Math.abs(nodeB.position[0] - nodeA.position[0]); const d2 = Math.abs(nodeB.position[1] - nodeA.position[1]); return d1 + d2;}
function getNeighbors(currentNode, grid) { const neighbors = []; const directions = [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1], ];
for (const direction of directions) { const neighborPos = [ currentNode.position[0] + direction[0], currentNode.position[1] + direction[1], ];
if ( neighborPos[0] >= 0 && neighborPos[0] < grid.length && neighborPos[1] >= 0 && neighborPos[1] < grid[0].length && grid[neighborPos[0]][neighborPos[1]] === 1 ) { neighbors.push(new Node(currentNode, neighborPos)); } }
return neighbors;}
function aStar(grid, start, end) { const startNode = new Node(null, start); const endNode = new Node(null, end);
let openSet = [startNode]; let closedSet = [];
while (openSet.length > 0) { console.log('startNode'); let lowestIndex = 0; for (let i = 0; i < openSet.length; i++) { if (openSet[i].f < openSet[lowestIndex].f) { lowestIndex = i; } }
let currentNode = openSet[lowestIndex];
if (currentNode.isEqual(endNode)) { let path = []; let current = currentNode; while (current != null) { path.push(current.position); current = current.parent; } return path.reverse(); }
openSet.splice(lowestIndex, 1); closedSet.push(currentNode);
let neighbors = getNeighbors(currentNode, grid); for (let neighbor of neighbors) {
if (closedSet.some(closedNode => closedNode.isEqual(neighbor))) { continue; }
const isDiagonalMove = Math.abs(currentNode.position[0] - neighbor.position[0]) === 1 && Math.abs(currentNode.position[1] - neighbor.position[1]) === 1;
const tentativeG = currentNode.g + (isDiagonalMove ? Math.sqrt(2) : 1);
let openNode = openSet.find(openNode => openNode.isEqual(neighbor)); if (!openNode || tentativeG < neighbor.g) { neighbor.g = tentativeG; neighbor.h = heuristic(neighbor, endNode); neighbor.f = neighbor.g + neighbor.h; neighbor.parent = currentNode;
if (!openNode) { openSet.push(neighbor); } } } } return []; }
对比上述射线检测的寻路算饭,A*寻路算法的优势比较明显:
▐ 简易地图编辑器
相比于方案一,基于A*的寻路逻辑,需要提前将地图进行栅格化(网格),栅格化是把连续的信息分解成离散的单元格(像素、方格、体素),便于计算处理和分析,我们需要将整张地图进行栅格化,这里是和实际渲染在手机上的地图进行1:1还原,每个单元格以50*50为宽和高,为什么为50,是一个经验值,值越小,鸡走动的约真实,路径就越细致,50已经能满足当前场景。
![]()
区块指的是地图上的事件的承载体,包含渲染坐标、障碍物坐标、落地点、事件code(服务端关联玩法)等信息。
同时支持运营开发同学自己勾选、编辑、移动障碍物等位置,图中勾选出来的障碍物即为1,不可通行。
如何做到在地图道路上走、和远景房子融合?
这块经过与设计、pd对接,在2d场景下很难实现,我们基于栅格的A*寻路能实现,但是会很不真实,如果要做到该效果,只能做3d的场景,下面是我画的路线图。
![]()
很明显该路线是曲折的,需要小鸡的各角度的侧身,也就是需要x、y、z轴,才能模拟真实的效果,也就是只有3D场景才能满足诉求,与设计同学讨论了一下决定用更适合2D的场景设计,如下图:
![]()
我们只需要将障碍物(场景、区块)设置好、在y轴上对小鸡做一定的scale缩放,来做透视扭曲(模拟现实中物体随着距离远近而变化的视觉效果)的效果,就能模拟一定的2.5d效果。
整体将背景结合寻路算法给设计和业务同学看了一下,对于效果还没达到预期,比较僵硬,需要做一些层次效果,来模拟真实走动的效果。
▐ 视差滚动
通过获取相机在x、y轴的滚动距离,与远、中、近景以不同的速率相乘,近景即为草丛区移动得更快,中景即为行动草地区按正常滚动距离移动,远景也就是云层和天空移动得更慢,来模拟摄像机的移动和景深的效果。
核心代码:
export const parallaxFactorFarX = 0.2;export const parallaxFactorFarY = 0.2;export const farOriginXY = [0, -60];
export const parallaxFactorNearX = 1.8;export const parallaxFactorNearY = 1.05;export const nearOriginXY = [0, gameHeightBounds - 420];
const { scrollX, scrollY } = this.scene.cameras.main;if (scrollX >= 0 && scrollX <= 750 && scrollY >= 0 && this.bgFar && this.bgNear) { this.bgFar.x = -scrollX * parallaxFactorFarX; this.bgFar.y = -scrollY * parallaxFactorFarY + farOriginXY[1]; this.bgNear.x = -scrollX * parallaxFactorNearX; this.bgNear.y = -scrollY * parallaxFactorNearY + nearOriginXY[1];}
![]()
▐ 深度排序
通过调整游戏对象的 depth 值,可以确保某些对象看上去像是位于其他对象的前面或背后,从视觉上产生立体感、模拟真实世界。具体方案为:
-
我们会将当前存在地图上的游戏对象构建虚拟边框,常用矩形去表达,大部分spine对象的形状都是不固定的,面积也会很大,我们统一用矩形去描述游戏对象的轮廓 ;
-
取每个虚拟边框的bottomY,也就是底边y轴的坐标,按从大到小排序;
-
分不同的区间,区间左闭为上一个bottomY,右闭为当前bottomY,生成该区间内可以设置的深度;
-
当小鸡行走时,读取y轴坐标,判断在哪一个区间,为小鸡设置该区间可以设置的深度。
核心代码如下:
const divideRegional = (blocks: Array<{ id: number, bottomY: number }>) => { blocks.sort((a, b) => a.bottomY - b.bottomY); return blocks.map((item, idx) => { const nextBottomY = blocks[idx + 1] ? blocks[idx + 1].bottomY : Infinity; return { regional: [(idx + 1) * 100, (idx + 1) * 100 + 100], range: [item.bottomY, nextBottomY], ...item } })}
const currentY = chicken.getPosition().y + 130;const currentRegion = regionals.find((rengional) => { const [start, end] = rengional.range; return currentY >= start && currentY <= end;})if (currentRegion) { chicken.setDepth(currentRegion.regional[0] + 1);} else { chicken.setDepth(99);}
效果图:可以看到我们通过深度排序和寻路算法很好的处理了小鸡与建筑物的关系。
![]()
▐ 阴影效果
因为我们以2d为主,光照、材质、阴影这块主要是以设计为主:
|
小鸡 |
神秘屋 |
双色球 |
彩蛋 |
|
![]() |
|
|
![]() |
![]()
全链路指引
主动提的需求,担心用户不知道如何玩儿起来,得提供一个全链路的引导、指引你去熟悉新的玩法,一句话诉求为:用户长时间不行走、没有触发玩法、不浏览地图就触发指引,指引也有一定的优先级。如何划分优先级,这是业务属性,这里就不提了。
第一直觉代码如何编写?
-
在行走结束后启动定时器,在拖拽地图时、触发玩法后清除定时器;
-
在拖拽地图结束后启动定时器,在走动、触发玩法后清除定时器;
-
在触发玩法后启动定时器,在走动、拖拽地图后清除定时器。
多一个链路,这种像狗皮膏药一样的代码就会越来越多,很不优雅,且容易遗漏。
使用了组内小伙伴做的小而美的多流程定时器的能力,大致的思路如下:
![]()
核心思想就是,定时器统一管理,在过程中可以打断和重新计时,只有全部状态ok了,才能执行。
核心代码如下:
function ProcessTimer() { let id = 0; let hasEmit = false; const timers = {}; const flags = {}; const types: any = {}; let func: any;
const run = (cb) => { func = cb; };
const startTimer = (type, delayTime) => { if (hasEmit || timers[type]) return; timers[type] = setTimeout(() => { flags[type] = true; checkTimer(); }, delayTime); };
const checkTimer = () => { const keys = Object.keys(timers) || []; const notSatisfied = keys.find((key) => !flags[key]);
if (!notSatisfied && !hasEmit) { hasEmit = true; clearAllTimer();
if (func && typeof func === 'function') { func(); } } };
const clearAllTimer = () => { const keys = Object.keys(timers) || []; keys.forEach((key) => { clearTimer(key); }); };
const clearTimer = (type) => { flags[type] = false;
if (timers[type]) { clearTimeout(timers[type]); timers[type] = null; } };
const create = (delayTime = 8000) => { const type = `timer${id++}`; timers[type] = null; flags[type] = false; types[type] = delayTime;
return { start: () => { startTimer(type, delayTime); }, end: () => { clearTimer(type); }, type, }; };
const reset = () => { hasEmit = false; }
return { create, run, clearAllTimer, reset, };}
export default ProcessTimer;
去年写过一篇关于前端业务代码分层的文章《小鸡PK业务架构治理记录》,主要是针对于rax这个视图引擎的,本文的区别在于属于混合开发的模式,Phaser游戏开发的内容占比甚至比传统的前端rax/react开发更多。对于我们的分层模式来说其实没什么区别,只不过多了一种渲染方式而已,用phaser渲染和用react还是rax渲染其实都没什么区别。
架构图:
![]()
Phaser游戏对象设计:
![]()
这样设计的好处是游戏对象可以在controller逻辑层任意调用,细看api,游戏对象只负责渲染,不包含任何业务逻辑。
![]()
总结
记录一下自己在做这个项目过程中遇到的问题和解决的思路,游戏的开发区别于传统前端,上述一些方案也自己慢慢摸索出来的,有更好的方案也可以一起讨论。
![]()
参考资料