羊了个羊玩法PIXI版简单实现

411 阅读7分钟

开发环境

VUE + PIXI

"dependencies": {
    "core-js": "^3.8.3",
    "vue": "^3.2.13",
    "vue-pixi-wrapper": "^2.3.5",
},

初始化页面及棋盘参数

<template lang="html">
   <div id="pixi2"></div>
</template>


<script>
import * as PIXI from 'pixi.js';
// 加载棋子图案
import p1 from '../../assets/1.png';
import p2 from '../../assets/2.png';
import p3 from '../../assets/3.png';
import p4 from '../../assets/4.png';
import p5 from '../../assets/5.png';
import p6 from '../../assets/6.png';
import bg from '../../assets/bg.png';

export default {
  name: 'Pixi2',
  props: {},
  data() {
    // 游戏设置
    return {
      app: null, // PIXI.Application
      left: 0, // 棋盘左边坐标,后面会由top等计算赋值,这里先初始化
      right: 0, // 棋盘右边坐标,后面会由top等计算赋值,这里先初始化
      top: 100, // 棋盘上边坐标
      bottom: 0, // 棋盘下边坐标,后面会由top等计算赋值,这里先初始化
      stageWidth: 600, // 棋盘格在canvas上的绘制长度,长宽相等
      blockWidth: 100, // 棋子长宽相等
      layer: 5, // 棋盘层数
      blocks: [], // 棋盘状态实时更新
      sprites: [], // 棋子块图
      images: [p1, p2, p3, p4, p5, p6], // 将棋子图片用数组下标编号
      total: 0, // 总块数
      slot: [], // 计算棋子是否可消除的块槽
      status: -1, // 游戏成功状态
    };
  },
  mounted() {
    // 初始化棋盘
    this.initGame();
  },
  methods: {
    initGame() {
      this.status = -1;
      let app = new PIXI.Application({
        width: window.innerWidth, // default: 800 宽度
        height: window.innerHeight, // default: 600 高度
        antialias: true, // default: false 反锯齿
        transparent: false, // default: false 透明度
        resolution: 1, // default: 1 分辨率
        backgroundAlpha: 0, // 设置背景颜色透明度   0是透明
      });
      this.app = app;

      // 平铺背景
      this.setBackground(app);

      // 初始化棋盘
      this.left = Math.floor((window.innerWidth - this.stageWidth) / 2);
      this.right = this.left + this.stageWidth;
      this.top = window.innerHeight - this.stageWidth - 110;
      this.bottom = this.top + this.stageWidth;
      this.resetGame();

      // 将创建好的canvas添加到DOM当中去
      document.getElementById('pixi2').appendChild(app.view);
    },
   }
</script>

本篇文章在多处会用到生成随机数,所以先把实现随机数的方案放在这里,由于羊了个羊游戏的玩法是3个同类图案可消除,所以我们必须保证每种类型图案存在的个数是3的倍数,所以我们的随机数也要生成3的倍数随机数。

/**
 * 生成随机数
 * @param {*} min 随机数最小值
 * @param {*} max 随机数最大值
 * @param {*} times 随机数是times的倍数
 */
random(min, max, times = 1) {
  const num = Math.floor(Math.random() * (max - min) + min);
  return (num % times) + num; // 在num = max的极端情况下可能导致 num > max
},

绘制游戏面板

屏幕快照 2023-01-14 21.24.15.png

1. 棋子属性

  • active: boolean // 棋子是否可操作,可操作状态透明度为1,默认为true可操作
  • layer: number // 棋子层级,用1~n的数字大小来表示棋子层级,数字越大层级越高
  • target: Sprite // 棋子精灵对象,可调用pixi API来实现物理计算和事件监听
  • type: number // 棋子图片编号,本例中有6张棋子图片,所以type为0~5
  • x: number // 棋子在canvas面板上的绘制坐标
  • y: number // 棋子在canvas面板上的绘制坐标

2. 初始化棋盘的数据结构

视觉上6 * 6的游戏棋盘,为了绘制出棋子重叠的效果,每层需要12*12的二维数组来记录棋子的占位情况。然而因为棋子还有层级关系,所以需要的是三维数组12 * 12 * layer(即 x * y * layer)来记录棋盘情况。

这里为了简化数据结构,可以将游戏中需要用到棋盘计算的部分划分成几个子问题:

  1. 绘制不同层级棋子的显示情况(最高层棋子绘制透明度为1,其余棋子透明度为0.3)

解决这个问题只需要【层级*棋子数量】的二维数组,在同一层级平面有棋子自身属性(x,y)来展现平面二维的位置关系与层级一起组成三维的棋盘空间。

// 因为开发使用前端框架 Vue,所以页面全局变量挂载在 this 上
resetGame() {
    // 初始化棋盘
    const blocks = []; // 存储棋子
    this.layer = 5// 初始化5层棋盘,每层单独计算棋子在该层平面的位置
    for (let l = 0; l < this.layer; l++) {
        // 这里的+5(+n)最好通过计算得出,使得(this.total - (avg + n) * (this.layer-1)) > 0,保证总数total可分layer层
        let layerTotal = this.random(15, 10); // 获取最大值15和最小值10之间的一个随机数初始化为本层棋子数
        blocks.push(this.genBlocks(this.app, l, layerTotal)); // genBlocks 返回数量为 layerTotal 的棋子对象数组,this.app 为 PIXI.Application 对象为后期绘制canvas传入
    }
    
    // 计算最上层可操作棋子
    // 绘制棋子和是否可操作样式
}
  1. 保证每类棋子的生成数量是3的倍数
/**
 * 计算每种图案的块数
 * @param {*} total 总块数
 * @param {*} num 图案总数
 * @param {*} min 每种图案的最少块数
 */
divideTotalBlocks(total, num, min = 12) {
  let sum = 0;
  let sprites = [];
  for (let i = 0; i < num; i++) {
    let r = this.random(min, Math.floor(total / num) * 1.2); // 总块数大致平分为总数的1/num
    let s = 0;

    // 最后一种图案补齐块数
    if (i === num - 1) {
      s = total - sum > 0 ? total - sum : min;
    }

    // 每种图案的块数不能小于min值
    if (s < min) {
      s = min;
    }

    // 保证每种图案的块数都是3的倍数
    s = 3 - (r % 3) + r;

    sum += s;
    sprites.push({
      image: this.images[i], // 图像精灵图片地址
      type: i,
      num: s, // 记录每种图案的棋子个数,便于后续为棋子分配图案
    });
  }

  // 记录总块数
  this.total = sum;
  this.sprites = sprites;
}
  1. 同一层级棋子不相互覆盖

在同一层级棋子就只剩下(x,y)坐标的二维属性,所以这里也是需要二维数组来记录棋盘的放置状态。由于在二维空间棋子不相互覆盖,所以可以将每层平面简化成6*6的情况来表示。

  1. 相邻两层棋盘的棋子交错显示可以实现为单双层棋盘棋子初始位置间隔半个棋子宽度即可。

屏幕快照 2023-01-21 10.06.29.png

// 因为开发使用前端框架 Vue,所以页面全局变量挂载在 this 上
/**
 * 生成块
 * @param {*} app new PIXI.Application()对象
 * @param {*} layer 棋子所处层级
 * @param {*} total 本层棋子总数
 */
genBlocks(app, layer, total) {
  const width = this.blockWidth;
  const height = this.blockWidth;
  const rowNum = (this.right - this.left) / width;
  const colNum = (this.bottom - this.top) / height;
  const blocks = []; // 存放该层生成的棋子

  // 用mark二维数组记录生成块的位置,以免位置重复
 let mark = new Array(rowNum); // 表格行数
 for (let i = 0; i < rowNum; i++) {
    mark[i] = new Array(colNum).fill(0); // 表格列数
 }

  let i = 0;
  while (i < total) {
    let bx = 0;
    let by = 0;
    let px = 0;
    let py = 0;

    // 单双分层 绘制交错效果
    if (layer % 2 === 0) {
      // Math.floor(Math.random() * rowNum) 为0~rowNum的随机整数
      // 双层棋子起始位置为棋盘的最左边 this.left
      bx = Math.floor(Math.random() * rowNum);
      by = Math.floor(Math.random() * colNum);
      px = this.left +bx * width;
      py = this.top + by * height;
    } else {
      // 单层棋子起始位置为棋盘的最左边 this.left + width / 2 半个棋子宽度
      // 相应的末尾也要减少一个棋子位以免绘制出棋盘边界
      bx = Math.floor(Math.random() * (rowNum - 1));
      by = Math.floor(Math.random() * (colNum - 1));
      px = this.left + width / 2 + bx * width;
      py = this.top + height / 2 + by * height;
    }

    // 判断是否同层的随机位置已有占位块,有则重新初始化该棋子位置,无则将棋子放入棋盘mark中
    // 这个算法其实不太好,因为用的是 随机数 + while循环,有可能会产生死循环
    if (mark[bx][by] === 1) {
      continue;
    }
    mark[bx][by] = 1;
    i++;

    // 筛选出还有可分配余额的图案
    //(为了保证每个图案的总数都是3的倍数,所以我们在上一步中确定了每种图案的可分配数额)
    const sprites = this.sprites.filter(item => item.num > 0);
    const index = this.random(0, sprites.length);
    const randomSprite = sprites[index]; // 获取本次分配的图案
    randomSprite.num -= 1; // 分配出去的图案数量-1

    let block = new PIXI.Sprite.from(image);
    block.x = px;
    block.y = py;
    block.width = width;
    block.height = height;
    const blockItem = {
      target: block,
      type: type, // 图案类型
      x: px, // canvas 绘制x
      y: py, // canvas 绘制y
      layer: layer,
      active: false, // 默认棋子不可操作,下一步计算这个值
    };
    blocks.push(blockItem);

    // 选中棋子,则重新计算最上层可操作块,且将块移除this.blocks中,加入this.slot进行消除计算
    block.on('pointerdown', () => { // pointerdown 点击事件
      // 将选中棋子移除棋盘
      this.transferBlock(blockItem);
      // 重新计算最上层可操作块
      this.genActiveBlocks();
      // 重绘所有棋子
      this.renderBlocks(app, false);
      // 将选中棋子放入插槽数组,并计算插槽中的棋子是否有可消除
      this.computeSlot(blockItem);
      // 修改棋子的坐标到插槽内,重绘插槽中的棋子
      this.renderSlot();
    });
  }
  return blocks;
}
  1. 计算可操作棋子

由于只有最上层棋子可操作,可操作和棋子层级并不是强相关的关系。

由于存在单双层交错显示,所以计算棋子是否可交错,要把每一个棋子的占位分开成为2 * 2的矩阵来考虑。所以这个子问题中我们需要12 * 12的标记矩阵mark来记录每个2 * 2位置是否最上层已放置了棋子。

从最上层开始计算,每遍历一个棋子,先判断棋子所在2 * 2位置在标记矩阵mark中是否都没有标记过。如果是则该棋子位于三维棋盘最上层,可操作位active为true,并记录棋子放置于标记矩阵中,即对应2 * 2位置记为1(初始为0);否则仅记录棋子放置入标记矩阵即可。

由于从最上层开始计算,所以保证了最上层棋子一定都是可操作的。

/**
 * 计算可操作棋子
 */
genActiveBlocks() {
  const len = this.stageWidth / this.blockWidth;
  let mark = new Array(len * 2); // 因为表格有单双两层交错,所以表格有2倍len行
  for (let i = 0; i < len * 2; i++) {
    mark[i] = new Array(len * 2).fill(0); //每行有2倍len列
  }
  for (let l = 0; l < this.blocks.length; l++) {
    const layer = this.blocks[l];
    for (let b = 0; b < layer.length; b++) {
      const x = (layer[b].x - this.left) / (this.blockWidth / 2);
      const y = (layer[b].y - this.top) / (this.blockWidth / 2);
      if (
        mark[x][y] === 0 &&
        mark[x + 1][y] === 0 &&
        mark[x][y + 1] === 0 &&
        mark[x + 1][y + 1] === 0
      ) {
        layer[b].active = true;
      }
      mark[x][y] = 1;
      mark[x + 1][y] = 1;
      mark[x][y + 1] = 1;
      mark[x + 1][y + 1] = 1;
    }
  }
},
  1. 绘制棋子 计算得出可操作棋子区别于一开始初始化的不可操作棋子,我们就可以根据棋子的位置属性来绘制棋子了。

❗️需要注意的是,由于canvas绘制同一位置的图案会按绘制顺序最晚绘制的覆盖最先绘制的,所以绘制层级的顺序要和计算层级的顺序相反,避免原先的最上层可操作棋子绘制于最底层被覆盖。

/**
 * 绘制棋子
 */
renderBlocks(app, initial = true) { // app = new PIXI.Application()
  for (let l = this.blocks.length - 1; l >= 0; l--) {
    const layer = this.blocks[l];
    for (let b = 0; b < layer.length; b++) {
      if (layer[b].active) {
        layer[b].target.interactive = true;
        layer[b].target.alpha = 1; // 简单通过透明度来表示是否位于最上层,也可改为将图片灰度化处理,需要用到离屏canvas绘制的方式
      } else {
        layer[b].target.alpha = 0.3;
      }
      if (initial) {
        app.stage.addChild(layer[b].target);
      }
    }
  }
}

棋子块槽中的位置及消除计算

棋子块槽的数据结构比较简单,就是7位的一位数组,放入第7个棋子且无法和原有块槽中的棋子消除则视为游戏失败。

  1. 聚类和消除:块槽中插入的棋子与同类并列排放,块槽中存在3个同类棋子则同时消除3个棋子

屏幕快照 2023-01-21 12.38.20.png 也就是说不论新加入的棋子是第几个加入的,都要先找到块槽中是否有与它类型相同的棋子,把它摆放在同类棋子的位置序列中,如果没有同类棋子才将它放置在块槽所有棋子之后。

/**
 * 计算插槽内的块是否可消除
 */
computeSlot(block) {
  // 块不可交互
  block.target.interactive = false; // 放置入块槽的棋子不可交互,即不再响应鼠标点击事件

  // 添加或消除块
  const index = this.slot.findIndex(item => item.type === block.type); // 块槽中是否存在同类棋子
  if (index >= 0) { // 存在同类棋子则返回其坐标index >= 0,否则index为-1
    this.slot.splice(index, 0, block); // 存在则在index后一位插入棋子block
  } else {
    this.slot.push(block); // 不存在则在块槽末尾插入棋子block
  }

  const count = this.slot.filter(item => item.type === block.type); // 计算是否存在同类棋子数量为3
  if (count.length === 3) { // 存在则同时消除这一类棋子
    this.slot = [
      ...this.slot.slice(0, index),
      ...this.slot.slice(index + 3),
    ];
    count.forEach(item => {
      item.target.destroy(); // 消除棋子的同时,销毁棋子在canvas中的对象
    });
  }

  // 计算是否失败
  if (this.slot.length === 7) { // 块槽为7为,计算块槽是否放满7位棋子,是则游戏失败
    this.status = 0;
    console.error('You Lose!');
    return;
  }

  // 计算是否成功
  let sum = 0;
  for (let layer = 0; layer < this.blocks.length; layer++) {
    // 由于棋子是 new PIXI.Sprite 对象
    // 所以在 destroy 销毁后其存储在blocks里面的对象也将不存在
    // 所以这里直接计数就可以了
    sum += this.blocks[layer].length;
  }

  if (sum === 0) { // 统计剩余棋子数量,为0则游戏胜利
    this.status = 1;
    console.error('You Win!');
    return;
  }
},
  1. 绘制块槽中的棋子

无论是棋盘还是块槽,我们都是先进行棋子相关情况的计算,确定绘制的位置和显示透明度,以及是否可操作,最后再执行绘制。

由于块槽的起始位置固定,棋子长度固定,所以直接利用这两个常量和棋子在块槽数组slot中的坐标,就可以计算每个棋子绘制的位置。

/**
 * 修改块坐标到块槽内
 */
renderSlot() {
  const left = (window.innerWidth - this.blockWidth * 7) / 2;
  const top = window.innerHeight - 110;
  // 绘制块
  this.slot.forEach((block, index) => {
    block.target.x = left + index * this.blockWidth;
    block.target.y = top;
    block.x = left + index * this.blockWidth;
    block.y = top;
  });
}

成功和失败面板及操作

在游戏失败的情况下,需要修改所有棋子的可操作状态为false不可操作,否则会出现游戏已失败,但是提示后面的游戏棋盘还可进行操作的情况。

为了避免上述麻烦,我们这里就简单的在成功和失败这两种状态下,绘制一个覆盖在canvas之上的全屏div来显示游戏状态,并提供一个按钮button来重置游戏即可。

屏幕快照 2023-01-21 13.19.34.png

优化

  1. 打乱棋子顺序的实现方案

1)可以遍历所有棋盘中的棋子,用随机数生成棋子位置再重新绘制的方式实现,但是这个方案生成的位置是随机的,会打乱原先的棋盘层级和布局。

2)可以遍历所有棋盘中的棋子,将其位置收集在一个新数组中,再遍历一遍棋子,随机取该数组中的一个位置为该棋子新位置,再重新绘制。

3)随机交换两个棋子的位置,将所有棋子拍平到一位数组里,然后遍历一遍这个一维数组,任意交换当前棋子和数组中另一个位置棋子的坐标。

由于本demo是简易版实现就没有实现上述功能了,有兴趣的小伙伴可以利用上述思路实现一下。

  1. 回退一步

需要每操作一个棋子都在全局保存一下该棋子的状态,和块槽中的棋子状态,如果玩家执行回退操作,则重新将该棋子加入棋盘blocks中,并恢复上一步保存的块槽状态再重新绘制。

  1. 推出3格块槽中的棋子

新设置一块暂存操作区,保存推出来的3块槽首部3颗棋子,将棋子从块槽中删除,后将棋子状态切换为可操作即可。

❗️由于引入了随机数,还要保证游戏有解,所以计算中可能存在死循环的情况。如果按照羊了个羊,每天下发固定棋盘的方式实现可以减少上述麻烦。

其他

Demo体验地址:www.miraclegarden.cn/#/cafeteria

Demo代码实现:github.com/theforeverh…