• בלוג
  • פיתוח משחק בול פגיעה ב React ו Redux

פיתוח משחק בול פגיעה ב React ו Redux

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

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

1. התחילו עם מצב היישום

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

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

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

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

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

ברדוקס הקוד הבא מגדיר את המצב הראשוני של היישום:

const initialState = Immutable.fromJS({
  guesses: [],
  currentGuess: [' ', ' ', ' ', ' '],
  focusedDigit: 0,
  secret: '1984',
});

2. המשיכו להגדרת הפעולות

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

function setDigit(state, val) {
  const guessSize = state.get('currentGuess').size;
  const idx = state.get('focusedDigit');

  return state.update('currentGuess', (arr) => arr.set(idx, val)).
    update('focusedDigit', (n) => (n+1)%guessSize);
}

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

function play(state) {
  return state.update('guesses', (guesses) => guesses.unshift(state.get('currentGuess').join(''))).
    set('currentGuess', initialState.get('currentGuess')).
    set('focusedDigit', 0);
}

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

function setFocus(state, index) {
  return state.set('focusedDigit', index);
}

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

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

3. נמשיך להגדרת ה Reducer

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

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

const actions = {
  setDigit: (val) => ({ type: '@@setDigit', payload: { val }}),
  setFocus: (idx) => ({ type: '@@setFocus', payload: idx }),
  play:     ()    => ({ type: '@@play' }),
  newGame:  ()    => ({ type: '@@newGame' }),
};

כל פונקציה מחזירה אוביקט עם שדות type ו payload לפי תוכן הפונקציה (הכתיב משתמש בפונקציות חץ של ES6).

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

function reducer(state = initialState, action) {
  switch(action.type) {
    case '@@setDigit': 
      return setDigit(state, action.payload.val);

    case '@@play':
      return play(state);

    case '@@newGame':
      return initialState;

    case '@@setFocus':
      return setFocus(state, action.payload);

    default:
      return state;
  }
}

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

4. פיתוח ממשק משתמש מבוסס ריאקט למשחק

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

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

var App = connect(mapStateToProps)(function(props) {
  const game = new BPGame(props.secret);

  return (<div>      
      {game.isWinner(props.guesses.first()) ? 
        <div>
          {props.secret.split('').map((d, i) => (
          <div className="digit" style={{background: 'green'}}>{d}</div>
          ))}
        </div>
        :
        <div>
        <div className="digit">?</div>
        <div className="digit">?</div>
        <div className="digit">?</div>
        <div className="digit">?</div>      
      </div>
      }
      <button onClick={() => props.dispatch(actions.newGame())}>Restart</button>
      <hr />
      <div className="currentGuess" refs="currentGuess">
        <InputPanel 
          currentGuess={props.currentGuess} 
          focusedDigit={props.focusedDigit}
          dispatch={props.dispatch} />
        <button onClick={() => props.dispatch(actions.play())}>Check</button>
      </div>
      <hr />
      <div className="pastGuesses">
        {props.guesses.map((guess, idx) => (
          <div className="row" key={idx}>
            {guess.split('').map((d, j) => (
              <div className="digit" key={j} style={game.getStyleFor(guess, j)}>{d}</div>
            ))}
          </div>
        ))}
      </div>
      </div>);
});

הפונקציה mapStateToProps אחראית על מיפוי השדות הרלוונטים מאוביקט המידע של היישום לפקד הספציפי. היא מועברת לפונקציה connect של ספריית react-redux בעת יצירת הפקד. במשחק מימוש הפונקציה נראה כך:

function mapStateToProps(state) {
  return {
    guesses: state.get('guesses'),
    currentGuess: state.get('currentGuess'),
    focusedDigit: state.get('focusedDigit'),
    numRounds: state.get('numRounds'),
    secret: state.get('secret'),
  };
}

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

5. הפקד InputPanel

הפקד השני במשחק הוא יותר מעניין שכן הוא כבר כולל פונקציות Lifecycle של ריאקט. הסיבה שהפעם עלינו לטפל גם באירועי פוקוס, ואירועים אלו לא ניתן לנהל רק באמצעות פונקציית render. בריאקט כדי לתת פוקוס לאלמנט יש להגיע אליו באמצעות refs ולהפעיל את הפונקציה focus של ה DOM Element המתאים. בנוסף, ברגע שפוקוס משתנה יש לעדכן את ה Redux Store שלנו בערך החדש, כדי שבפעם הבאה שמישהו יקליד סיפרה היא תכתב למקום הנכון.

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

const InputPanel = React.createClass({

  componentDidUpdate() {
    const digits = this.refs.el.querySelectorAll('.digit');
    digits[this.props.focusedDigit].focus();
  },

  render() {
    const { dispatch, currentGuess } = this.props;

    return (<div ref="el">
      {currentGuess.map((d,i) => (
        <div onKeyPress={(e) => dispatch(actions.setDigit(String.fromCharCode(e.charCode)))} 
          tabIndex={1} 
          onFocus={(e) => dispatch(actions.setFocus(i))}
          className="digit" 
          key={i}>{d}</div>
      ))}
      </div>);
  }
});

6. סיכום ומחשבות להמשך

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

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

דרך אחת להתמודד עם הזעזוע היא לפתוח Reducer נוסף עבור הפקד (כך עובד Redux Form). חשוב גם לזכור את ההבדלים בין תכנות מונחה עצמים לבין תכנות פונקציונאלי. בתכנות מונחה עצמים אנו מחברים בין מידע לפעולות וקוראים לחיבור אוביקט; בתכנות פונקציונאלי אנו מפרידים בין המידע לפעולות ומעדיפים פונקציות טהורות המשפיעות רק על מידע שהועבר אליהן כפרמטרים.

את קוד המשחק המלא תוכלו למצוא בקודפן בקישור:
http://codepen.io/ynonp/pen/yJPGqK