Connect4 — 03: מתי המשחק מסתיים?


בונים מצב עובד אחד מתוך המסלול

מפת המסלול

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

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

השינויים לפי סדר העבודה

התחילו במצב שעבד בסוף הפרק הקודם. קובץ חדש מוצג במלואו; בקובץ קיים מופיעים רק האזורים שמשתנים. בקטעי diff מסירים את שורות -, מוסיפים את שורות +, ומשאירים את שורות ההקשר. הסימנים עצמם אינם חלק מקוד Java או XML. אין למחוק קוד אחר שאינו מוצג.

GameState.java

app > kotlin+java > com.example.connect4 > core > GameState.java

מרחיבים את הצילום במנצח, דגל סיום, היסטוריית עמודות ותאי הניצחון. Cell הוא זוג קואורדינטות לקריאה בלבד. הרשימות מוחזרות כבלתי ניתנות לשינוי. equals ו־hashCode משווים את כל תוכן המצב; בהמשך נשתמש בכך כדי לפסול תשובה שחושבה עבור מצב ישן.

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

 package com.example.connect4.core;
 
-/** A detached board snapshot. Only GameEngine changes the live game. */
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/** Immutable, detached snapshot of a Connect Four position. */
 public final class GameState {
     public static final int ROWS = 6;
     public static final int COLUMNS = 7;
     public static final int EMPTY = 0;
     public static final int RED = 1;
     public static final int YELLOW = 2;
+
     private final int[][] board;
     private final int currentPlayer;
+    private final int winner;
+    private final boolean terminal;
+    private final String moves;
+    private final List<Cell> winningCells;
 
-    /** Copies every row so future engine changes cannot change this snapshot. */
-    GameState(int[][] source, int currentPlayer) {
-        board = new int[ROWS][COLUMNS];
-        for (int row = 0; row < ROWS; row++) board[row] = source[row].clone();
+    GameState(
+            int[][] board,
+            int currentPlayer,
+            int winner,
+            boolean terminal,
+            String moves,
+            List<Cell> winningCells) {
+        this.board = copyBoard(board);
         this.currentPlayer = currentPlayer;
+        this.winner = winner;
+        this.terminal = terminal;
+        this.moves = Objects.requireNonNull(moves, "moves");
+        this.winningCells = Collections.unmodifiableList(new ArrayList<>(winningCells));
     }
 
-    /** Returns the occupant of a zero-based cell. */
-    public int cell(int row, int column) { return board[row][column]; }
+    /** Returns the occupant at a zero-based board coordinate. */
+    public int cell(int row, int column) {
+        checkCoordinates(row, column);
+        return board[row][column];
+    }
 
-    /** Returns whose turn comes next. */
-    public int currentPlayer() { return currentPlayer; }
+    /** Returns the player whose turn is next, even after a terminal move. */
+    public int currentPlayer() {
+        return currentPlayer;
+    }
+
+    /** Returns 0 when no player has won, including for an unfinished game or draw. */
+    public int winner() {
+        return winner;
+    }
+
+    /** Reports whether no more moves may be applied to this snapshot. */
+    public boolean isTerminal() {
+        return terminal;
+    }
+
+    /** Reports a terminal state with no winning player. */
+    public boolean isDraw() {
+        return terminal && winner == EMPTY;
+    }
+
+    /** Successful zero-based column actions, concatenated without separators. */
+    public String moves() {
+        return moves;
+    }
+
+    /** Returns the number of accepted moves represented by this state. */
+    public int moveCount() {
+        return moves.length();
+    }
+
+    /** Returns all cells participating in one or more winning four-in-a-row lines. */
+    public List<Cell> winningCells() {
+        return winningCells;
+    }
+
+    /** Legal actions in ascending-column order. Terminal positions have no actions. */
+    public List<GameAction> legalActions() {
+        if (terminal) {
+            return Collections.emptyList();
+        }
+        List<GameAction> actions = new ArrayList<>(COLUMNS);
+        for (int column = 0; column < COLUMNS; column++) {
+            if (board[0][column] == EMPTY) {
+                actions.add(new GameAction(column));
+            }
+        }
+        return Collections.unmodifiableList(actions);
+    }
+
+    /** Returns a deep copy so callers cannot mutate this immutable snapshot. */
+    public int[][] boardCopy() {
+        return copyBoard(board);
+    }
+
+    private static int[][] copyBoard(int[][] source) {
+        if (source == null || source.length != ROWS) {
+            throw new IllegalArgumentException("board must have 6 rows");
+        }
+        int[][] result = new int[ROWS][COLUMNS];
+        for (int row = 0; row < ROWS; row++) {
+            if (source[row] == null || source[row].length != COLUMNS) {
+                throw new IllegalArgumentException("board rows must have 7 columns");
+            }
+            result[row] = source[row].clone();
+        }
+        return result;
+    }
+
+    private static void checkCoordinates(int row, int column) {
+        if (row < 0 || row >= ROWS || column < 0 || column >= COLUMNS) {
+            throw new IndexOutOfBoundsException("cell outside 6x7 board");
+        }
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        }
+        if (!(other instanceof GameState)) {
+            return false;
+        }
+        GameState state = (GameState) other;
+        return currentPlayer == state.currentPlayer
+                && winner == state.winner
+                && terminal == state.terminal
+                && moves.equals(state.moves)
+                && winningCells.equals(state.winningCells)
+                && Arrays.deepEquals(board, state.board);
+    }
+
+    @Override
+    public int hashCode() {
+        int result = Arrays.deepHashCode(board);
+        result = 31 * result + Objects.hash(currentPlayer, winner, terminal, moves, winningCells);
+        return result;
+    }
+
+    /** Immutable board coordinate. */
+    public static final class Cell {
+        private final int row;
+        private final int column;
+
+        /** Creates a coordinate inside the standard 6-by-7 board. */
+        public Cell(int row, int column) {
+            checkCoordinates(row, column);
+            this.row = row;
+            this.column = column;
+        }
+
+        /** Returns the zero-based row, measured from the board's top edge. */
+        public int row() {
+            return row;
+        }
+
+        /** Returns the zero-based column, measured from the board's left edge. */
+        public int column() {
+            return column;
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            if (!(other instanceof Cell)) {
+                return false;
+            }
+            Cell cell = (Cell) other;
+            return row == cell.row && column == cell.column;
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(row, column);
+        }
+
+        @Override
+        public String toString() {
+            return "Cell{" + "row=" + row + ", column=" + column + '}';
+        }
+    }
 }

GameEngine.java

app > kotlin+java > com.example.connect4 > core > GameEngine.java

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

שימו לב: currentPlayer מתחלף גם אחרי המהלך המנצח. לכן מציגים את winner() בסיום, ולא את השחקן שהתור עבר אליו. terminal חוסם כל ניסיון לשחק לאחר תוצאה. הסימונים TWIN-ID הם הערות קישור של המורה למודל המסופק; אין בהם פעולת Java או משימת ML לתלמיד.

 package com.example.connect4.core;
 
-/** Owns gravity, alternating turns and the mutable board. */
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/** The authoritative mutable Connect Four rules engine. */
 public final class GameEngine {
-    private final int[][] board = new int[GameState.ROWS][GameState.COLUMNS];
-    private int currentPlayer = GameState.RED;
+    private final int[][] board;
+    private final StringBuilder moves;
+    private int currentPlayer;
+    private int winner;
+    private boolean terminal;
+    private List<GameState.Cell> winningCells;
 
-    /** Returns a detached copy for the screen. */
-    public GameState snapshot() { return new GameState(board, currentPlayer); }
+    /** Creates a new empty game with Red to move first. */
+    public GameEngine() {
+        board = new int[GameState.ROWS][GameState.COLUMNS];
+        moves = new StringBuilder();
+        currentPlayer = GameState.RED;
+        winner = GameState.EMPTY;
+        winningCells = new ArrayList<>();
+    }
 
-    /** Rejects invalid or full columns without changing the turn. */
+    /** Returns a detached immutable snapshot suitable for rendering or AI simulation. */
+    public GameState snapshot() {
+        return new GameState(
+                board,
+                currentPlayer,
+                winner,
+                terminal,
+                moves.toString(),
+                winningCells);
+    }
+
+    /**
+     * Applies a legal action and returns true. Invalid, full-column, and terminal
+     * actions return false without changing the game.
+     */
+    // TWIN-ID: CONNECT4.APPLY_MOVE (TWIN-PYTHON: ml/game_env.py::apply_move)
     public boolean apply(GameAction action) {
-        if (action == null) return false;
+        if (action == null || terminal) {
+            return false;
+        }
         int column = action.column();
-        if (column < 0 || column >= GameState.COLUMNS) return false;
+        if (column < 0 || column >= GameState.COLUMNS) {
+            return false;
+        }
+        int landingRow = findLandingRow(board, column);
+        if (landingRow < 0) {
+            return false;
+        }
+
+        int movingPlayer = currentPlayer;
+        board[landingRow][column] = movingPlayer;
+        moves.append(column);
+        currentPlayer = opponent(movingPlayer);
+
+        winningCells = findWinningCells(board, movingPlayer);
+        if (!winningCells.isEmpty()) {
+            winner = movingPlayer;
+            terminal = true;
+        } else if (moves.length() == GameState.ROWS * GameState.COLUMNS) {
+            // TWIN-ID: CONNECT4.TERMINAL_CHECK (TWIN-PYTHON: ml/game_env.py::is_terminal)
+            terminal = true;
+        }
+        return true;
+    }
+
+    private static int findLandingRow(int[][] board, int column) {
         for (int row = GameState.ROWS - 1; row >= 0; row--) {
             if (board[row][column] == GameState.EMPTY) {
-                board[row][column] = currentPlayer;
-                currentPlayer = currentPlayer == GameState.RED ? GameState.YELLOW : GameState.RED;
-                return true;
+                return row;
             }
         }
-        return false;
+        return -1;
+    }
+
+    private static int opponent(int player) {
+        return player == GameState.RED ? GameState.YELLOW : GameState.RED;
+    }
+
+    // TWIN-ID: CONNECT4.WIN_CHECK (TWIN-PYTHON: ml/game_env.py::winner)
+    private static List<GameState.Cell> findWinningCells(int[][] board, int player) {
+        int[][] directions = {{0, 1}, {1, 0}, {1, 1}, {1, -1}};
+        Set<GameState.Cell> result = new LinkedHashSet<>();
+        for (int[] direction : directions) {
+            int rowStep = direction[0];
+            int columnStep = direction[1];
+            for (int row = 0; row < GameState.ROWS; row++) {
+                for (int column = 0; column < GameState.COLUMNS; column++) {
+                    int endRow = row + 3 * rowStep;
+                    int endColumn = column + 3 * columnStep;
+                    if (endRow < 0 || endRow >= GameState.ROWS
+                            || endColumn < 0 || endColumn >= GameState.COLUMNS) {
+                        continue;
+                    }
+                    boolean connected = true;
+                    for (int offset = 0; offset < 4; offset++) {
+                        if (board[row + offset * rowStep][column + offset * columnStep]
+                                != player) {
+                            connected = false;
+                            break;
+                        }
+                    }
+                    if (connected) {
+                        for (int offset = 0; offset < 4; offset++) {
+                            result.add(new GameState.Cell(
+                                    row + offset * rowStep,
+                                    column + offset * columnStep));
+                        }
+                    }
+                }
+            }
+        }
+        return new ArrayList<>(result);
     }
 }

BoardView.java

app > kotlin+java > com.example.connect4 > ui > BoardView.java

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

                 paint.setColor(player == GameState.RED ? 0xffd32f2f
                         : player == GameState.YELLOW ? 0xffffc107 : 0xffeeeeee);
                 canvas.drawCircle((column + .5f) * size, (row + .5f) * size,
                         size * .38f, paint);
+                if (state != null) {
+                    for (GameState.Cell cell : state.winningCells()) {
+                        if (cell.row() == row && cell.column() == column) {
+                            paint.setStyle(Paint.Style.STROKE);
+                            paint.setStrokeWidth(size * .07f);
+                            paint.setColor(0xffffffff);
+                            canvas.drawCircle((column + .5f) * size, (row + .5f) * size,
+                                    size * .29f, paint);
+                            paint.setStyle(Paint.Style.FILL);
+                        }
+                    }
+                }
             }
         }
     }

MainActivity.java

app > kotlin+java > com.example.connect4 > MainActivity.java

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

     /** Presents the model; drawing does not change the game. */
     private void render() {
         GameState state = engine.snapshot();
         binding.board.show(state);
-        binding.status.setText(state.currentPlayer() == GameState.RED ? "Red to play" : "Yellow to play");
+        if (state.isDraw()) binding.status.setText("Draw — board full");
+        else if (state.isTerminal()) binding.status.setText(state.winner() == GameState.RED ? "Red wins!" : "Yellow wins!");
+        else binding.status.setText(state.currentPlayer() == GameState.RED ? "Red to play" : "Yellow to play");
     }
 }

מריצים ובודקים

  1. הקלידו בנגיעות את העמודות 0101010 (מספור פנימי 0–6): אדום מנצח בעמודה השמאלית.
  2. נסו עוד נגיעה: הלוח והתוצאה אינם משתנים. New game מחזיר לוח ריק.
  3. בדקו גם ניצחון אופקי ושני אלכסונים. דוגמה אופקית: 342211650.
  4. בדיקת המורה כוללת תיקו מלא (051434605223061623660540405114233565141232) וניצחון בדיסקית האחרונה, כדי להבחין בין שני המצבים.

בדיקת הבנה

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