פיתוח משחק צוללות ב React, TypeScript ו MobX. חלק 1.

28/02/2020

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

בשביל שיהיה מעניין אני מפרסם את המדריך הזה ב-3 חלקים (היום, מחר ומחרתיים). אתם מוזמנים לקחת כל יום את הקוד של אותו יום, להקליד אצלכם ולראות שעובד, וכמובן בסוף כל חלק יהיה גם תרגיל הרחבה למיטיבי לכת. אני מקווה שהמשחק הזה ייתן לכם הבנה טובה יותר של MobX וגם הצצה לפינות בעבודה איתו ועם TypeScript.

1. תוכנית עבודה

את משחק הצוללות אני מתכנן לבנות ב-3 שלבים:

  1. בחלק הזה נבנה את הלוגיקה ב TypeScript בתור קוד מונחה עצמים רגיל לגמרי.

  2. בחלק השני נוסיף לקוד בדיקות.

  3. בחלק השלישי נוסיף לקוד ממשק משתמש ב React ו MobX.

2. בואו נכתוב את הלוגיקה

פיתחו פרויקט חדש בתיקיה חדשה באמצעות create-react-app באופן הבא:

$ npx create-react-app submarines-game --template typescript

בתוך תיקיית src שנוצרה פיתחו תיקיה חדשה בשם lib ובתוכה קובץ בשם submarines.tsx. זה יהיה קובץ המשחק שלנו בו נכתוב את הלוגיקה.

משחק צוללות מורכב מלוח משחק, כאשר כל משבצת בלוח יכולה להיות משבצת של ים או משבצת שהיא חלק מצוללת. לכן יהיה לי נוח לסדר את הלוח בתור מפה שהמפתח הוא אינדקס המשבצת (שורה ועמודה) והערך הוא אוביקט שמנהל את מה שקורה עם המשבצת. אני אקרא למחלקה שלו BoardSquare וככה נראה הקוד שלה:

class BoardSquare {
    item: Submarine|Sea;
    id: number;
    revealed: boolean = false;

    constructor(item: Sea|Submarine, id: number) {
        this.item = item;
        this.id = id;
    }

    bomb() {
        this.item.hit(this.id);
        this.revealed = true;
    }
}

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

נמשיך לבנות את הלוח ובתור התחלה אפשר פשוט לאתחל את המפה בלולאה ולמלא אותו במשבצות של ים:


class Board {
    data: Map<string, BoardSquare> = new Map();
    rowCount: number = 0;
    columnCount: number = 0;

    constructor(rowCount: number, columnCount: number) {
        this.rowCount = rowCount;
        this.columnCount = columnCount;

        for (let i=0; i < rowCount; i++) {
            for (let j=0; j < columnCount; j++) {
                this.data.set(`${i},${j}`, new BoardSquare(new Sea(), 0));
            }
        }
    }
}

טוב ומאיפה נביא ים אתם שואלים? אין בעיה הנה הקוד עבורו:

class Sea {
    hit(id: number) {}
}

עם ים החיים קלים כי כשפוגעים בים ממילא לא קורה כלום.

3. נוסיף את הצוללות

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

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

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

  1. הלוח ישאל את הצוללת איזה משבצות היא צריכה

  2. הלוח ירוץ על כל המשבצות האלה ויעדכן אותן שצריך לשמור בהן צוללת

  3. לכל משבצת ניתן מזהה. כשמישהו יפציץ את הצוללת נוכל להגיד לה שהיא הופצצה במשבצת 3, 4 או 7 שלה וכך היא תדע לנהל את הפגיעות.

הקוד המשותף לצוללת אופקית ואנכית נראה כך:

abstract class Submarine extends Sea {
    size: number;
    bombed: Set<number> = new Set();

    abstract getCoordinates(row: number, column: number): Point [];

    constructor(size: number) {
        super();
        this.size = size;
    }

    get sank() {
        return this.bombed.size === this.size;
    }

    hit(id: number) {
        super.hit(id);
        this.bombed.add(id);
    }
}

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

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

class HorizontalSubmarine extends Submarine {
    getCoordinates(row: number, column: number) {
        return _.range(this.size).map((i): Point => ([row, column + i]));
    }
}

ועבור צוללת אנכית נקבל:

class VerticalSubmarine extends Submarine {
    getCoordinates(row: number, column: number) {
        return _.range(this.size).map((i): Point => ([row + i, column]));
    }
}

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

type Point = [number, number];

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

עכשיו שאנחנו יודעים איך נראית צוללת נמשיך לפונקציה שמוסיפה אותה ללוח. בתוך המחלקה Board הוסיפו את הפונקציה הבאה:

    addSubmarine(submarine: Submarine, row: number, column: number) {
        const coords = submarine.getCoordinates(row, column);
        for (let i=0; i < coords.length; i++) {
            const square = this.cellAt(coords[i]);
            if (square == null) {
                throw new Error(`Invalid Coordinates: ${row}, ${column}`)
            }

            square.item  = submarine;
            square.id    = i;
        }
    }

הפונקציה משתמשת בפונקציית העזר שכתבתי cellAt שנראית פשוט כך:

    cellAt(pos: Point) {
        const [row, col] = pos;
        return this.data.get(`${row},${col}`);
    }

ואם אנחנו כבר בתוך הלוח שווה להוסיף קיצור דרך גם להפצצה עם הפונקציה:

    bomb(pos: Point) {
        const square = this.cellAt(pos);
        if (square == null) return;

        square.bomb();
    }

4. בדיקה בדפדפן

אחרי שתכניסו את כל הקוד לפרויקט תקבלו משהו שנראה כמו הפרויקט הבא בגיטהאב שלי: https://github.com/ynonp/mobx-submarines-demo/tree/f6059c1869edae34b541919dc99ef94a7a0b6976

בשביל לוודא שהכל עובד לכם כמו שצריך תוכלו להוסיף את הקוד הבא לסוף הקובץ:

const b = new Board(10, 10);

const s1 = new VerticalSubmarine(5);
const s2 = new HorizontalSubmarine(3);
b.addSubmarine(s1, 0, 0);
b.addSubmarine(s2, 5, 5);

b.bomb([5, 5]);
b.bomb([5, 6]);
b.bomb([5, 7]);

(window as any).board = b;

לטעון את הקובץ בדפדפן ואז לראות אם אתם מצליחים לפנות למשבצות השונות ולזהות את הספינה שטבעה ולנסות להטביע לבד את השניה דרך המשתנה הגלובלי board.

5. תרגילים להרחבה

יש לכם קוד צוללות שעובד, ועוד קצת זמן פנוי להיום? הנה כמה רעיונות נחמדים שתוכלו לעשות איתו:

  1. הוסיפו ל BoardSquare פונקציה בשם repr שמחזירה בתור מחרוזת את תוכן התא: עבור תא שעדיין לא נחשף היא תחזיר רווח, עבור תא עם ים היא תחזיר 0, עבור תא עם צוללת שנפגעה תחזיר / ועבור תא עם צוללת שטבעה תחזיר X.

  2. הוסיפו ל Board פונקציה בשם repr שמחזירה מחרוזת שמייצגת את הלוח. יהיו במחרוזת 10 שורות ובכל שורה 10 תווים שמייצגים בהתאמה מה כבר גילינו על הלוח.

  3. הוסיפו ל Board פונקציה randomize שמסדרת כמה צוללות באקראי על הלוח.

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