互动游戏之我养的鸡要旅行

简介: 游戏的开发区别于传统前端,本文记录了作者慢慢摸索该项目过程中遇到的问题和解决的思路。


背景

3月中旬pd找到我,说我们的特价版小鸡送好礼要进行大改版,要让小鸡在地图上自由的走动起来,期间会遇到各种随机事件、玩法,从而提高趣味性和业务指标。交互图如下: image.png 作为一个半吊子游戏开发选手首先想到的就是

  • 小鸡如何行走,是否需要前、后、左、右、左上、左下、右上、右下八个动画;
  • 遇到建筑物、河流如何处理;
  • 交互稿子上有景深的效果、有道路,小鸡如何做到在道路上走、和远景房子等融合;

行走

和设计一起看了一些人物扮演游戏,自己也想了一下DNF、三国这些2d游戏,没我们想得那么复杂需要八个方向不同的动画,不区分奔跑的话就两种动画,左下和右下就能满足大部分场景。

设计师

提供小鸡形象的spine骨骼动画Spine动画是什么[1],包含行走(左、右)、奔跑(左、右)。


开发同学

通过补间动画,将小鸡从当前位置移动到目标位置,配合spine动画一起循环播放。


行走的效果

行走 奔跑




路径计算


方案一:基于射线投射的寻路算法

image.png

这是我一开始想到的思路,但存在不少问题。

具体的路径计算步骤为:

  • 从起点发射一条射线到目标点。
  • 如果射线直达目标点而不与任何障碍物相交,那么路径就是直线,寻路结束。
  • 如果射线与障碍物交叉了,确定射线与哪个障碍物边界相交,并且找到这个边界的两个顶点。
  • 对每一个顶点,计算从起点到顶点的直线距离。
  • 将每个顶点视为新的起点,从这些顶点发射新的射线到目标点。
  • 重复步骤2至5,直至找到不被障碍物阻断的直线路径,或者确定所有可能路径都检查完毕。
  • 计算所有可能路径的长度,并选取最短路径。

图上所示全路径:

  • A-B-E-F-G-目标点
  • A-B-E-H-目标点
  • A-B-H-目标点
  • A-B-C-目标点
  • A-D-目标点(最优解)
  • A-D-C-目标点

该方案存在的问题:

  • 计算成本高,需要大量射线投射和碰撞检测计算,每次遇到障碍物都需要寻找交叉点并再次发射新的射线。在大型地图或高度动态的环境中,这可能会导致性能问题;
  • 路径并非最优,算法可能找到一个避开障碍物的路径,但不一定是最短或最高效的路径。根据障碍物的布局和顶点的选择,最终的路径可能远远绕过障碍物,而不是采取更合理或直接的路线;
  • 不适合所有类型环境,试用于简单的矩形障碍物场景,遇到复杂的障碍物图形,如迷宫、凹凸类型的多边形,可能难以找到一个有效的通路。


方案二:基于A*的寻路算法

A*算法是一种栅格化地图上常用的高效寻路算法,利用估算的成本函数来遍历节点,从而找到一条从起点到终点的最短路径,这也是大部分游戏在使用的路径计算方法。

采用某乎上的一些分析例子和思路,A*搜索算法(A-Star Search)简介[2]。

介绍一下概念,每个栅格即为一个Node节点,每个节点都有自己的三个属性值

  • g为从起点走到当前格子的成本;
  • h为当前格子到终点的估计成本,使用的曼哈顿距离,即为 |x1 - x2| + |y1 - y2|;
  • f为G值和H值的总和;
  • openList为待计算的点;
  • closeList为已选中的点;

以一个简单的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时,即找到最短路径;

image.png

写了一个简单的以Javascript实现的版本

// A*算法 Node 类,用于存储节点信息
class Node {
  constructor(parent = null, position = null) {
    this.parent = parent;    // 父节点
    this.position = position; // 节点在网格中的坐标位置
    this.g = 0;              // G值是从起点走到当前格子的成本
    this.h = 0;              // H值是当前格子到终点的估计成本
    this.f = 0;              // F值是G值和H值的总和
  }

  // 判断两个节点是否位于同一个位置
  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;
}

// A* 算法主函数
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');
    // 在openSet中找到F值最低的节点
    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,并加入closedSet
    openSet.splice(lowestIndex, 1);
    closedSet.push(currentNode);

    // 找到所有邻居
    let neighbors = getNeighbors(currentNode, grid);
    for (let neighbor of neighbors) {
      // 如果邻居是不可访问的或已在closedSet中,忽略它们

      // 如果这个邻居在关闭列表中,跳过它
      if (closedSet.some(closedNode => closedNode.isEqual(neighbor))) {
        continue;
      }

        // 对角线移动的成本要考虑 √2
        // 通过查看相邻节点和当前节点的坐标差来判断是否为对角移动
          const isDiagonalMove =
            Math.abs(currentNode.position[0] - neighbor.position[0]) === 1 &&
            Math.abs(currentNode.position[1] - neighbor.position[1]) === 1;

          // 对角线移动的成本假定为 √2,其他为1
          const tentativeG = currentNode.g + (isDiagonalMove ? Math.sqrt(2) : 1);

          // 如果新的G值更低,或者邻居节点不在开放列表中
          let openNode = openSet.find(openNode => openNode.isEqual(neighbor));
          if (!openNode || tentativeG < neighbor.g) {
            neighbor.g = tentativeG;
            neighbor.h = heuristic(neighbor, endNode);  // H值不变,因为它是启发式估计到终点的成本
            neighbor.f = neighbor.g + neighbor.h;
            neighbor.parent = currentNode;

            // 如果邻居节点不在开放列表中,加入开放列表
            if (!openNode) {
              openSet.push(neighbor);
            }
          }
        }
      }
      // 如果循环结束还没有到达终点,表示没有路径到达终点
      return [];
    }

对比上述射线检测的寻路算饭,A*寻路算法的优势比较明显:

  • 最优路径保证:A*算法在合适的启发函数(Heuristic Function)下,保证找到从起点到终点的最短路径,而基于射线检测的方法可能无法始终保证找到最短的路径,尤其是在复杂多变的环境中;
  • 高效率:A*算法通过启发式评估(即评价函数 f(n) = g(n) + h(n)),能够高效地确定搜索的方向,从而减少需要探索的节点数量。相比之下,基于射线检测的方法可能需要多次尝试和错误,尤其是在遇到多个障碍物时,计算成本较高;
  • 兼容性好:A*算法可以灵活地应用于不同类型的地图和多样化的环境,无论是开放空间、狭窄通道还是复杂迷宫,都能有效工作。通过调整启发式函数,A可以适应各种需求。射线检测方法在一些特定场景(如开阔地带少量障碍)中可能更为直接高效,但在条件复杂的环境中,其性能可能会受到限制;


简易地图编辑器

实现栅格化

相比于方案一,基于A*的寻路逻辑,需要提前将地图进行栅格化(网格),栅格化是把连续的信息分解成离散的单元格(像素、方格、体素),便于计算处理和分析,我们需要将整张地图进行栅格化,这里是和实际渲染在手机上的地图进行1:1还原,每个单元格以50*50为宽和高,为什么为50,是一个经验值,值越小,鸡走动的约真实,路径就越细致,50已经能满足当前场景。

 image.png

区块配置、预览

区块指的是地图上的事件的承载体,包含渲染坐标、障碍物坐标、落地点、事件code(服务端关联玩法)等信息。

同时支持运营开发同学自己勾选、编辑、移动障碍物等位置,图中勾选出来的障碍物即为1,不可通行。

image.png 

如何做到在地图道路上走、和远景房子融合

这块经过与设计、pd对接,在2d场景下很难实现,我们基于栅格的A*寻路能实现,但是会很不真实,如果要做到该效果,只能做3d的场景,下面是我画的路线图。

image.png 

很明显该路线是曲折的,需要小鸡的各角度的侧身,也就是需要x、y、z轴,才能模拟真实的效果,也就是只有3d场景才能满足诉求,与设计同学讨论了一下决定用更适合2d的场景设计,如下图:

image.png 

我们只需要将障碍物(场景、区块)设置好、在y轴上对小鸡做一定的scale缩放,来做透视扭曲(模拟现实中物体随着距离远近而变化的视觉效果)的效果,就能模拟一定的2.5d效果。

2.5D效果

整体将背景结合寻路算法给设计和业务同学看了一下,对于效果还没达到预期,比较僵硬,需要做一些层次效果,来模拟真实走动的效果。

视差滚动

通过获取相机在x、y轴的滚动距离,与远、中、近景以不同的速率相乘,近景即为草丛区移动得更快,中景即为行动草地区按正常滚动距离移动,远景也就是云层和天空移动得更慢,来模拟摄像机的移动和景深的效果。

image.png 

核心代码:

// 远景x轴速率
export const parallaxFactorFarX = 0.2;
// 远景y轴速率
export const parallaxFactorFarY = 0.2;
// 初始坐标
export const farOriginXY = [0, -60];

// 近景x轴速率
export const parallaxFactorNearX = 1.8;
// 近景y轴速率
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对象的形状都是不固定的,面积也会很大,我们统一用矩形去描述游戏对象的轮廓;

image.png

  • 取每个虚拟边框的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为主,光照、材质、阴影这块主要是以设计为主:

小鸡

神秘屋

双色球

彩蛋

image.png

 image.png

image.png 

 image.png

全链路指引

主动提的需求,担心用户不知道如何玩儿起来,得提供一个全链路的引导、指引你去熟悉新的玩法,一句话诉求为:用户长时间不行走、没有触发玩法、不浏览地图就触发指引,指引也有一定的优先级。如何划分优先级,这是业务属性,这里就不提了。

第一直觉代码如何编写?

  • 在行走结束后启动定时器,在拖拽地图时、触发玩法后清除定时器;
  • 在拖拽地图结束后启动定时器,在走动、触发玩法后清除定时器;
  • 在触发玩法后启动定时器,在走动、拖拽地图后清除定时器;

多一个链路,这种像狗皮膏药一样的代码就会越来越多,很不优雅,且容易遗漏。

使用了组内小伙伴做的小而美的多流程定时器的能力,大致的思路如下:

 image.png

核心思想就是,定时器统一管理,在过程中可以打断和重新计时,只有全部状态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;

Phaser+Rax双渲染引擎分层设计

去年写过一篇关于前端业务代码分层的文章《小鸡PK业务架构治理记录》,主要是针对于rax这个视图引擎的,本文的区别在于属于混合开发的模式,Phaser游戏开发的内容占比甚至比传统的前端rax/react开发更多。对于我们的分层模式来说其实没什么区别,只不过多了一种渲染方式而已,用phaser渲染和用react还是rax渲染其实都没什么区别。


架构图:

 image.png


Phaser游戏对象设计:

image.png

这样设计的好处是游戏对象可以在controller逻辑层任意调用,细看api,游戏对象只负责渲染,不包含任何业务逻辑。

总结

记录一下自己在做这个项目过程中遇到的问题和解决的思路,游戏的开发区别于传统前端,上述一些方案也自己慢慢摸索出来的,有更好的方案也可以一起讨论。

参考链接:

[1]‍https://zhuanlan.zhihu.com/p/679339532

[2]https://zhuanlan.zhihu.com/p/665252967


相关文章
游戏陪玩系统源码如何开发,实现游戏陪玩和游戏社交兼顾?
游戏市场的火热,吸引了很多资本的目光,各种赛事的激烈竞争,也燃起了年轻人对游戏的热情。在游戏市场飞速发展带动的周边业务中,游戏陪玩系统源码的开发备受瞩目。作为游戏陪玩行业发展的基石,游戏陪玩系统源码的开发在近几年经历了多次变革,最终形成了游戏陪玩+游戏社交的全新模式。
|
10月前
|
程序员 图形学 C++
一个无知oier的娱乐——我的游戏开发经历
一个无知oier的娱乐——我的游戏开发经历
70 0
|
容器
你也能做个羊了个羊游戏系列教程4:道具的实现
嗨!大家好,我是小蚂蚁。 今天是羊了个羊系列教程的最后一节,我们来学习一下游戏中的三种道具的实现。 这三种道具分别是:移出三张牌,撤回一步,随机打乱顺序。 接下来,我们就依次来看一下每种道具的具体实现。 这个道具的作用是从当前的卡牌槽中移出前三张牌,把它们暂时放在卡牌槽的上方,移出的牌可以在后续再次加入到卡牌槽中。 使用移出道具时,有可能会出现两种情况。一种是当前卡牌槽中的卡牌数量小于等于 3 张,一种是当前卡牌槽中的卡牌数量大于 3 张
83 0
|
传感器 开发框架 网络协议
羡慕《钢铁侠》电影里科技感满满的全息手势交互吗?现在你也可以!试试这款【本地手势识别案例】
羡慕《钢铁侠》电影里科技感满满的全息手势交互吗?现在你也可以!试试这款【本地手势识别案例】
156 0
P1000 超级玛丽游戏
P1000 超级玛丽游戏
77 0
10:超级玛丽游戏
10:超级玛丽游戏
82 0
|
机器人 C++
养一只机器猫是怎样一种体验? | 酷品
估计所有看过哆啦A梦的小伙伴都幻想有一天有这样一个机器猫宠物,万能又贴心。一个机器宠物,像普通宠物那么可爱贴心,但却不用管理它的吃喝拉撒,不是非常美好吗?
146 0
养一只机器猫是怎样一种体验? | 酷品
|
机器学习/深度学习 数据采集 编解码
3D全息技术带你沉浸式逛展逛店逛世界
万花筒3D全息扫描技术能够针对室内的各种场景实现全自动、高精度的室内3D全息内容采集和建模,并通过真实感的渲染技术,让用户摆脱了地理空间的束缚,身临其境地漫游在真实感的1比1复刻的数字孪生世界中。其中,团队自研的空间三维信息采集硬件,巧妙地运用旋转云台将单线激光雷达所采集的2D空间信息升格为3D信息,并研发了高效的深度稠密化算法。在算法拼接上,具有高精度,轻量化以及大规模化的优势,且引入高效的渲染技术让用户有更完美的沉浸体验感。目前该技术应用于云上会展、零售电商、建筑地产、家居服务等多个行业,通过AI技术推进真实物理空间的还原,让“空间”就在眼前。
806 0
3D全息技术带你沉浸式逛展逛店逛世界