#app
  .info
    button.btn(@click="gameInit") RESTART
    .text
      span your score: {{ score }}
      span(v-if="gameOver") Game Over, thanks for coming
      span(v-else) (use less match times to get more bouns score)

  //- span {{ select }} {{ selectIndex }}
  .container
    .card(
      v-for="(card, index) in cards.value",
      :class="{ visible: card.isVisible, matched: card.isMatch }"
    )
      .card-cover(
        :class="{ hidden: card.isVisible, matched: card.isMatch }",
        @click="cardClick(card, index)"
      )
      ion-icon.icon(:name="card.icon")
View Compiled
@import url('https://fonts.googleapis.com/css2?family=Lobster&family=Waterfall&display=swap')

// const
card_w = 120px
card_h = 160px
bg = linear-gradient(45deg, #F1DAC4, #A69CAC)
card_bc = #FFF
card_cover_bc = #163045
card_border = 3px solid #000

// mixin
setSize(w, h)
  width: w
  height: h

// global
body
  background: bg
  margin: 0
  display: flex
  justify-content: center
  align-items: center
  height: 100vh
  font-family: 'Lobster', cursive

.container
  width: 'calc((%s * 6) + 1rem * 6)' % card_w
  display: flex
  flex-wrap: wrap
  margin: 0.25rem

  > *
    margin: 0.25rem

.info
  padding: 0.5rem
  font-size: 1.5rem
  display: flex
  justify-content: space-between

  .text
    display: flex
    align-self: center
    padding: 0.5rem 1rem

    > *
      margin-right: 0.5rem

  .btn
    position: relative
    cursor: pointer
    border-radius: 0
    padding: 0.5rem 1rem
    font-size: 1.5rem
    background-color: card_cover_bc
    font-family: Lobster
    overflow: hidden
    color: #ddd

    &:before
      content: ''
      position: absolute
      setSize(100%, 100%)
      background-color: rgba(#fff, 0.2)
      left: 0
      top: 0
      transform: translateX(50%) skew(30deg)
      transition: 0.3s

    &:hover
      &:before
        transform: translateX(-50%) skew(30deg)

// card
.card
  setSize(card_w, card_h)
  position: relative
  background-color: card_bc
  border: card_border
  border-radius: 4px
  display: flex
  justify-content: center
  align-items: center
  transition: 0.5s
  transform: rotateY(180deg)
  box-shadow: inset 0 0 0 4px #fff, inset 0 0 0 7px #333

  &.visible
    transform: rotateY(0deg)

  &.matched
    opacity: 0
    pointer-events: none

  .icon
    font-size: 5.5rem
    display: block

.card-cover
  setSize(card_w, card_h)
  position: absolute
  cursor: pointer
  background-color: card_cover_bc
  transition: 0.5s
  border-radius: 4px
  border: card_border
  z-index: 1
  transform: rotateY(0deg)
  backface-visibility: hidden
  overflow: hidden

  &:before
    content: ''
    position: absolute
    setSize(card_w, card_h)
    background-color: silver
    transform: skew(35deg) scaleX(50%)
    box-shadow: 0 0 0 50px card_cover_bc, 0 0 0 100px #fff, 0 0 0 150px card_cover_bc, 0 0 0 200px silver

  &:after
    content: ''
    position: absolute
    opacity: 0.3
    setSize(card_w, card_h)
    background-color: silver
    transform: skew(-35deg) scaleX(50%)
    box-shadow: 0 0 0 50px card_cover_bc, 0 0 0 100px silver, 0 0 0 150px card_cover_bc, 0 0 0 200px silver

  &.hidden
    transform: rotateY(180deg)
View Compiled
const { ref, reactive, computed } = Vue;
const cardOptions = [
  "accessibility-outline",
  "fish-outline",
  "hourglass-outline",
  "diamond-outline",
  "skull-outline",
  "shield-outline",
  "dice-outline",
  "bug-outline",
  "boat-outline",
  "baseball-outline",
  "infinite-outline",
  "star-outline"
];

const App = {
  setup() {
    // https://stackoverflow.com/questions/64416605/vuejs3-reactive-array-in-a-component
    let cards = reactive({ value: [] });
    let select = ref([]);
    let score = ref(0);
    let bonus = new Bouns(10);
    let matchPair = ref(0);

    const selectIndex = computed(() => select.value.map((s) => s.index));
    const gameOver = computed(() => cardOptions.length === matchPair.value);

    // 初始化
    // 洗牌,所有暫存值歸 0
    const gameInit = () => {
      cards.value = shuffle();
      score.value = 0;
      matchPair.value = 0;
      bonus.init();
      select.value = [];
    };

    // 卡片點擊:
    // 翻開卡片, 紀錄點擊的卡片
    const cardClick = (card, index) => {
      cards.value[index].isVisible = true;
      select.value.push({ value: cards.value[index].icon, index });
      checkSelect();
    };

    // 檢查分數:
    // 如果待檢查的卡片超過兩張,就清空欄位
    // 如果待檢查的卡片等於兩張,檢查是否相符,計算分數
    const checkSelect = () => {
      if (select.value.length > 2) {
        select.value = [];
        cards.value.forEach((c) => (c.isVisible = false));
      } else if (select.value.length == 2) {
        let isMatch = select.value.reduce((a, b) => a.value === b.value);
        if (isMatch) {
          // 卡片相符:累加分數與重置累計分
          select.value.forEach((s) => (cards.value[s.index].isMatch = true));
          select.value = [];
          score.value += bonus.value;
          matchPair.value++;
          bonus.init();
        } else {
          // 沒有相符:失去累計分
          bonus.losePoint();
        }
      }
    };

    gameInit();

    return { cards, score, gameInit, cardClick, select, selectIndex, gameOver };
  }
};

// 洗牌
// 建立隨機數字塞入 option, 用 counter 紀錄已經塞過的值
function shuffle() {
  let counter = {};
  let array = [];
  while (array.length < cardOptions.length * 2) {
    let randomNum = Math.floor(Math.random() * cardOptions.length);
    let c = cardOptions[randomNum];
    if (!counter[c] || counter[c] < 2) {
      array.push({ icon: c, isVisible: false, isMatch: false });
      counter[c] = counter?.[c] ? counter[c] + 1 : 1;
    }
  }
  return array;
}

class Bouns {
  constructor(initValue) {
    this.initValue = initValue;
    this.value = initValue;
  }
  init() {
    this.value = this.initValue;
  }
  losePoint() {
    if (this.value > 1) {
      this.value--;
    }
  }
}

Vue.createApp(App).mount("#app");

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.