[03] Yahtzee

Wei
海大 SwiftUI iOS / Flutter App 程式設計
18 min readMay 2, 2024

github

遊戲內容展示

roll dice

  • 可以 lock & unlock 骰子
  • 每次交換玩家時會重置
  • 投擲完 3 次之後不可再按 roll 按鈕

dice class :

class Dice {
int value; // 骰子的點數
bool isLocked; // 骰子是否被鎖定,不進行擲骰

Dice({this.value = 0, this.isLocked = false}); // 構造函數,初始化點數為 0,未鎖定

// 生成一個新的骰子點數
void roll() {
if (!isLocked) {
// 如果骰子未被鎖定
value = Random().nextInt(6) + 1; // 生成 1 到 6 的隨機數
}
}
}

使用 ChangeNotifier 實現

class DiceNotifier extends ChangeNotifier {
// 骰子列表,包含五個骰子,每個骰子初始值為 0。
List<Dice> _diceList = List.generate(5, (_) => Dice(value: 0));

// 紀錄擲骰次數。
int _rollCount = 0;

// 提供公開的 getter 方法
// 初始化骰子
// ...

// 擲骰,對每個骰子進行擲骰操作並增加擲骰次數。
void rollDice() {
for (var dice in diceList) {
dice.roll(); // 調用 Dice 類別的 roll 方法來生成新的骰子值。
}
_rollCount++;
notifyListeners(); // 通知監聽器數據已更新。
}

// 重置骰子,將所有骰子的值設定為 0 並重置擲骰次數。
void resetDice() {
_diceList = List.generate(5, (_) => Dice(value: 0));
_rollCount = 0;
notifyListeners(); // 通知監聽器數據已更新。
}

// 獲取所有骰子的值,返回一個包含所有骰子值的整數列表。
List<int> getDiceValues() {
return _diceList.map((dice) => dice.value).toList();
}
}

play & score

  • 在 roll dice 之後,記分板上顯示可以選擇的策略
  • 可選擇不同的策略,選擇後可以點選 play 按鈕
  • 點選 play 按鈕,將分數記上記分板,也會同時紀錄玩家總分
  • 若是沒有可以加分的策略,則可以選擇一個策略記0分
  • 完成後切換玩家,計分板和骰子重置,回合玩家會高亮顯示

選擇一個策略為0分

使用ChangeNotifier紀錄分數,在分數更新後通知記分板元件更新顯示

class GameStateNotifier extends ChangeNotifier {
// 定義遊戲中所有可能的分數類型
static const categories = [
'ace',
'twos',
'threes',
'fours',
'fives',
'sixes',
'three_of_a_kind',
'four_of_a_kind',
'small',
'large',
'chance',
'full',
'yahtzee',
'bonus',
];

// 儲存每位玩家的分數板,每個分類的初始分數為0
List<Map<String, int>> _playerScoreBoards =
List.generate(2, (_) => {for (var category in categories) category: 0});

// 儲存臨時分數,用於當前骰子組合的評分預測
Map<String, int> _tempScores = {for (var category in categories) category: 0};

// 新增記錄已選擇過的分數
List<Set<String>> _selectedCategories = List.generate(2, (_) => <String>{});

// 儲存兩位玩家的總分
List<int> _playerScores = [0, 0];
// 紀錄當前操作的玩家(0 或 1)
int _currentPlayer = 0;
// 紀錄當前遊戲輪數
int _round = 1;
// 紀錄當前選擇的分數類型
String _pickedCategory = '';
// 遊戲是否結束的標誌
bool _gameOver = false;

// 公開讀取器,以獲得不可修改的玩家分數板
// 遊戲初始化方法,設置所有參數為初始狀態
// ...

// 策略是否可以選擇
bool isCategorySelectable(String category) {
// 如果已經選過不能再選
if (_selectedCategories[_currentPlayer].contains(category)) {
return false;
}

// 不能選 bonus
if (category == 'bonus') {
return false;
}

if (_noStrategy || _tempScores[category] != 0) {
return true;
}

return false;
}

// 更新玩家選擇的分數類型
void updatePickedCategory(String category) {
_pickedCategory = category;
notifyListeners(); // 更新數據後通知聽眾
}

// 根據骰子值和選擇的分數類型更新分數
void updateScore(List<int> diceValues) {
int score = calculateCategoryScore(_pickedCategory, diceValues);
// 確保分數板中有這個類別
int currentScore = _playerScoreBoards[_currentPlayer][_pickedCategory] ?? 0;
_playerScoreBoards[_currentPlayer][_pickedCategory] = currentScore + score;
_playerScores[_currentPlayer] += score;

// 紀錄已選擇或以刪除的策略(快艇可重複選擇)
if (_pickedCategory != 'yahtzee' || score == 0) {
_selectedCategories[_currentPlayer].add(_pickedCategory);
}

if (checkBonus()) {
// 檢查是否達到獎勵條件
_playerScoreBoards[_currentPlayer]['bonus'] = 35;
}

_nextPlayer(); // 換到下一位玩家
}

// 根據當前骰子值計算各類別的預測分數
void calculateScore(List<int> diceValues) {
// 是否沒有可以選擇(有分數)的策略
_noStrategy = true;

for (var category in categories) {
_tempScores[category] = calculateCategoryScore(category, diceValues);

if (_tempScores[category] != 0 &&
!_selectedCategories[_currentPlayer].contains(category)) {
_noStrategy = false;
}
}
notifyListeners();
}

// 根據不同的分數類型計算分數
int calculateCategoryScore(String category, List<int> diceValues) {
switch (category) {
case 'ace':
return sumDiceByValue(diceValues, 1);
case 'twos':
return sumDiceByValue(diceValues, 2);
case 'threes':
return sumDiceByValue(diceValues, 3);
case 'fours':
return sumDiceByValue(diceValues, 4);
case 'fives':
return sumDiceByValue(diceValues, 5);
case 'sixes':
return sumDiceByValue(diceValues, 6);
case 'three_of_a_kind':
return checkXOfAKind(diceValues, numberOfAKind: 3);
case 'four_of_a_kind':
return checkXOfAKind(diceValues, numberOfAKind: 4);
case 'small':
return checkSmallStraight(diceValues) ? 30 : 0;
case 'large':
return checkLargeStraight(diceValues) ? 40 : 0;
case 'chance':
return diceValues.reduce((a, b) => a + b);
case 'full':
return checkFullHouse(diceValues) ? 25 : 0;
case 'yahtzee':
return checkYahtzee(diceValues)
? (_playerScoreBoards[_currentPlayer]['yahtzee'] == 0 ? 50 : 100)
: 0;
default:
return 0;
}
}

// 切換到下一位玩家,如果所有回合結束則標記遊戲結束
void _nextPlayer() {
_currentPlayer = (_currentPlayer + 1) % 2;
_tempScores = {for (var category in categories) category: 0};
_pickedCategory = '';
_noStrategy = false;

// 回到第一位玩家時,回合+1
if (_currentPlayer == 0) {
// 如果已經13回合,則結束遊戲
if (_round == 13) {
_gameOver = true;
} else {
_round += 1;
}
}

notifyListeners(); // 通知聽眾狀態已更改
}
}

計分板的每一列使用ConsumerGameStateNotifier 更新之後改變顯示

class ScoreRow extends StatelessWidget {
final ScoreCategory category; // 接收一個分數類別作為參數
const ScoreRow({super.key, required this.category});

@override
Widget build(BuildContext context) {
return Consumer<GameStateNotifier>(
builder: (context, gameState, child) {
// 根據 gameState 決定分數、回合玩家、是否可以選擇、是否已被選擇等等
bool pickable = gameState.isCategorySelectable(category.id); // 是否可以選擇這個策略
bool isSelected = gameState.pickedCategory == category.id; // 是否已選擇這個策略
// ...

return GestureDetector(
onTap: pickable
? () => gameState.updatePickedCategory(category.id) // 選擇分數類別的動作
: null,
child: Container(
height: 28,
margin: const EdgeInsets.symmetric(vertical: 1.0),
decoration: BoxDecoration(
// 根據是否被使用者選擇改變顯示方式
// ...
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(category.icon),
Text(category.name,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
)),
ScoreDisplay(
score: isCurrentPlayer1 ? score1 + tempScore : score1,
pickable: pickable && isCurrentPlayer1,
),
ScoreDisplay(
score: isCurrentPlayer2 ? score2 + tempScore : score2,
pickable: pickable && isCurrentPlayer2,
),
],
),
),
);
},
);
}
}

play 按鈕

class PlayButton extends StatelessWidget {
const PlayButton({super.key}); // 構造函數,接收一個 key 參數

void _playGame(BuildContext context) {
// 從 Provider 獲取 GameStateNotifier 和 DiceNotifier 的實例,不監聽變化
final gameState = Provider.of<GameStateNotifier>(context, listen: false);
final dice = Provider.of<DiceNotifier>(context, listen: false);

gameState.updateScore(dice.getDiceValues()); // 更新分數板的分數
dice.resetDice(); // 重置骰子
}

@override
Widget build(BuildContext context) {
// 創建一個 ElevatedButton 組件
return Consumer<GameStateNotifier>(
builder: (context, gameState, _) => ElevatedButton(
onPressed: gameState.pickedCategory == ''
? null
: () => _playGame(context), // 按鈕點擊時執行 _playGame 函數
child: const Text('Play'), // 按鈕文本
),
);
}
}

遊戲結束展示

  • 透過GameStateNotifier的回合數和Listener判斷遊戲是否結束
  • 當遊戲結束時用Navigator.of(context).push導航到結束頁面
  • 結束頁面中獲勝玩家用金色顯示
  • 可以透過重新開始按鈕重新開始遊戲

GameStateNotifier_nextPlayer會再增加回合樹的同時判斷遊戲是否結束

void _nextPlayer() {
_currentPlayer = (_currentPlayer + 1) % 2;
_tempScores = {for (var category in categories) category: 0};
_pickedCategory = '';

// 回到第一位玩家時,回合+1
if (_currentPlayer == 0) {
// 如果已經13回合,則結束遊戲
if (_round == 13) {
_gameOver = true;
} else {
_round += 1;
}
}

notifyListeners(); // 通知聽眾狀態已更改
}

main.dart 中的checkGameOver 來處理當遊戲結束時的畫面跳轉

void checkGameOver() {
if (gameState!.gameOver) {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => GameOverPage(score: gameState!.playerScores),
));
}
}

@override
void initState() {
super.initState();
// 通過Provider獲取狀態管理器實例,不監聽變化
gameState = Provider.of<GameStateNotifier>(context, listen: false);
dice = Provider.of<DiceNotifier>(context, listen: false);

// 在頁面首次渲染後初始化遊戲和骰子狀態
WidgetsBinding.instance.addPostFrameCallback((_) {
gameState!.initGame();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
dice!.initDice();
});

// 監聽遊戲結束狀態
gameState!.addListener(checkGameOver);
}

重新開始按鈕

ElevatedButton(
onPressed: () {
final gameState =
Provider.of<GameStateNotifier>(context, listen: false);
gameState.initGame(); // 重置遊戲狀態
Navigator.pop(context); // 返回上一頁
},
child: const Text('重新開始'), // 按鈕文字
)

bonus

檢查是否達到bonus條件

// 檢查是否達到獎勵分數
bool checkBonus() {
const items = ['ace', 'twos', 'threes', 'fours', 'fives', 'sixes'];
int total = 0;
for (int i = 0; i < 6; i++) {
String category = items[i];
total += _playerScoreBoards[_currentPlayer][category]!;
}
return total >= 63;
}

yahtzee

從第二次 yahtzee 開始分數為100分

checkYahtzee(diceValues)
? (_playerScoreBoards[_currentPlayer]['yahtzee'] == 0 ? 50 : 100)
: 0;

重新開始按鈕

  • 在遊戲中可以直接點選按鈕重新開始

使用GameStateNotifierDiceNotifierinit函數

Widget _buildReStartButton(BuildContext context) {
// 建立重啟遊戲按鈕
return Container(
// ...
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
final gameState =
Provider.of<GameStateNotifier>(context, listen: false);
final dice = Provider.of<DiceNotifier>(context, listen: false);

gameState.initGame(); // 初始化遊戲狀態
dice.initDice(); // 初始化骰子狀態
},
borderRadius: BorderRadius.circular(8),
child: const Icon(Icons.restart_alt_outlined), // 重啟圖標
),
),
);
}

--

--