• בלוג
  • סיור קצר ב XState לצורך פיתוח משחק סיימון

סיור קצר ב XState לצורך פיתוח משחק סיימון

30/03/2021

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

  1. הקומפוננטה יכולה להיות במהלך הצגת רצף הצבעים האקראי שהמחשב בחר

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

  3. אפשרות שניה במהלך הצגת צבע מהרצף היא שאף אחד לא נוגע ואז צריך להמשיך להציג את הצבע הבא

  4. אבל אם אנחנו מציגים את הצבע האחרון ברצף צריך לעצור ולחכות שמשתמש יתחיל להקליד את הרצף שלו או ילחץ על כפתור "הצג שוב"

  5. בנוסף במהלך הצגת הצבעים מהרצף האקראי ובין כל שני צבעים צריך לכבות את כל המנורות לכמה מאיות שניה כדי לתת למשתמש להבדיל בין הצבעים.

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

1. עקרונות מרכזיים של xstate שהשתמשתי בהם בכתיבת המשחק

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

ב xstate מעברי מצבים הם תוצאה של "אירועים". האירועים יכולים להגיע מבחוץ או כתוצאה מ"פעולות" שמכונת המצבים עצמה מייצרת.

לדוגמה בואו נדבר על ניגון רצף הצבעים האקראי-

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

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

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

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

2. מימוש ניגון רצף הצבעים עם הפסקות בין הצבעים

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

const { actions, createMachine, assign } = require("xstate");

// Stateless machine definition
// machine.transition(...) is a pure function used by the interpreter.
export default createMachine({
  id: "simon",
  initial: "autoplay",
  context: {
    sequence: [1, 1, 2, 3],
    currentIndex: 0
  },
  on: {
    PLAY: {
      target: "autoplay",
      actions: [
        actions.cancel("autoplayTimer"),
        assign({ currentIndex: (context, event) => 0 })
      ]
    },
    FLASH: {
      target: "flash",
      actions: [
        actions.cancel("autoplayTimer"),
        assign({
          currentIndex: (context, event) => 0
        })
      ]
    }
  },
  states: {
    autoplay: {
      entry: actions.send("TICK", { delay: 1000, id: "autoplayTimer" }),
      on: {
        TICK: {
          target: "pause",
          actions: assign({
            currentIndex: (context, event) => context.currentIndex + 1
          })
        }
      },
      always: [
        {
          target: "idle",
          cond: ({ currentIndex, sequence }) => currentIndex >= sequence.length
        }
      ]
    },
    pause: {
      entry: actions.send("TICK", { delay: 200, id: "autoplayTimer" }),
      on: {
        TICK: {
          target: "autoplay"
        }
      }
    },
    idle: {
      entry: actions.cancel("autoplayTimer")
    },
    flash: {
      entry: actions.send("TICK", { delay: 200 }),
      on: {
        TICK: "idle"
      }
    }
  }
});

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

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

  id: "simon",
  initial: "autoplay",
  context: {
    sequence: [1, 1, 2, 3],
    currentIndex: 0
  },

המפתח states מתאר את כל המצבים והמעברים ביניהם. שימו לב למפתחות בלבד בתוכו: autoplay, pause, idle, flash. כל מה שיש לנו במשחק בעצם מסתכם ב-4 מצבים.

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

    idle: {
      entry: actions.cancel("autoplayTimer")
    },

המצבים היותר מעניינים הם autoplay ו pause. מצבים אלה מתארים את מנגנון הצגת רצף הצבעים, כאשר במצב autoplay תהיה לנו מנורה דולקת ב GUI, ובמצב pause כל הנורות יהיו כבויות לצורך הפסקה קצרה בין הצגת הצבעים. האירוע TICK הוא שמעביר בין המצבים:

    autoplay: {
      entry: actions.send("TICK", { delay: 1000, id: "autoplayTimer" }),
      on: {
        TICK: {
          target: "pause",
          actions: assign({
            currentIndex: (context, event) => context.currentIndex + 1
          })
        }
      },
      always: [
        {
          target: "idle",
          cond: ({ currentIndex, sequence }) => currentIndex >= sequence.length
        }
      ]
    },
    pause: {
      entry: actions.send("TICK", { delay: 200, id: "autoplayTimer" }),
      on: {
        TICK: {
          target: "autoplay"
        }
      }
    },

ובואו נקרא את זה לאט:

  1. במצב autoplay אם התקבל אירוע TICK יש לעבור למצב pause, וגם להפעיל "פעולה פנימית" של עדכון הזיכרון - קידום האינדקס ברצף ב-1.

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

  3. במצב pause אם התקבל אירוע TICK יש לעבור למצב autoplay.

נשארנו עם המפתח entry: מפתח זה מתאר פעולות פנימיות שהמכונה מייצרת בכניסה למצב המתאים. בקוד שלי בכניסה למצב autoplay אני מתחיל שעון של שניה אחריו יישלח בצורה אוטומטית אירוע TICK; בכניסה למצב pause שוב יופעל שעון של 200 מילי שניות בסיומו יישלח אירוע TICK. הפעולות הפנימיות של המכונה מאפשרות לי לטפל בקוד שקשור לזמן בצורה נוחה.

3. מימוש פידבק למשתמש אחרי לחיצה

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

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

  on: {
    PLAY: {
      target: "autoplay",
      actions: [
        actions.cancel("autoplayTimer"),
        assign({ currentIndex: (context, event) => 0 })
      ]
    },
    FLASH: {
      target: "flash",
      actions: [
        actions.cancel("autoplayTimer"),
        assign({
          currentIndex: (context, event) => 0
        })
      ]
    }
  },

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

  1. אם התקבל אירוע PLAY, לא משנה באיזה מצב היינו, יש לעבור למצב autoplay, לבטל את השעון autoplayTimer ולאפס את משתנה currentIndex.

  2. אם התקבל אירוע FLASH, לא משנה באיזה מצב היינו, יש לעבור למצב flash ושוב לבטל את השעון autoplayTimer ולאפס את משתנה currentIndex.

ולסיום מצב flash עצמו נראה כך:

    flash: {
      entry: actions.send("TICK", { delay: 200 }),
      on: {
        TICK: "idle"
      }
    }

4. חיבור לריאקט

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

send("PLAY");

את המשתנים sequence ו currentIndex מתוך הזיכרון של המכונה אני קורא דרך משתנה context של הסטייט הנוכחי:

  const [current, send] = useMachine(machine);
  const { sequence, currentIndex } = current.context;

שם הסטייט הנוכחי הוא current.value והאירוע שבגללו הגעתי לסטייט הנוכחי הוא current.event. אתם מוזמנים לראות את הקוד המלא של החיבור לריאקט בקודסנדבוקס בקישור https://codesandbox.io/s/wonderful-worker-pyozo.

5. רגע, זה לא דומה קצת ל redux?

בעבודה עם xstate יש הרבה דברים שמזכירים את Redux:

  1. הלוגיקה נשמרת בקובץ נפרד ואנחנו ניגשים אליה דרך Hook של הספריה, מה שמשחרר את ריאקט להתמקד רק בקוד התצוגה

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

  3. אנחנו מאוד מוגבלים בתקשורת עם הלוגיקה ויכולים רק "לשלוח" אירועים כדי לגרום למכונה לעשות דברים.

  4. אירועים מסוימים שנשלח יגרמו ל Side Effects (כלומר ליצירת "פעולות"). בקוד הדוגמה שלנו אלה היו שעונים ובמקומות מתוחכמים יותר נוכל למצוא בקשות רשת. ב Redux היינו משתמשים ב Middlewares כדי להגדיר איזה אירועים שנכנסים יפעילו Side Effects.

  5. עדכון המידע קורה רק דרך הכלים של xstate, והמידע כולו הוא Immutable, בדומה ל Redux שם עדכון המידע קורה באמצעות החזרת סטייט מעודכן מה Reducer.

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