React+Pixijs开发仿羊了个羊游戏总结

327 阅读6分钟

准备工作

源码地址:github.com/Soundmark/i…

成品地址:soundmark.github.io/ikun-game/

框架:cra

pixi版本:6.x

配套工具:gsap,@pixi/sound

pixi文档:pixijs.huashengweilai.com/

开发流程:

  1. 先初始化pixi实例,创建画布,创建一个全局对象维护游戏相关状态
  2. 规划页面区域(卡牌区,收集区和按钮区),分别写对应内容的绘制方法,初始化pixi实例后执行这些绘制方法绘制出页面
  3. 细化功能(点击事件,收集消除,洗牌等)

基础繁琐的内容不详细赘述,下面主要记录一下核心功能的实现思路。

功能

点击牌移动到收集框

20221007_040633.gif

这里重点实现的是移动的效果,正常来说,需要再requestAnimationFrame中逐帧改变卡牌位置直到卡牌到达我们需要的位置,但是自己手动实现的话还是比较麻烦,需要处理一些误差,这里我用了gsap来实现,可以很完美地完成这个功能。

    card.on("pointertap", ({ target }: { target: Card }) => {
      // 判断是否已收集或被遮罩
      if (target.gameInfo?.isCollected || model.isOnMask) return;

      target.zIndex = 1000;
      // 移动至收集框
      gsap.to(target, {
        x: model.collectorX + 2 + model.collectorArray.length * width,
        y: model.collectorY + 2,
      });
      target.gameInfo!.isCollected = true;
      model.collectorArray.push(target);
    });

牌消除

20221007_040732.gif

牌的消除需要注意两个点,一是判断什么组合才能进行消除,二是消除后剩下的牌的位置重新整理。

由于这里是用不同字的组合进行消除的方式,就会出现多个相同字都在收集框内并且达到消除条件的情况,所以要采用先收集先消除的策略,为了达到这个目的,不能采用直接遍历已收集的卡片判断是否有达到消除条件的方式,我们需要先做一个变体处理。

const foods = ["荔枝", "酥鳝", "蒸虾头", "圣金饼", "香精煎鱼", "人参公鸡"];

const collaspItems = (app: PIXI.Application): boolean => {
    // 将收集的卡片的字作为对象的key,将卡片对应的索引数组作为对象的value,这样可以记录卡片的位置以及收集顺序
    const infoObj = model.collectorArray
      .map((item) => item.gameInfo?.text || "any")
      .reduce((acc: Record<string, number[]>, cur, index) => {
        if (!acc[cur]) {
          acc[cur] = [index];
        } else {
          acc[cur].push(index);
        }
        return acc;
      }, {});
    const checkAndDealCollasp = (arr: string[]) => {
      if (arr.every((item) => infoObj[item])) {
        const indexArr: any[] = [];
        arr.forEach((item) => {
          // 达成消除条件时消除先收集的卡片
          app.stage.removeChild(model.collectorArray[infoObj[item][0]]);
          indexArr.push(infoObj[item][0]);
        });
        model.collectorArray = model.collectorArray.filter(
          (item, index) => !indexArr.includes(index)
        );
        return true;
      }
      return false;
    };
    for (let i = 0; i < foods.length; i++) {
      if (checkAndDealCollasp(foods[i].split(""))) {
        return true;
      }
    }
    return false;
  };
 
    card.on("pointertap", ({ target }: { target: Card }) => {
      if (target.gameInfo?.isCollected || model.isOnMask) return;

      target.zIndex = 1000;
      gsap.to(target, {
        x: model.collectorX + 2 + model.collectorArray.length * width,
        y: model.collectorY + 2,
      });
      target.gameInfo!.isCollected = true;
      model.collectorArray.push(target);
      // 定时器使卡片移动的动画结束后再开始判断消除的逻辑
      setTimeout(() => {
        if (collaspItems(app)) {
          // 消除后重新整理收集框的卡片位置
          model.collectorArray.forEach((item, index) => {
            gsap.to(item, {
              x: model.collectorX + 2 + index * width,
              y: model.collectorY + 2,
            });
          });
        }
      }, 600);
    });
    app.stage.addChild(card);
  };

牌的布局

牌的布局是整个游戏最难的地方,因为牌与牌之间有覆盖关系,当牌被覆盖的时候无法点击,覆盖的牌拿走了之后恢复点击,同时牌的颜色也要做出对应的变换。最开始是想通过碰撞检测来确定牌与牌之间的关系的,但是考虑到多层叠加的情况下,假如第1层的其中一个牌并不是被第2层的牌覆盖,而是被第3层的牌覆盖,有可能有这种情况,我就不得不对某一层的每一个牌都要和其上的每一层的牌进行碰撞检测,即使我并不需要与其他层的每一个牌进行碰撞检测(只对附近区域的牌检测),我认为也是比较耗费性能的一个事情,而且比较难去界定碰撞的范围,如果大家有什么比较好的想法欢迎来交流讨论。

最后我想的办法是直接固定布局,在洗牌的时候就确定好每一个牌之间的关系,而且这个关系是可预测的,并且将覆盖的关系信息记录在每一个牌中。这样做的好处就是很快并且准确的地确认牌与牌之间的关系,缺点就是伴随着布局需要固定死一种,如果要换一种的话需要重新思考如何去确认牌关系,灵活性很差。

image.png

// allItemArr是所有的卡片,卡牌分为三个区域:main,leftBottom和rightBottom
// leftBottom和rightBottom中的卡牌只有一种关系,比较容易确认
const leftBottom = allItemArr.slice(0, 20);
    const rightBottom = allItemArr.slice(20, 40);
    const main = allItemArr.slice(40);
    leftBottom.forEach((item, index) => {
      createCard(app, item, {
        x: 0.5 * model.cardWidth + index * model.cardWidth * 0.1,
        y: 6.75 * model.cardHeight,
        name: `leftBottom${index}`,
        upList: index < leftBottom.length - 1 ? [`leftBottom${index + 1}`] : [],
        downList: index !== 0 ? [`leftBottom${index - 1}`] : [],
      });
    });
    rightBottom.forEach((item, index) => {
      createCard(app, item, {
        x:
          window.innerWidth -
          1.5 * model.cardWidth -
          index * model.cardWidth * 0.1,
        y: 6.75 * model.cardHeight,
        name: `rightBottom${index}`,
        upList:
          index < rightBottom.length - 1 ? [`rightBottom${index + 1}`] : [],
        downList: index !== 0 ? [`rightBottom${index - 1}`] : [],
      });
    });
    let i = 0;
    // main区域的卡牌矩阵,布局的策略是一层5×5,一层6×6,错位覆盖,并且根据层之间的错位关系确定牌与牌的覆盖关系
    const grid: string[][][] = []; 
    while (main.length) {
      const size = i % 2 === 0 ? 5 : 6;
      const arr = main.splice(0, size * size);
      if (arr.length === 15) {
        arr.splice(5, 0, ...new Array(5).fill(0));
        arr.splice(15, 0, ...new Array(5).fill(0));
      }
      const subGrid = arr.reduce((acc: string[][], cur, index) => {
        const vector = Math.floor(index / size);
        if (acc[vector]) {
          acc[vector].push(cur);
        } else {
          acc[vector] = [cur];
        }
        return acc;
      }, []);
      i++;
      grid.push(subGrid);
    }
    grid.forEach((item, index) => {
      const size = index % 2 === 0 ? 5 : 6;
      item.forEach((item1, index1) => {
        item1.forEach((item2, index2) => {
          if (!item2) return;
          const getUpList = () => {
            if (index === grid.length) return [];
            if (index % 2 === 0) {
              return [
                `main_${index + 1}_${index1}_${index2}`,
                `main_${index + 1}_${index1 + 1}_${index2}`,
                `main_${index + 1}_${index1}_${index2 + 1}`,
                `main_${index + 1}_${index1 + 1}_${index2 + 1}`,
              ].filter((e) => {
                const eSplit = e.split("_") as [string, number, number, number];
                return grid[eSplit[1]]?.[eSplit[2]]?.[eSplit[3]];
              });
            }
            return [
              `main_${index + 1}_${index1}_${index2}`,
              `main_${index + 1}_${index1 - 1}_${index2}`,
              `main_${index + 1}_${index1}_${index2 - 1}`,
              `main_${index + 1}_${index1 - 1}_${index2 - 1}`,
            ].filter((e) => {
              const eSplit = e.split("_") as [string, number, number, number];
              return grid[eSplit[1]]?.[eSplit[2]]?.[eSplit[3]];
            });
          };
          const getDownList = () => {
            if (index === 0) return [];
            if (index % 2 === 0) {
              return [
                `main_${index - 1}_${index1}_${index2}`,
                `main_${index - 1}_${index1 + 1}_${index2}`,
                `main_${index - 1}_${index1}_${index2 + 1}`,
                `main_${index - 1}_${index1 + 1}_${index2 + 1}`,
              ].filter((e) => {
                const eSplit = e.split("_") as [string, number, number, number];
                return grid[eSplit[1]][eSplit[2]][eSplit[3]];
              });
            }
            return [
              `main_${index - 1}_${index1}_${index2}`,
              `main_${index - 1}_${index1 - 1}_${index2}`,
              `main_${index - 1}_${index1}_${index2 - 1}`,
              `main_${index - 1}_${index1 - 1}_${index2 - 1}`,
            ].filter((e) => {
              const eSplit = e.split("_") as [string, number, number, number];
              return grid[eSplit[1]]?.[eSplit[2]]?.[eSplit[3]];
            });
          };
          createCard(app, item2, {
            x:
              (window.innerWidth - size * model.cardWidth) / 2 +
              index2 * model.cardWidth,
            y:
              model.cardHeight * (0.5 + index1) +
              (index % 2 === 0 ? 0.5 * model.cardHeight : 0),
            upList: getUpList(),
            downList: getDownList(),
            name: `main_${index}_${index1}_${index2}`,
          });
        });
      });
    });

洗牌

20221007_040812.gif

洗牌的核心逻辑其实是打乱牌的顺序然后重新做一次牌的布局,为了让这个操作顺滑一点,这个过程要做一个动画,我在这里是将所有牌都收到中心然后再铺开,实现起来难度不大,不放代码了,不过布局的方法会有一些改动,这里要考虑的优化是将开始游戏时的布局方法和洗牌的布局方法复用起来。

提示弹窗

20221007_040854.gif

提示弹窗其实可以用html+css来做,不过这样做的效果是跟pixi的风格有差异的,因此我用pixi自己做了一个,主要的难点在于所有的尺寸和变换效果都要自己做,不像用html+css那样通过写样式能完成大部分内容。

const createText = (app: PIXI.Application, text: string) => {
  const msg = new PIXI.Text(text, {
    fontSize: window.innerWidth / 15,
    fill: "white",
    stroke: "#ff3300",
  });
  return msg;
};

export const createMenuModal = (app: PIXI.Application, model: Model) => {
  const container = new PIXI.Container();
  let roundBox = new PIXI.Graphics();
  roundBox.lineStyle(4, 0xb98340, 1);
  roundBox.beginFill(0xff9933);
  const width = (window.innerWidth * 2) / 3;
  const height = (window.innerWidth * 11) / 15;
  roundBox.drawRoundedRect(0, 0, width, height, 10);
  roundBox.endFill();
  roundBox.x = (window.innerWidth - width) / 2;
  roundBox.y = 0;

  const msgArr = [
    "🍴菜单🍴:",
    "1.荔枝 ¥18",
    "2.酥鳝 ¥53",
    "3.圣金饼 ¥28",
    "4.蒸虾头 ¥88",
    "5.香精煎鱼 ¥138",
    "6.人参公🐔 ¥666",
  ];
  const arr = msgArr.map((item, index) => {
    const msg = createText(app, item);
    msg.x = roundBox.x + 10;
    msg.y = index * msg.height + 10;
    return msg;
  });

  const btnText = createText(app, "关闭");
  btnText.x = (window.innerWidth - btnText.width) / 2;
  btnText.y = roundBox.y + roundBox.height - btnText.height - 20;
  btnText.interactive = true;
  btnText.on("pointertap", () => {
    if (model.mask) {
      model.mask.visible = false;
      model.isOnMask = false;
      const width = container.width;
      const height = container.height;
      const y = container.y;
      gsap.to(container, {
        x: window.innerWidth / 2,
        y: y + height / 2,
        width: 0,
        height: 0,
        duration: 0.2,
      });
      setTimeout(() => {
        container.visible = false;
        container.width = width;
        container.height = height;
        container.x = 0;
        container.y = y;
      }, 300);
    }
  });

  container.addChild(roundBox, ...arr, btnText);
  container.y =
    (window.innerHeight - container.height) / 2 - container.height / 2;
  container.zIndex = 2001;
  container.visible = false;

  app.stage.addChild(container);
  model.menu = container;
  return container;
};

总结

第一次开发游戏,也是第一次使用pixijs,使用上来说是很好用的,开发这样一个小游戏难度不大,但是要做的事情很繁琐,不知道如果使用游戏引擎开发效果如何,欢迎大家交流。