本章探討**井字遊戲(Tic-Tac-Toe)**的物件導向設計。我們將打造一個雙人輪流落子的互動平台,涵蓋遊戲盤面、玩家動作追蹤、輸贏判定,以及管理玩家評分的計分追蹤器。

遊戲規則:井字遊戲是經典的雙人遊戲,於 3x3 棋盤上進行。雙方各執「X」或「O」,輪流在空格落子。先讓三個相同符號橫、直、斜連成一線者獲勝;棋盤填滿仍無人連線則為平手

Tic-Tac-Toe

需求蒐集#

題目情境#

想像你和朋友坐下玩一局井字。你們各選符號(X 或 O)並輪流落子。每次落子後遊戲檢查是否有人獲勝或棋盤已滿(平手)。背景中,遊戲追蹤每次行動、更新計分板、維持玩家排名以供未來對戰。請設計這樣一個系統。

需求釐清對話#

Candidate:支援不同棋盤大小嗎?

Interviewer:不必,3x3 即可。

Candidate:如何處理勝負與平手?

Interviewer:系統需偵測連線並通知玩家結果(勝、和、進行中)。

Candidate:是否追蹤玩家評分?

Interviewer:是的,計分追蹤器依結果更新評分。

Candidate:無效落子如何處理?

Interviewer:若玩家試圖落於已佔用或無效位置,告知並要求重選。

需求整理#

功能性需求#

  • 3x3 棋盤上進行
  • 系統判定遊戲狀態:
    • (橫、直、斜三連線)
    • (棋盤滿且無人連線)
    • 進行中
  • 計分追蹤器紀錄玩家表現、依勝負更新評分,並支援排名查詢
  • 無效落子(如已佔用格)會被拒絕並回饋玩家

非功能性需求#

  • 介面直觀,提供清楚的回饋訊息
  • 系統應支援未來擴充(如不同棋盤大小或遊戲模式)而無需大幅改動架構

辨識核心物件#

  • Board:模擬 3x3 棋盤,處理格位更新、檢查勝者(檢視橫、直、斜列)、判斷棋盤是否已滿
  • Player:代表參與遊戲的個人
  • Game:協調玩家輪流的中心實體,驗證落子(如格子未被佔用),追蹤遊戲狀態

Design Choice:Game 容易承擔過多操作。為保持精簡,我們將盤面管理委派給 Board、評分管理委派給 ScoreTracker。

  • ScoreTracker:追蹤玩家評分,依結果更新

類別圖設計#

Game#

Game 是中央協調者,管理遊戲流程:初始化元件、處理輪流、判定結果。為保持模組化:

  • ScoreTracker:專責追蹤玩家表現與勝場數
  • Board:管理棋盤格、確保落子有效
  • Player:保持無狀態,不直接儲存勝場數

這種分離讓集中式 ScoreTracker 能跨多場遊戲監控玩家表現,為可擴展的排名系統打下基礎。

Game class diagram

Board#

Board 代表 3x3 棋盤,以二維 Player 陣列建模。職責:

  • 在棋盤層級執行遊戲規則,確保落子位置有效
  • 透過檢查橫、直、斜判定勝者
  • 重置棋盤
  • 取得指定位置的玩家符號

Board class diagram

Design Choice:將勝者檢查邏輯放在 Board 而非 Game,符合單一職責原則——Board 是棋盤規則的擁有者。

ScoreTracker#

ScoreTracker 透過維護集中式計分板追蹤玩家表現。雖然真實系統常用複雜的動態評分機制,但面試中我們以勝場數為簡單評分基準。

不把評分作為 Player 屬性的原因:

  • 評分不像姓名是內在特質——它是情境性的,反映相對於他人的表現
  • 隨遊戲進行而變動,同時影響多位玩家
  • 進階系統中可能隨時間演化

將此邏輯獨立到 ScoreTracker,為未來擴充(如多聯盟不同評分)保留彈性。

ScoreTracker 使用 HashMap<Player, Integer>playerRatings)儲存所有玩家的勝場數,支援更新評分、查找頂尖玩家、查詢任一玩家的排名。

ScoreTracker class diagram

遊戲結束時,reportGameResult 判定贏家並更新 ScoreTracker;平手時不變動評分。

Move#

Move 是直接的資料結構,記錄一次落子的列號、欄號與玩家。

Move class diagram

將細節綁進單一 Move 物件而非分開傳參,可提升可讀性與維護性。

Player#

Player 封裝玩家核心屬性:姓名與符號(X 或 O)。

Player class diagram

雖然把「落子」或「更新評分」放進 Player 看似直觀,但這會違反 SRP。在好的設計中:

  • Board:是驗證與置放落子的唯一真實來源
  • ScoreTracker:追蹤與更新評分,因為評分依賴一群玩家的整體脈絡

完整類別圖#

Class Diagram of Tic-Tac-Toe

程式實作#

Game#

public class Game {
    // Core game components
    private final Board board; // Manages the game board state
    private final ScoreTracker scoreTracker; // Keeps track of player scores
    private Player[] players; // Array of players in the game
    private int currentPlayerIndex; // Index of the current player's turn

    // Constructor initializes game components and starts a new game
    public Game(Player playerX, Player playerY) {
        board = new Board();
        scoreTracker = new ScoreTracker();
        startNewGame(playerX, playerY);
    }

    // Resets the game state and initializes players for a new game
    public void startNewGame(Player playerX, Player playerY) {
        board.reset();
        players = new Player[] {playerX, playerY};
        currentPlayerIndex = 0;
    }

    // Processes a player's move, validates it, and updates game state
    public void makeMove(int colIndex, int rowIndex, Player player) {
        if (getGameStatus().equals(GameCondition.ENDED)) {
            throw new IllegalStateException("game ended");
        }
        if (players[currentPlayerIndex] != player) {
            throw new IllegalArgumentException("not the current player");
        }
        if (board.getPlayerAt(colIndex, rowIndex) != null) {
            throw new IllegalArgumentException("board position is taken");
        }
        board.updateBoard(colIndex, rowIndex, player);
        final Move newMove = new Move(colIndex, rowIndex, player);
        currentPlayerIndex = (currentPlayerIndex + 1) % players.length;
        if (getGameStatus().equals(GameCondition.ENDED)) {
            scoreTracker.reportGameResult(players[0], players[1], board.getWinner());
        }
    }

    // Determines if the game is in progress or has ended
    public GameCondition getGameStatus() {
        Optional<Player> winner = board.getWinner();
        if (winner.isPresent()) {
            return GameCondition.ENDED;
        }
        return board.isFull() ? GameCondition.ENDED : GameCondition.IN_PROGRESS;
    }

    // Returns the player whose turn it is
    public Player getCurrentPlayer() {
        return players[currentPlayerIndex];
    }

    // Returns the score tracker for accessing game statistics
    public ScoreTracker getScoreTracker() {
        return scoreTracker;
    }
}

關鍵方法:

  • makeMove — 驗證遊戲狀態、玩家順序、目標格位空著;更新棋盤與輪到下一玩家;若遊戲結束則更新計分
  • getGameStatus — 透過檢查贏家或棋盤是否已滿,判定遊戲狀態

Implementation Choice:Game 使用類似狀態機的方式管理流程,以 enum GameCondition 追蹤狀態(IN_PROGRESSENDED),確保同時間只有一玩家落子,並在勝/和時停止。

Board#

public class Board {
    // 3x3 grid to store player moves
    private final Player[][] grid = new Player[3][3];

    // Updates the board with a player's move at the specified position
    public void updateBoard(int colIndex, int rowIndex, Player player) {
        if (grid[colIndex][rowIndex] == null) {
            grid[colIndex][rowIndex] = player;
        }
    }

    // Checks for a winner by examining rows, columns, and diagonals
    public Optional<Player> getWinner() {
        // Check rows for three in a row
        for (int i = 0; i < grid.length; i++) {
            Player first = grid[i][0];
            if (first != null && Arrays.stream(grid[i]).allMatch(p -> p == first)) {
                return Optional.of(first);
            }
        }

        // Check columns for three in a column
        for (int j = 0; j < grid[0].length; j++) {
            final Player first = grid[0][j];
            int finalJ = j; // streams require a final object
            if (first != null && Arrays.stream(grid).allMatch(row -> row[finalJ] == first)) {
                return Optional.of(first);
            }
        }

        // Check main diagonal (top-left to bottom-right)
        Player topLeft = grid[0][0];
        if (topLeft != null
                && IntStream.range(0, grid.length).allMatch(i -> grid[i][i] == topLeft)) {
            return Optional.of(topLeft);
        }

        // Check anti-diagonal (top-right to bottom-left)
        Player topRight = grid[0][grid[0].length - 1];
        if (topRight != null
                && IntStream.range(0, grid.length)
                        .allMatch(i -> grid[i][grid[0].length - 1 - i] == topRight)) {
            return Optional.of(topRight);
        }

        // No winner found
        return Optional.empty();
    }

    // Checks if all positions on the board are filled
    public boolean isFull() {
        return Arrays.stream(grid).flatMap(Arrays::stream).noneMatch(Objects::isNull);
    }

    // Resets the board by clearing all positions
    public void reset() {
        for (Player[] players : grid) {
            Arrays.fill(players, null);
        }
    }

    // Returns the player at the specified position, or null if empty
    public Player getPlayerAt(int colIndex, int rowIndex) {
        return grid[colIndex][rowIndex];
    }
}

Implementation Choice:用 2D array (Player[][]) 表示 3x3 棋盤,直接對應遊戲的空間結構,且讀寫皆為 O(1)

Player#

public class Player {
    private final String name;
    private final char symbol;

    public Player(String name, char symbol) {
        this.name = name;
        this.symbol = symbol;
    }

    public String getName() {
        return name;
    }

    public char getSymbol() {
        return symbol;
    }
}

ScoreTracker#

class ScoreTracker {
    // Stores player ratings in a map where key is player and value is their score
    private HashMap<Player, Integer> playerRatings = new HashMap<>();

    // This logic is customizable and, in reality, will use a complex ranking algorithm. For the
    // interview, we use a simple victory count system where the winner gets one point, the loser
    // loses a point, and no changes occur for a draw.
    public void reportGameResult(Player player1, Player player2, Optional<Player> winningPlayer) {
        if (winningPlayer.isPresent()) {
            Player winner = winningPlayer.get();
            Player loser = player1 == winner ? player2 : player1;
            playerRatings.putIfAbsent(winner, 0);
            playerRatings.put(winner, playerRatings.get(winner) + 1);
            playerRatings.putIfAbsent(loser, 0);
            playerRatings.put(loser, playerRatings.get(loser) - 1);
        }
    }

    // Returns a map of players sorted by their ratings in descending order
    public Map<Player, Integer> getTopPlayers() {
        return playerRatings.entrySet().stream()
                .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
                .map(Map.Entry::getKey)
                .collect(Collectors.toMap(player -> player, player -> playerRatings.get(player)));
    }

    // Returns the rank of a player based on their rating
    public int getRank(Player player) {
        List<Player> sortedPlayers =
                playerRatings.entrySet().stream()
                        .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
                        .map(Map.Entry::getKey)
                        .collect(Collectors.toList());
        return sortedPlayers.indexOf(player) + 1;
    }

    // getters are omitted for brevity
}

Implementation ChoiceHashMap<Player, Integer> 提供 O(1) 的查找與更新,適合頻繁的計分操作。

深度討論#

實作 Undo 功能#

如何讓玩家能撤回上一步落子?

Step 1:追蹤動作歷史#

每次落子時把該動作存起來。Move 類別已捕捉 rowIndexcolIndex 與玩家。

Move class diagram

Step 2:用堆疊儲存動作#

由於落子是 後進先出(LIFO) 順序——最後一步先被撤回——我們用堆疊(ArrayDeque<Move>):

  • 落子時 push 到堆疊
  • 撤回時 pop 最近一步,並還原棋盤

MoveHistory class diagram

class MoveHistory {
    // Stack-like structure to store moves in chronological order
    private final ArrayDeque<Move> history = new ArrayDeque<>();

    // Adds a new move to the history stack
    public void recordMove(Move move) {
        history.push(move);
    }

    // Removes and returns the most recent move from the history
    public Move undoMove() {
        return history.pop();
    }

    // Clears all moves from the history
    public void clearHistory() {
        history.clear();
    }
}

Step 3:反轉棋盤狀態#

清除該格並把當前玩家切回前一位。

public void makeMove(int colIndex, int rowIndex, Player player) {
    // Validate that game hasn't ended
    if (getGameStatus().equals(GameCondition.ENDED)) {
        throw new IllegalStateException("game ended");
    }
    // Validate that it's the correct player's turn
    if (players[currentPlayerIndex] != player) {
        throw new IllegalArgumentException("not the current player");
    }
    // Validate that the position is not already taken
    if (board.getPlayerAt(colIndex, rowIndex) != null) {
        throw new IllegalArgumentException("board position is taken");
    }
    // Update the board with the player's move
    board.updateBoard(colIndex, rowIndex, player);
    // Record the move in history
    final Move newMove = new Move(colIndex, rowIndex, player);
    moveHistory.recordMove(newMove);
    // Switch to the next player
    currentPlayerIndex = (currentPlayerIndex + 1) % players.length;
    // If game has ended, update the score
    if (getGameStatus().equals(GameCondition.ENDED)) {
        scoreTracker.reportGameResult(players[0], players[1], board.getWinner());
    }
}

// Reverts the last move made in the game
public void undoMove() {
    // Check if game has ended to prevent undoing after winner is reported
    if (getGameStatus().equals(GameCondition.ENDED)) {
        throw new IllegalStateException("game ended and winner already reported");
    }
    // Get the last move from history
    final Move lastMove = moveHistory.undoMove();

    // Update current player index to previous player
    if (currentPlayerIndex == 0) {
        currentPlayerIndex = players.length - 1;
    } else {
        currentPlayerIndex--;
    }

    // Clear the board position of the undone move
    board.updateBoard(lastMove.getColIndex(), lastMove.getRowIndex(), null);
}

Memento Pattern 定義:Memento 是一種行為型設計模式,允許物件儲存與還原先前狀態而不暴露實作細節。常用於需要 undo/rollback 的場景。

我們的設計實際上實踐了 Memento Pattern:

  • Memento:在特定時點儲存物件狀態的快照——本設計中由 Move 類別擔任
  • Caretaker:負責儲存與管理 Memento 物件——由 MoveHistory 維護 Move 堆疊並提供 undoMove()
  • Originator:狀態被擷取與還原的物件——由 GamemakeMove() 時建立 Move、用 undoMove() 還原

Undo functionality using the memento pattern

Memento Pattern 的潛在挑戰是記憶體開銷。井字遊戲棋盤小,問題不大,但更複雜的遊戲若狀態龐大,記憶體可能成為瓶頸。

本章小結#

主要收穫

  • 模組化與職責分離:每個元件(Board、Game、Player、ScoreTracker)各司其職
  • Memento Pattern:用以實現 undo 功能,讓玩家能撤回落子並維護遊戲狀態的完整性