בואו נכתוב וורדל רק בשביל לאכול את הלב

03/02/2022

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

1. חוקי המשחק

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

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

2. קומפוננטת עזר ReadOnlyWordInput

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

function ReadOnlyWordInput(props) {
  const { word, correctWord } = props;

  function classes(letterIndex) {
    let res = "letter";
    if (word.chart(letterIndex) === correctWord.charAt(letterIndex)) {
      res += " correct";
    } else if (correctWord.includes(word.charAt(letterIndex))) {
      res += " misplaced";
    }
    return res;
  }

  return (
    <div>
      {_.range(5).map((i) => (
        <div className={classes(i)} key={i}>
          {word.charAt(i)}
        </div>
      ))}
    </div>
  );
}

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

3. קומפוננטת עזר WordInput

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

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

function replaceAt(str, index, replacement) {
  return (
    str.padEnd(5).substr(0, index) +
    replacement +
    str.substr(index + replacement.length)
  );
}

מחרוזות ב JavaScript הן Immutable, ולכן "לכתוב" לאינדקס במחרוזת אומר לייצר מחרוזת חדשה שבה רק האות שאליה אנחנו כותבים תהיה שונה. השימוש ב padEnd מתמודד עם מצב שמנסים לכתוב למחרוזת קצרה יותר מהאינדקס אליה ניסינו לכתוב.

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

function WordInput(props) {
  const { onSubmit } = props;
  const [word, setWord] = useState("");
  const [letter, setLetter] = useState(0);
  const el = useRef(null);

  useEffect(() => {
    el.current.querySelectorAll(".letter")[letter].focus();
  }, [letter]);

  return (
    <div ref={el}>
      {_.range(5).map((i) => (
        <input
          className="letter"
          key={i}
          onFocus={() => setLetter(i)}
          onKeyDown={(e) => {
            if (e.key === "Enter") {
              // ENTER Key
              return onSubmit(word);
            } else if (e.key === "Backspace") {
              setLetter((l) => Math.max(l - 1, 0));
              setWord((word) => replaceAt(word, i - 1, " "));
            } else if (e.key.match(/^[a-zA-Z]$/)) {
              setWord((word) => replaceAt(word, i, e.key.toLowerCase()));
              setLetter((l) => Math.min(l + 1, 4));
            }
          }}
          value={word.charAt(i)}
          pattern="[a-z]"
        />
      ))}
    </div>
  );
}

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

4. קומפוננטה ראשית App

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

export default function App() {
  const [selectedWord, setSelectedWord] = useState(selectRandomWord());
  const [guesses, setGuesses] = useState([]);

  function submit(word) {
    setGuesses([word, ...guesses]);
  }

  return (
    <div className="App">
      <WordInput key={guesses.length} onSubmit={submit} />

      {guesses.map((w, i) => (
        <ReadOnlyWordInput word={w} correctWord={selectedWord} key={i} />
      ))}
    </div>
  );
}

5. רגע רגע, מאיפה המילים?

שמתם לב ש App קוראת לפונקציה selectRandomWord כדי לקבל מילה אקראית. בואו נראה איך זה עובד:

import _ from "lodash";
import { words } from "popular-english-words";


function selectRandomWord() {
  return _.sample(words.getMostPopularLength(2000, 5));
}

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

אתם מוזמנים לחטט בקוד המלא כולל ה CSS בקודסנדבוקס בקישור https://codesandbox.io/s/white-tree-67euq

או למי מכם שקורא מהאתר גם בגירסה המוטמעת: