Connect4 — 02: דיסקיות, כוח כבידה ותורות


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

מפת המסלול

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

מפרידים בין שלושה מושגים: GameAction היא בקשה להכניס לעמודה; GameEngine מחזיק את הלוח המשתנה ומחליט אם הבקשה מותרת; GameState הוא צילום של המצב לציור. ה־View אינו בעל המערך החי.

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

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

GameAction.java

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

צרו package בשם core ובו את המחלקה. הפעולה מכילה רק עמודה: השורה נקבעת על ידי כוח הכבידה, והשחקן נקבע על ידי התור. equals משווה תוכן ולא כתובת אובייקט; נשתמש באותו חוזה כשנשווה פעולות מותרות בפרק המחשב.

package com.example.connect4.core;

import java.util.Objects;

/** One Connect Four decision: the zero-based column where a piece is dropped. */
public final class GameAction {
    private final int column;

    /** Stores a proposed column; the engine decides whether it is legal. */
    public GameAction(int column) {
        this.column = column;
    }

    /** Returns the proposed zero-based column. */
    public int column() {
        return column;
    }

    @Override
    public boolean equals(Object other) {
        return other instanceof GameAction && column == ((GameAction) other).column;
    }

    @Override
    public int hashCode() {
        return Objects.hash(column);
    }

    @Override
    public String toString() {
        return "GameAction{" + "column=" + column + '}';
    }
}

GameState.java

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

הבנאי מעתיק כל שורה בנפרד. העתקת המערך החיצוני בלבד הייתה משאירה את השורות משותפות. הבנאי ללא public: רק קוד בחבילת core יוצר צילום מצב; ה־Activity מקבל אותו מהמנוע.

package com.example.connect4.core;

/** A detached board snapshot. Only GameEngine changes the live game. */
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;

    /** 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();
        this.currentPlayer = currentPlayer;
    }

    /** Returns the occupant of a zero-based cell. */
    public int cell(int row, int column) { return board[row][column]; }

    /** Returns whose turn comes next. */
    public int currentPlayer() { return currentPlayer; }
}

GameEngine.java

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

הלולאה מתחילה בשורה 5 ועולה לכיוון 0. מחליפים תור רק בתוך הענף שמצא תא פנוי. אם העמודה אינה קיימת או מלאה, מחזירים false ללא שינוי. snapshot() יוצר עותק לקריאה, ולא מחזיר את המערך החי.

package com.example.connect4.core;

/** Owns gravity, alternating turns and the mutable board. */
public final class GameEngine {
    private final int[][] board = new int[GameState.ROWS][GameState.COLUMNS];
    private int currentPlayer = GameState.RED;

    /** Returns a detached copy for the screen. */
    public GameState snapshot() { return new GameState(board, currentPlayer); }

    /** Rejects invalid or full columns without changing the turn. */
    public boolean apply(GameAction action) {
        if (action == null) return false;
        int column = action.column();
        if (column < 0 || column >= GameState.COLUMNS) return false;
        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 false;
    }
}

BoardView.java

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

הלוח מקבל צילום חדש דרך show. invalidate() מבקש מ־Android לצייר מחדש בהזדמנות הקרובה; הוא אינו קורא ישירות ל־onDraw. לכל תא בוחרים צבע לפי הנתון שבצילום.

 package com.example.connect4.ui;
 
+import com.example.connect4.core.GameState;
 import android.content.Context;
 import android.graphics.Canvas;
 import android.graphics.Paint;
 import android.util.AttributeSet;
 import android.view.View;
 
 /** Draws a six-by-seven board and reports column taps to its controller. */
 public final class BoardView extends View {
-    private static final int ROWS = 6;
-    private static final int COLUMNS = 7;
+    private static final int ROWS = GameState.ROWS;
+    private static final int COLUMNS = GameState.COLUMNS;
+    private GameState state;
     private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
     private ColumnListener listener;
 
     /** Receives a zero-based column; the View does not apply game rules. */
 
     /** Connects the Activity's response to a board tap. */
     public void setColumnListener(ColumnListener listener) { this.listener = listener; }
 
+    /** Replaces the snapshot and requests a redraw. */
+    public void show(GameState state) {
+        this.state = state;
+        invalidate();
+    }
+
     @Override protected void onDraw(Canvas canvas) {
         super.onDraw(canvas);
         float size = cellSize();
         paint.setColor(0xff1565c0);
         canvas.drawRect(0, 0, size * COLUMNS, size * ROWS, paint);
         for (int row = 0; row < ROWS; row++) {
             for (int column = 0; column < COLUMNS; column++) {
-                paint.setColor(0xffeeeeee);
+                int player = state == null ? GameState.EMPTY : state.cell(row, column);
+                paint.setColor(player == GameState.RED ? 0xffd32f2f
+                        : player == GameState.YELLOW ? 0xffffc107 : 0xffeeeeee);
                 canvas.drawCircle((column + .5f) * size, (row + .5f) * size,
                         size * .38f, paint);
             }
         }

activity_main.xml

app > res > layout > activity_main.xml

מוסיפים רק כפתור מתחת ללוח. לא משנים את שאר אילוצי התבנית.

         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintTop_toBottomOf="@id/status" />
 
+    <Button
+        android:id="@+id/restart"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="New game"
+        app:layout_constraintTop_toBottomOf="@id/board"
+        app:layout_constraintStart_toStartOf="parent" />
+
 </androidx.constraintlayout.widget.ConstraintLayout>

MainActivity.java

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

חברו את שרשרת האירועים: נגיעה → GameAction → apply → render. ביצירת משחק חדש מחליפים את המנוע, ואז מציגים את צילום הלוח הריק. אין צורך לנקות בנפרד את ה־View.

 package com.example.connect4;
 
 import android.os.Bundle;
+import com.example.connect4.core.GameAction;
+import com.example.connect4.core.GameEngine;
+import com.example.connect4.core.GameState;
 
 import com.example.connect4.databinding.ActivityMainBinding;
 
 import androidx.activity.EdgeToEdge;
 import androidx.core.view.WindowInsetsCompat;
 
 public class MainActivity extends AppCompatActivity {
     private ActivityMainBinding binding;
+    private GameEngine engine = new GameEngine();
 
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
             Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
             v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
             return insets;
         });
-        binding.board.setColumnListener(column ->
-                binding.status.setText("Column " + (column + 1)));
+        binding.board.setColumnListener(column -> {
+            if (engine.apply(new GameAction(column))) render();
+        });
+        binding.restart.setOnClickListener(v -> {
+            engine = new GameEngine();
+            render();
+        });
+        render();
+    }
+    /** 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");
     }
 }

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

  1. הכניסו פעמיים לאותה עמודה: אדום בתחתית, צהוב מעליו, והתור שוב לאדום.
  2. מלאו עמודה בשש דיסקיות וגעו בה שוב: לא נוספת דיסקית ולא מתחלף התור.
  3. לחצו New game: הלוח מתרוקן ואדום מתחיל.
  4. כרגע סיבוב יוצר משחק חדש. נשנה זאת רק בפרק השמירה.

בדיקת הבנה

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