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

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

01/03/2020

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

1. שינוי הלוגיקה כך שתתאים ל MobX

השינויים בלוגיקה בשביל להתאים ל MobX הם לא גדולים אבל כן דורשים קצת מחשבה ודיוק:

  1. נצטרך להוסיף Observable ליד כל שדה מידע שעשוי להשפיע על ממשק המשתמש.

  2. נצטרך לעטוף כל פונקציה שעשויה להשפיע על ממשק המשתמש ב computedFn.

עבור שדות המידע המשחק די פשוט. לדוגמא המחלקה BoardSquare תיראה כך:


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

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

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

עבור פונקציות יש טריק קטן: כל פונקציה שמחזירה ערך מחושב (כלומר ערך שמושפע ממשתנים שהם Observable) צריכה להיות מוגדרת בתור computedFn. הפונקציה computedFn מגיעה מספריה שנקראת mobx-utils ויש ליבא אותה בנפרד כך:

import { computedFn } from "mobx-utils";

טייפסקריפט בתורו לא ממש מזהה מה סוג המשתנה this בתוך פונקציית computedFn ולכן אני משתמש בכתיב הקצת מוזר הבא:

export class BoardSquare {
    // ...
    repr = computedFn(function repr(this: BoardSquare) {
        if (this.revealed) {
            return this.item.repr();
        } else {
            return " ";
        }
    });
}

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

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

import {action, computed, observable} from "mobx";
import { computedFn } from "mobx-utils";
import _ from "lodash";

type Point = [number, number];

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

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

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

    repr = computedFn(function repr(this: BoardSquare) {
        if (this.revealed) {
            return this.item.repr();
        } else {
            return " ";
        }

    });
}

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

    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));
            }
        }
    }

    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;
        }
    }

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

        square.bomb();
    }

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

    repr() {
        let res = "";
        for (let i=0; i < this.rowCount; i++) {
            for (let j=0; j < this.columnCount; j++) {
                let square = this.data.get(`${i},${j}`);
                if (square == null) {
                    throw new Error("Invalid Board");
                }
                res += square.repr;
            }
            res += "\n";
        }
        return res;
    }
}

class Sea {
    hit(id: number) {}

    repr() {
        return "0";
    }
}

abstract class Submarine extends Sea {
    size: number;
    @observable bombed: Set<number> = new Set();
    @observable sank: boolean = false;

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

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

    hit(id: number) {
        super.hit(id);
        this.bombed.add(id);
        if (this.bombed.size === this.size) {
            this.sank = true;
        }
    }

    repr() {
        if (this.sank) {
            return "X";
        } else {
            return "/";
        }
    }
}

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

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

const b = new Board(10, 10);
b.addSubmarine(new HorizontalSubmarine(5), 0, 0);
b.addSubmarine(new VerticalSubmarine(3), 2, 5);
b.addSubmarine(new HorizontalSubmarine(2), 6, 0);
export default b;

2. פיתוח ממשק המשתמש

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

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

const Square = observer(function Square({ item }: { item?: BoardSquare }) {
    if (!item) {
        throw new Error("Item cannot be null");
    }

    function handleClick() {
        item?.bomb();
    }

    return (
        <td onClick={handleClick}>
            {item.repr()}
        </td>
    );
});

והלוח כולו בסך הכל יוצר את המשבצות ומחבר אותן לאותה Square באופן הבא:

const SubmarineGame = observer(function SubmarineGame() {
    return (
        <div>
            <table>
                <tbody>
                {_.range(board.rowCount).map(rowIndex => (
                    <tr key={rowIndex}>
                        {_.range(board.columnCount).map(colIndex => (
                          <Square key={colIndex} item={board.cellAt([rowIndex, colIndex])} />
                        ))}
                    </tr>
                ))}
                </tbody>
            </table>
        </div>
    );
});

אם מעניין אתכם הפרויקט שלי במלואו זמין בגיטהאב בקישור הזה: https://github.com/ynonp/mobx-submarines-demo.

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

אחרי שתחברו את הכל בסביבת הפיתוח שלכם הנה כמה רעיונות ששווה לנסות:

  1. כתבו בדיקה אוטומטית מסודרת ב Jest על המשחק שלנו, רק שהפעם ממש תלך דרך פקדי ריאקט ותלחץ על הכפתורים.

  2. הוסיפו עיצוב יפה יותר עם תמונות ואנימציות.

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

סיימתם ורוצים לשתף? לינק לגיטהאב בתגובות יעבוד מעולה.