• בלוג
  • בואו נכתוב משחק זיכרון ב JavaScript עם הפרדה בין לוגיקה ל UI

בואו נכתוב משחק זיכרון ב JavaScript עם הפרדה בין לוגיקה ל UI

17/11/2020

הלוגיקה של משחק זיכרון היא די פשוטה: שחקן הופך קלף, הופך עוד אחד, אם הם זהים משאירים את שניהם פתוחים ואם שונים סוגרים את שניהם. למרות זאת כשאנחנו באים לחבר את זה ל JavaScript ולממשק משתמש לפעמים נוצר סלט בין הלוגיקה של המשחק לבין הקוד הדרוש כדי לצייר, להציג ולהסתיר את הקלפים. ערבוב כזה אומר שיהיה לנו יותר קשה לבדוק את הלוגיקה או לשנות את ה UI בעתיד.

אבל האמת שב JavaScript ערבוב כזה ממש לא הכרחי והשפה כוללת מספר מנגנונים שעוזרים לנו לבנות את רכיב הלוגיקה ורכיב ה UI כמעט בנפרד ולייצר ממשק עבודה ברור ביניהם. בואו נראה מהם המנגנונים ואיתם נבנה את המשחק.

1. חלוקה למחלקות

הכלי הראשון הוא פשוט חלוקה למחלקות. בשביל לבנות שתי ממלכות נפרדות, אחת של הלוגיקה והשניה של ממשק המשתמש, הכי קל להתחיל עם הגדרת שתי מחלקות:

class MemoryGame {
}

class MemoryGameUi {
}

אני ממשיך לבחירת מחלקה אחת שתנהל את המשחק ותשתמש במחלקה השניה. אפשר לבחור כל אחת מהשתיים ובשביל הדוגמה אני אלך על MemoryGame בתור המחלקה המנהלת. אנשי Object Oriented יותר רציניים היו יוצרים מחלקה חדשה לחבר בין שתי המחלקות, אבל אני חושב שבמשחק זיכרון אפשר לעשות הנחה.

מאחר ו MemoryGame היא המחלקה הראשית אני מעביר אוביקט מסוג MemoryGameUi לבנאי של MemoryGame. מחלקת הלוגיקה תשתמש בממשק המשתמש כדי לצייר את הקלפים, להציג קלף כשצריך ולהסתיר קלפים כשצריך:

class MemoryGame {
    constructor(size, ui) {
        this.ui = ui;
    }
}

class MemoryGameUi {
}

2. מימוש לוגיקת המשחק בלי ממשק משתמש

הלוגיקה של משחק זיכרון היא פשוטה ותיארתי אותה בתחילת הפוסט. כך זה נראה בקוד:

  constructor(size, ui) {
    this.ui = ui;
    this.cards = _.shuffle([..._.range(size), ..._.range(size)]);
    this.revealedPositions = new Set();
    this.currentTurnPositions = [];
  }

  play(cardPosition) {
    if (
      this.revealedPositions.has(cardPosition) ||
      this.currentTurnPositions.includes(cardPosition)
    ) {
      // card is already revealed
      return;
    }
    if (this.currentTurnPositions.length === 0) {
      this.revealFirstCard(cardPosition);
    } else if (this.currentTurnPositions.length === 1) {
      this.revealSecondCard(cardPosition);
    } else {
      alert(this.currentTurnPositions);
    }
  }

  revealFirstCard(cardPosition) {
    this.currentTurnPositions.push(cardPosition);
  }

  revealSecondCard(cardPosition) {
    this.currentTurnPositions.push(cardPosition);

    if (
      // found a pair :)
      this.cards[this.currentTurnPositions[0]] ===
      this.cards[this.currentTurnPositions[1]]
    ) {
      for (let idx of this.currentTurnPositions) {
        this.revealedPositions.add(idx);
      }
    } else {
      // no pair, hide them back
    }
    this.currentTurnPositions = [];
  }
}

מאחר ואין שום קוד ממשק משתמש קל לכתוב בדיקות יחידה לקוד הלוגיקה ולדמות משחקים שלמים. נכון, בטח נרצה לדרוס את הקוד של shuffle בקוד הבדיקה כדי שהמשחק יהיה צפוי אבל זה mock ממש פשוט וכל תשתית בדיקה שנבחר מכילה את הכלים לכזה שינוי.

3. קוד ה UI

הקוד שמטפל ב UI הוא קצת יותר מורכב כי הוא משתמש ב DOM APIs: אנחנו צריכים להיות מסוגלים לצייר DOM Elements עבור רשימת קלפים, להציג ולהסתיר אותם וגם לתת למשתמש זמן לראות את הקלפים לפני שמסתירים.

קוד ה UI לא יודע כלום על קוד הלוגיקה והממשק שלו כולל את הפונקציות הבאות:

  1. פונקציה בשם revealCard שמציגה למשתמש קלף

  2. פוקנציה בשם drawCards שמציירת את כל הקלפים מכוסים

  3. פונקציה בשם hideAfter שמסתירה קלפים אחרי X שניות

קוד המחלקה המלא נראה כך:

class MemoryGameUi {
  constructor(container) {
    this.root = container;
    this.onClick = () => {};

    this.root.addEventListener("click", (e) => {
      const card = e.target;
      if (!card.classList.contains("card")) {
        return;
      }

      const position = card.dataset.position;
      this.onClick(position);
    });
  }

  hideAfter(seconds, ...cardPositions) {
    setTimeout(() => {
      for (let pos of cardPositions) {
        this.cardElements[pos].classList.add("hidden");
      }
    }, seconds * 1000);
  }

  revealCard(position) {
    this.cardElements[position].classList.remove("hidden");
  }

  drawCards(cards) {
    const container = document.createDocumentFragment();
    this.cardElements = cards.map(this.drawHiddenCard.bind(this));

    this.cardElements.forEach((el) => {
      container.appendChild(el);
    });

    this.root.innerHTML = "";
    this.root.appendChild(container);
  }

  drawHiddenCard(cardValue, position) {
    const card = document.createElement("div");
    card.classList.add("card");
    card.textContent = cardValue;
    card.classList.add("hidden");
    card.dataset.position = position;
    return card;
  }
}

4. ממשק מובלע וטיפוסי משתנים דינמיים

עכשיו אפשר להתחיל לחבר את החוטים בין שתי המחלקות. בגלל שאנחנו ב JavaScript אני לא צריך לספר לאף אחד מה הממשק של MemoryGameUi; מספיק לקרוא לפונקציות שלו עם הפרמטרים הנכונים.

לכן התחנה הראשונה שלי בחיבור יכולה להיות להוסיף את הקריאות ל UI בנקודות מפתח בקוד הלוגיקה:

  1. כשמשתמש לוחץ על קלף אני אחשוף את הקלף.

  2. כשמשתמש לוחץ על הקלף השני אם שני הקלפים לא זהים אסתיר את שניהם.

  3. כשמתחילים משחק אצייר את כל הקלפים על הלוח.

כך נראה הקוד עם החיבורים (סימנתי בהערה את תוספת החיבורים):

class MemoryGame {
  constructor(size, ui) {
    this.ui = ui;
    this.cards = _.shuffle([..._.range(size), ..._.range(size)]);

    // CALLING UI
    this.ui.drawCards(this.cards);

    this.revealedPositions = new Set();
    this.currentTurnPositions = [];
  }

  play(cardPosition) {
    if (
      this.revealedPositions.has(cardPosition) ||
      this.currentTurnPositions.includes(cardPosition)
    ) {
      // card is already revealed
      return;
    }
    if (this.currentTurnPositions.length === 0) {
      this.revealFirstCard(cardPosition);
    } else if (this.currentTurnPositions.length === 1) {
      this.revealSecondCard(cardPosition);
    } else {
      alert(this.currentTurnPositions);
    }
  }

  revealFirstCard(cardPosition) {
    this.currentTurnPositions.push(cardPosition);

    // CALLING UI
    this.ui.revealCard(cardPosition);
  }

  revealSecondCard(cardPosition) {
    this.currentTurnPositions.push(cardPosition);

    // CALLING UI
    this.ui.revealCard(cardPosition);

    if (
      // found a pair :)
      this.cards[this.currentTurnPositions[0]] ===
      this.cards[this.currentTurnPositions[1]]
    ) {
      for (let idx of this.currentTurnPositions) {
        this.revealedPositions.add(idx);
      }
    } else {
      // no pair, hide them back

      // CALLING UI
      this.ui.hideAfter(2, ...this.currentTurnPositions);
    }
    this.currentTurnPositions = [];
  }
}

סך הכל החיבור ל UI הוסיף לי 4 קריאות מקוד הלוגיקה.

5. שימוש בפונקציות בתור First Class Citizens

בשביל שהמשחק יעבוד אנחנו צריכים עוד חיבור קטן הפעם לצד השני - חיבור מקוד ה UI ללוגיקת המשחק. ברגע שמשתמש לוחץ על קלף אני רוצה להפעיל את הפונקציה play מקוד הלוגיקה.

טריק פשוט לטפל בחיבור כזה נקרא Hooks. אני בונה פונקציה ריקה במחלקת UI ומפעיל אותה כשמשתמש לוחץ על קלף. קוד הלוגיקה ידרוס את הפונקציה הזאת עם פונקציה שמעניינת אותו וכך יוכל להתחבר ל"וו" שקוד ה UI חושף.

בצד ה UI זה אומר לשנות את קוד הבנאי שיראה כך:

  constructor(container) {
    this.root = container;
    this.onClick = () => {};

    this.root.addEventListener("click", (e) => {
      const card = e.target;
      if (!card.classList.contains("card")) {
        return;
      }

      const position = card.dataset.position;
      this.onClick(position);
    });
  }

ובקוד הלוגיקה זה אומר לשנות את קוד הבנאי שיראה כך:

  constructor(size, ui) {
    this.ui = ui;
    this.ui.onClick = this.play.bind(this);
    this.cards = _.shuffle([..._.range(size), ..._.range(size)]);
    this.ui.drawCards(this.cards);
    this.revealedPositions = new Set();
    this.currentTurnPositions = [];
  }

הקוד המלא של שתי המחלקות נראה כך:

import _ from "lodash";

class MemoryGame {
  constructor(size, ui) {
    this.ui = ui;
    this.ui.onClick = this.play.bind(this);
    this.cards = _.shuffle([..._.range(size), ..._.range(size)]);
    this.ui.drawCards(this.cards);
    this.revealedPositions = new Set();
    this.currentTurnPositions = [];
  }

  play(cardPosition) {
    if (
      this.revealedPositions.has(cardPosition) ||
      this.currentTurnPositions.includes(cardPosition)
    ) {
      // card is already revealed
      return;
    }
    if (this.currentTurnPositions.length === 0) {
      this.revealFirstCard(cardPosition);
    } else if (this.currentTurnPositions.length === 1) {
      this.revealSecondCard(cardPosition);
    } else {
      alert(this.currentTurnPositions);
    }
  }

  revealFirstCard(cardPosition) {
    this.currentTurnPositions.push(cardPosition);
    this.ui.revealCard(cardPosition);
  }

  revealSecondCard(cardPosition) {
    this.currentTurnPositions.push(cardPosition);
    this.ui.revealCard(cardPosition);

    if (
      // found a pair :)
      this.cards[this.currentTurnPositions[0]] ===
      this.cards[this.currentTurnPositions[1]]
    ) {
      for (let idx of this.currentTurnPositions) {
        this.revealedPositions.add(idx);
      }
    } else {
      // no pair, hide them back
      this.ui.hideAfter(2, ...this.currentTurnPositions);
    }
    this.currentTurnPositions = [];
  }
}

class MemoryGameUi {
  constructor(container) {
    this.root = container;
    this.onClick = () => {};

    this.root.addEventListener("click", (e) => {
      const card = e.target;
      if (!card.classList.contains("card")) {
        return;
      }

      const position = card.dataset.position;
      this.onClick(position);
    });
  }

  hideAfter(seconds, ...cardPositions) {
    setTimeout(() => {
      for (let pos of cardPositions) {
        this.cardElements[pos].classList.add("hidden");
      }
    }, seconds * 1000);
  }

  revealCard(position) {
    this.cardElements[position].classList.remove("hidden");
  }

  drawCards(cards) {
    const container = document.createDocumentFragment();
    this.cardElements = cards.map(this.drawHiddenCard.bind(this));

    this.cardElements.forEach((el) => {
      container.appendChild(el);
    });

    this.root.innerHTML = "";
    this.root.appendChild(container);
  }

  drawHiddenCard(cardValue, position) {
    const card = document.createElement("div");
    card.classList.add("card");
    card.textContent = cardValue;
    card.classList.add("hidden");
    card.dataset.position = position;
    return card;
  }
}

const ui = new MemoryGameUi(document.querySelector("#app"));
const game = new MemoryGame(6, ui);

ואתם יכולים לשחק במשחק בקודסנדבוקס בקישור הזה:

https://codesandbox.io/s/xenodochial-flower-ezuj7