本章探討**井字遊戲(Tic-Tac-Toe)**的物件導向設計。我們將打造一個雙人輪流落子的互動平台,涵蓋遊戲盤面、玩家動作追蹤、輸贏判定,以及管理玩家評分的計分追蹤器。
遊戲規則:井字遊戲是經典的雙人遊戲,於 3x3 棋盤上進行。雙方各執「X」或「O」,輪流在空格落子。先讓三個相同符號橫、直、斜連成一線者獲勝;棋盤填滿仍無人連線則為平手。

需求蒐集#
題目情境#
想像你和朋友坐下玩一局井字。你們各選符號(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 能跨多場遊戲監控玩家表現,為可擴展的排名系統打下基礎。
Board#
Board 代表 3x3 棋盤,以二維 Player 陣列建模。職責:
- 在棋盤層級執行遊戲規則,確保落子位置有效
- 透過檢查橫、直、斜判定勝者
- 重置棋盤
- 取得指定位置的玩家符號
Design Choice:將勝者檢查邏輯放在 Board 而非 Game,符合單一職責原則——Board 是棋盤規則的擁有者。
ScoreTracker#
ScoreTracker 透過維護集中式計分板追蹤玩家表現。雖然真實系統常用複雜的動態評分機制,但面試中我們以勝場數為簡單評分基準。
不把評分作為 Player 屬性的原因:
- 評分不像姓名是內在特質——它是情境性的,反映相對於他人的表現
- 隨遊戲進行而變動,同時影響多位玩家
- 進階系統中可能隨時間演化
將此邏輯獨立到 ScoreTracker,為未來擴充(如多聯盟不同評分)保留彈性。
ScoreTracker 使用 HashMap<Player, Integer>(playerRatings)儲存所有玩家的勝場數,支援更新評分、查找頂尖玩家、查詢任一玩家的排名。
遊戲結束時,reportGameResult 判定贏家並更新 ScoreTracker;平手時不變動評分。
Move#
Move 是直接的資料結構,記錄一次落子的列號、欄號與玩家。
將細節綁進單一 Move 物件而非分開傳參,可提升可讀性與維護性。
Player#
Player 封裝玩家核心屬性:姓名與符號(X 或 O)。
雖然把「落子」或「更新評分」放進 Player 看似直觀,但這會違反 SRP。在好的設計中:
- Board:是驗證與置放落子的唯一真實來源
- ScoreTracker:追蹤與更新評分,因為評分依賴一群玩家的整體脈絡
完整類別圖#
程式實作#
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_PROGRESS或ENDED),確保同時間只有一玩家落子,並在勝/和時停止。
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 Choice:
HashMap<Player, Integer>提供 O(1) 的查找與更新,適合頻繁的計分操作。
深度討論#
實作 Undo 功能#
如何讓玩家能撤回上一步落子?
Step 1:追蹤動作歷史#
每次落子時把該動作存起來。Move 類別已捕捉 rowIndex、colIndex 與玩家。
Step 2:用堆疊儲存動作#
由於落子是 後進先出(LIFO) 順序——最後一步先被撤回——我們用堆疊(ArrayDeque<Move>):
- 落子時 push 到堆疊
- 撤回時 pop 最近一步,並還原棋盤
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:狀態被擷取與還原的物件——由 Game 在
makeMove()時建立 Move、用undoMove()還原
Memento Pattern 的潛在挑戰是記憶體開銷。井字遊戲棋盤小,問題不大,但更複雜的遊戲若狀態龐大,記憶體可能成為瓶頸。
本章小結#
主要收穫:
- 模組化與職責分離:每個元件(Board、Game、Player、ScoreTracker)各司其職
- Memento Pattern:用以實現 undo 功能,讓玩家能撤回落子並維護遊戲狀態的完整性