ניסוי ריילס: משחק איקס עיגול

10/10/2024

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

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

1. הכלים שריילס מספק

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

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

ActiveRecord::Schema[8.0].define(version: 2024_10_09_113739) do
  create_table "game_moves", force: :cascade do |t|
    t.integer "game_id", null: false
    t.integer "turn"
    t.string "player"
    t.integer "row"
    t.integer "column"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["game_id"], name: "index_game_moves_on_game_id"
  end

  create_table "games", force: :cascade do |t|
    t.string "name"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

  add_foreign_key "game_moves", "games"
end

נתתי לריילס ליצור קונטרולרים ומודלים לשתי הטבלאות עם rails g scaffold ויצאתי לדרך.

2. פיתוח מתודות עזר במודל

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

class Game < ApplicationRecord
  has_many :game_moves, inverse_of: :game

  def at(row, column)
    start = [['.', '.', '.'],
             ['.', '.', '.'],
            ['.', '.', '.']]
    board = game_moves.reduce(start) do |g, move|
      g.tap { |g| g[move.row][move.column] = move.player }
    end
    board[row][column]
  end

  def next_player
    ['X', 'O'][game_moves.count % 2]
  end
end

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

3. הצגת לוח המשחק

עיקר הקוד של הניסוי הוא בקובץ app/views/games/_game.html.erb. זה תוכן הקובץ:

<div id="<%= dom_id game %>">
  <p>Now playing: <%= @game.next_player %></p>
  <table>
    <tbody>
      <% (0..2).each do |row_index| %>
      <tr>
        <% (0..2).each do |column_index| %>
          <% text = @game.at(row_index, column_index) %>    
          <td
            class="<%= class_names(playable: text == '.') %>">
            <%= form_for :game_move, url: play_game_path(@game.id) do |f| %>
              <%= f.hidden_field :row, {value: row_index} %>
              <%= f.hidden_field :column, {value: column_index} %>
              <%= f.hidden_field :game_id, {value: @game.id} %>
              <%= f.hidden_field :player, {value: @game.next_player } %>
              <%= f.submit text %>    
            <% end %>            
          </td>
        <% end %>
      </tr>
      <% end %>
    </tbody>
  </table>
</div>

הקוד הוא Template מסוג ERB שמציירת לוח משחק. אנשי ריאקט יכולים לחשוב על זה כ JSX של Server Component, כלומר הקוד רץ בשרת, מייצר HTML ושולח לדפדפן את ה HTML שיצא. לתבנית יש גישה מלאה לבסיס הנתונים והוא בסך הכל קורא שוב ושוב לפונקציה at כדי לחשב ולצייר את הלוח. החלק המעניין בקוד זה הטופס:

  1. לכל תא בלוח המשחק יש טופס משלו.

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

  3. כפתור Submit של הטופס פונה לנתיב מיוחד שבניתי בשרת לטפל במהלכים במשחק.

4. טיפול בהגשת הטופס

החלק האחרון של הניסוי מוגדר בקובץ app/controllers/games_controller.rb ובדיעבד אני חושב שעדיף היה לשים אותו ב game_moves_controller אבל כמו שכתבתי בתחילת הפוסט אנחנו באילוצי זמן היום. זה הקוד שהוספתי לשם:

  def play
    GameMove.create!(game_move_params)
    respond_to do |format|
      format.turbo_stream { render turbo_stream: turbo_stream.replace(@game) }
      format.html         { redirect_to show_game_url(@game.id) }
    end
  end

  def game_move_params
    params.expect(game_move: [:row, :column, :player, :game_id])
  end

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

format.turbo_stream { render turbo_stream: turbo_stream.replace(@game) }

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

5. מסקנות ותוכנית להמשך

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

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

קוד הניסוי המלא נמצא בגיטהאב בקישור: https://github.com/ynonp/rails-demo-tic-tac-toe