clean-js | 手把手教你写一个羊了个羊麻将版

612 阅读4分钟

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第3篇文章,点击查看活动详情

贴上源码 👉🏻 github.com/lulusir/mal…
在线地址 👉🏻 lulusir.github.io/malegema/

期待宝子们的star⭐️

这游戏那么🔥,具体需求就不说啦

首先来看一下界面

image.png

界面元素 0. 卡牌,就是一张张的麻将

  1. 牌堆,用来放置剩余的牌
  2. 卡槽,用来存放已选择的牌

代码分析

辅助类

Card

  • 卡片类定义属性和方法,具体看下方代码
  • setRect用于设置卡片的xyz
  • intersect用来判断两张卡片是否重叠
  • delete设置过渡的删除状态以及删除状态
export class Card {
  static CardWidth = 40;

  static CardHeight = 56;

  static _id = 0

  constructor(public type: ECardType) {
    this.img = ImagesMap[this.type];
  }

  id = Card._id++

  left = 0;

  top = 0;

  z = 0;

  width = Card.CardWidth;

  height = Card.CardHeight;

  img = '';

  blocked = false;

  deleted = false;

  deleting = false;

  setRect(
    opt: Partial<{
      left: number;
      top: number;
      width: number;
      height: number;
    }>,
  ) {
    this.left = opt.left ?? this.left;
    this.top = opt.top ?? this.top;
    this.width = opt.width ?? this.width;
    this.height = opt.height ?? this.height;
  }

  intersect(b: Card) {
    const a = this;
    return (
      Math.abs(a.left + a.width / 2 - b.left - b.width / 2) <
        (a.width + b.width) / 2 &&
      Math.abs(a.top + a.height / 2 - b.top - b.height / 2) <
        (a.height + b.height) / 2
    );
  }

  /**
   * 设置删除动画
   */
  delete() {
    this.deleting = true;
    return new Promise((resolve) => {
      setTimeout(() => {
        this.deleting = false;
        this.deleted = true;
        resolve(null)
      }, 300);
    })
  }
}

Layer

层级控制器

  • 用来设置卡片的层级和位置
  • 我们用网格来放置每一层的卡片,比如下面这样的网格,1表示有卡片,0表示没有
10
10
  • 通过改变层级的偏移量,以及z属性,来实现不同层级卡片的堆叠效果
  • 通过init方法,把传进来的卡组设置好对应的left,top,z
export class Layer {

  /**
 * 从底部为第一层,每一层zindex为 10, 20, 30
 * @param level
 */

  static levelZ = (level = 1) => {
    return level * 10;
  };

  left = 0;

  top = 0;

  z = 0;

  data: (Card | 0)[][] = [[]]; // 0 表示没有卡片

  init(data: (Card | 0)[][], left: number, top: number, z: number) {
    this.left = left;
    this.top = top;
    this.z = Layer.levelZ(z);
    this.data = data;
    this.setCardsPosition();
  }


  /**
   * 对当前层级的卡片设置位置
   */
  setCardsPosition() {
    if (this.data) {
      this.data.forEach((col, j) => {
        col.forEach((v, i) => {
          if (v) {
            v.setRect({
              left: i * Card.CardWidth + this.left,
              top: j * Card.CardHeight + this.top,
            });
            v.z = this.z;
          }
        });
      });
    }
  }
}

Slot

卡槽,用来放置已选择的卡片,并且判断消除卡片

  • add方法,为卡槽添加卡片,判断是否结束游戏
  • refresh方法,更新卡槽中卡片数据
  • remove方法,消除卡片
export class CardSlot {
  left = 0;

  top = 400;

  data: Card[] = [];

  add(card: Card) {
    if (this.data.length < 7) {
      card.setRect({
        left: this.left + Card.CardWidth * this.data.length,
        top: this.top,
      });
      this.data.push(card);
      return true;
    } else {
      // 通知游戏结束
      return false;
    }
  }

  refresh() {
    this.data.forEach((v, i) => {
      v.setRect({
        left: this.left + Card.CardWidth * i,
        top: this.top,
      });
    });
  }

  remove(card: Card) {
    let count = 0;

    this.data.forEach((v) => {
      if (v.type === card.type) {
        count += 1;
      }
    });

    let success = count === 3;

    if (success) {
      const p = Promise.all(this.data.filter( v => v.type === card.type).map(v => v.delete()))
      this.data = this.data.filter((v) => v.type !== card.type);
      this.refresh();
      return p
    }

    return Promise.resolve()
  }
}

Presenter

  1. 先定义界面要展示的元素
interface IViewState {
  // 每一张卡牌的属性
  data: {
    id: number

    left: number;

    top: number;

    z: number; // 层级

    width: number;

    height: number;

    img: string;

    blocked: boolean; // 是否被遮挡

    deleted: boolean; // 是否被消除

    deleting: boolean; // 删除中,用于删除动画
  }[];
  // 插槽的位置
  slot: {
    top: number
    left: number
  }
}

const defaultState: () => IViewState = () => {
  return {
    data: [],
    slot: {
      top: 0,
      left: 0
    }
  };
};

@injectable()
export class GamePresenter extends Presenter<IViewState> {
  constructor(public slot: CardSlot, public layer: Layer) {
    super();
    this.state = defaultState();
  }
 allCard: Card[] = [];
  

  setSlot() {
    this.setState(s => {
      s.slot.top = this.slot.top
      s.slot.left = this.slot.left
    })
  }

  initCards() {
    let nums = 16 + 12 + 8;

    const types = Array(nums / 3)
      .fill(0)
      .map((_, i) => {
        return i % 7;
      });
    
    const cards: Card[] = [];

    for (let i = 0; i < nums; i++) {
      const element = new Card(types[i % 12]);
      cards.push(element);
    }

    const data = this.shuffle(cards);

    function getCard() {
      const c = data.pop();
      return c;
    }
    
    // 分配卡片,初始化层级
    this.layer.init(
      [
        [getCard(), getCard(), getCard(), getCard(), getCard()],
        [getCard(), 0, 0, 0, getCard()],
        [getCard(), 0, 0, 0, getCard()],
        [getCard(), 0, 0, 0, getCard()],
        [getCard(), getCard(), getCard(), getCard(), getCard()],
      ],
      0,
      0,
      1,
    );

    this.layer.init(
      [
        [getCard(), getCard(), getCard(), getCard()],
        [getCard(), 0, 0, getCard()],
        [getCard(), 0, 0, getCard()],
        [getCard(), getCard(), getCard(), getCard()],
      ],
      20,
      30,
      2,
    );

    this.layer.init(
      [
        [getCard(), getCard(), getCard()],
        [getCard(), 0, getCard()],
        [getCard(), getCard(), getCard()],
      ],
      40,
      60,
      3,
    );

    this.allCard = cards;
  }

  shuffle(input: any[]) {
    const arr = [...input];
    let length = arr.length,
      temp,
      random;
    while (0 !== length) {
      random = Math.floor(Math.random() * length);
      length -= 1;
      temp = arr[length];
      arr[length] = arr[random];
      arr[random] = temp;
    }
    return arr;
  }

  init() {
    this.setSlot()
    this.initCards()
    this.updateBlock();
    this.updateView()
  }

  click(id: number) {
    const c = this.allCard.find(v => v.id === id)
    if (c) {
      if (!c.blocked) {
        const canContinue = this.slot.add(c);
        if (!canContinue) {
          alert('游戏结束咧')
        }  else {
          this.slot.remove(c).then(() => {
            this.updateBlock()
            this.updateView()
          });
          this.updateBlock()
          this.updateView()
        }
      }
    }
  }

  /**
   * 更新遮挡
   */
  updateBlock() {
    this.allCard.forEach((v) => {
      v.blocked = false;
    });

    this.allCard.forEach((a) => {
      this.allCard.forEach((b) => {
        if (a !== b) {
          if (!(a.deleted || b.deleted)) {
            if (a.intersect(b)) {
              if (a.z > b.z) {
                b.blocked = true;
              } else {
                a.blocked = true;
              }
            }
          }
        }
      });
    });

  }

  updateView() {
    this.setState((s) => {
      s.data = this.allCard.map((v) => {
        return {
          ...v,
        };
      });
    });
  }
 }
  1. 编写界面
  • playground 作为我们的卡片和卡槽区域的容器
  • 每张卡片都是通过绝对定位和z-index来控制位置
  • 在卡片deleting状态时添加卡片消失动画
  • 卡槽也是通过绝对定位来控制位置
import { GamePresenter } from '@/core/game.presenter';
import { usePresenter } from '@clean-js/react-presenter';
import { useEffect } from 'react';
import './index.less';
import 'animate.css';

export default function HomePage() {
  const { p, s } = usePresenter(GamePresenter);

  useEffect(() => {
    p.init();
  }, []);

  return (
    <div className="page">
      <div className="playground">
        {s.data.map((u) => {
          if (!u) {
            return null;
          }
          if (u.deleted) {
            return null;
          }
          return (
            <img
              onClick={() => {
                p.click(u.id);
              }}
              className={`card ${u.blocked ? 'isBlocked' : ''} ${
                u.deleting ? 'animate__animated animate__fadeOut' : ''
              }`}
              src={u.img}
              style={{
                left: u.left,
                top: u.top,
                zIndex: u.z,
                width: u.width + 'px',
                height: u.height + 'px',
              }}
            ></img>
          );
        })}

        <div
          className="slot"
          style={{
            left: p.state.slot.left,
            top: p.state.slot.top,
          }}
        ></div>

        <button
          type="button"
          className="btn1"
          onClick={() => {
            p.init();
          }}
        >
          重来
        </button>
      </div>
    </div>
  );
}
.page {
  background-color: green;
  width: 100vw;
  height: 100vh;
  padding: 20px;
}
.playground {
  position: relative;

  width:200px;
  height: 650px;


  margin: 20px auto;
}

.card {
  position: absolute;
  transition: all .6s;
  color: rgb(208, 68, 68);
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: #fff;

  &.isBlocked {
    opacity: .3;
  }
}

.empty-card {
  position: absolute;
  transition: all .3s;
}

.slot {
  position: absolute;
  background: rgba(0, 0, 0, 0.503);
  left: 0;

  width: 280px;
  height: 56px;
}

.btn {
  position: absolute;

  top: 600px
}

.btn1 {
  position: absolute;

  top:660px
}

就这样,一个爆款游戏就完成了

在这里使用了clean-js作为状态库来编写游戏,期待宝子们的star⭐️

贴上源码 👉🏻 github.com/lulusir/cle…

整洁架构篇:juejin.cn/post/714092…

clean-js介绍篇: juejin.cn/post/714321…


其他文章
什么?在React中也可以使用vue响应式状态管理
clean-js | 自从写了这个辅助库,我已经很久没有加过班了…
clean-js | 在hooks的时代下,使用class管理你的状态
clean-js | 手把手教你写一个羊了个羊麻将版
写给前端的数据库入门 | 序
写给前端的数据库入门 | docker & 数据库
有没有一种可能,你从来都没有真正理解async
三分钟实现前端写JAVA这件事——装环境
三分钟实现前端写JAVA这件事——VS code