// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'package:async/async.dart'; import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation; import 'package:flutter/widgets.dart'; import 'game_board.dart'; import 'game_model.dart'; import 'move_finder.dart'; import 'styling.dart'; import 'thinking_indicator.dart'; /// Main function for the app. Turns off the system overlays and locks portrait /// orientation for a more game-like UI, and then runs the [Widget] tree. void main() { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setEnabledSystemUIOverlays([]); SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, DeviceOrientation.portraitDown, ]); runApp(FlutterFlipApp()); } /// The App class. Unlike many Flutter apps, this one does not use Material /// widgets, so there's no [MaterialApp] or [Theme] objects. class FlutterFlipApp extends StatelessWidget { @override Widget build(BuildContext context) { return WidgetsApp( color: Color(0xffffffff), // Mandatory background color. onGenerateRoute: (settings) { return PageRouteBuilder<dynamic>( settings: settings, pageBuilder: (context, animation, secondaryAnimation) => GameScreen(), ); }, ); } } /// The [GameScreen] Widget represents the entire game /// display, from scores to board state and everything in between. class GameScreen extends StatefulWidget { @override State createState() => _GameScreenState(); } /// State class for [GameScreen]. /// /// The game is modeled as a [Stream] of immutable instances of [GameModel]. /// Each move by the player or CPU results in a new [GameModel], which is /// sent downstream. [GameScreen] uses a [StreamBuilder] wired up to that stream /// of models to build out its [Widget] tree. class _GameScreenState extends State<GameScreen> { final StreamController<GameModel> _userMovesController = StreamController<GameModel>(); final StreamController<GameModel> _restartController = StreamController<GameModel>(); Stream<GameModel>? _modelStream; _GameScreenState() { // Below is the combination of streams that controls the flow of the game. // There are two streams of models produced by player interaction (either by // restarting the game, which produces a brand new game model and sends it // downstream, or tapping on one of the board locations to play a piece, and // which creates a new board model with the result of the move and sends it // downstream. The StreamGroup combines these into a single stream, then // does a little trick with asyncExpand. // // The function used in asyncExpand checks to see if it's the CPU's turn // (white), and if so creates a [MoveFinder] to look for the best move. It // awaits the calculation, and then creates a new [GameModel] with the // result of that move and sends it downstream by yielding it. If it's still // the CPU's turn after making that move (which can happen in reversi), this // is repeated. // // The final stream of models that exits the asyncExpand call is a // combination of "new game" models, models with the results of player // moves, and models with the results of CPU moves. These are fed into the // StreamBuilder in [build], and used to create the widgets that comprise // the game's display. _modelStream = StreamGroup.merge([ _userMovesController.stream, _restartController.stream, ]).asyncExpand((model) async* { yield model; var newModel = model; while (newModel.player == PieceType.white) { final finder = MoveFinder(newModel.board); final move = await finder.findNextMove(newModel.player, 5); if (move != null) { newModel = newModel.updateForMove(move.x, move.y); yield newModel; } } }); } // Thou shalt tidy up thy stream controllers. @override void dispose() { _userMovesController.close(); _restartController.close(); super.dispose(); } /// The build method mostly just sets up the StreamBuilder and leaves the /// details to _buildWidgets. @override Widget build(BuildContext context) { return StreamBuilder<GameModel>( stream: _modelStream, builder: (context, snapshot) { return _buildWidgets( context, snapshot.hasData ? snapshot.data! : GameModel(board: GameBoard()), ); }, ); } // Called when the user taps on the game's board display. If it's the player's // turn, this method will attempt to make the move, creating a new GameModel // in the process. void _attemptUserMove(GameModel model, int x, int y) { if (model.player == PieceType.black && model.board.isLegalMove(x, y, model.player)) { _userMovesController.add(model.updateForMove(x, y)); } } Widget _buildScoreBox(PieceType player, GameModel model) { var assetImageCode = player == PieceType.black ? 'black' : 'white'; String assetImageName = 'assets/skins/' + model.skin + '_tile_' + assetImageCode + '.png'; var scoreText = player == PieceType.black ? '${model.blackScore}' : '${model.whiteScore}'; return Container( padding: const EdgeInsets.symmetric( vertical: 3.0, horizontal: 30.0, ), decoration: (model.player == player) ? Styling.activePlayerIndicator : Styling.inactivePlayerIndicator, child: Row( children: <Widget>[ SizedBox( width: 30.0, height: 30.0, child: Image( image: AssetImage(assetImageName), fit: BoxFit.fill, ), ), SizedBox( width: 10.0, ), Text( scoreText, textAlign: TextAlign.center, style: TextStyle( fontSize: 50.0, color: Color(0xff000000), ), ), ], ), ); } Widget _buildScoreBoxes(GameModel model) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Spacer(flex: 1), _buildScoreBox(PieceType.black, model), Spacer(flex: 4), _buildScoreBox(PieceType.white, model), Spacer(flex: 1), ], ); } Table _buildGameBoardDisplay(BuildContext context, GameModel model) { final rows = <TableRow>[]; for (var y = 0; y < GameBoard.height; y++) { final cells = <Column>[]; for (var x = 0; x < GameBoard.width; x++) { PieceType pieceType = model.board.getPieceAtLocation(x, y); String? assetImageCode = Styling.assetImageCodes[pieceType]; String assetImageName = 'assets/skins/' + model.skin + '_tile_' + (assetImageCode != null ? assetImageCode : 'empty') + '.png'; Column cell = Column( children: [ Container( decoration: Styling.boardCellDecoration, child: SizedBox( child: GestureDetector( child: AnimatedSwitcher( duration: const Duration(milliseconds: 500), transitionBuilder: (Widget child, Animation<double> animation) { return ScaleTransition(child: child, scale: animation); }, child: Image( image: AssetImage(assetImageName), fit: BoxFit.fill, key: ValueKey<int>(pieceType == PieceType.empty ? 0 : (pieceType == PieceType.black ? 1 : 2)), ), ), onTap: () { _attemptUserMove(model, x, y); }, ), ), ) ] ); cells.add(cell); } rows.add(TableRow(children: cells)); } return Table( defaultColumnWidth: IntrinsicColumnWidth(), children: rows ); } Widget _buildThinkingIndicator(GameModel model) { return ThinkingIndicator( color: Styling.thinkingColor, height: Styling.thinkingSize, visible: model.player == PieceType.white, ); } Widget _buildRestartGameWidget() { return Container( padding: const EdgeInsets.symmetric( vertical: 5.0, horizontal: 15.0, ), child: Image( image: AssetImage('assets/icons/button_restart.png'), fit: BoxFit.fill, ), ); } Widget _buildEndGameWidget(GameModel model) { Image decorationImage = Image( image: AssetImage( model.gameResultString == 'black' ? 'assets/icons/game_win.png' : 'assets/icons/empty.png' ), fit: BoxFit.fill, ); return Container( margin: EdgeInsets.all(2), padding: EdgeInsets.all(2), child: Table( defaultColumnWidth: IntrinsicColumnWidth(), children: [ TableRow( children: [ Column(children: [ decorationImage ]), Column(children: [ GestureDetector( onTap: () { _restartController.add( GameModel(board: GameBoard()), ); }, child: _buildRestartGameWidget(), ) ]), Column(children: [ decorationImage ]), ], ), ] ) ); } // Builds out the Widget tree using the most recent GameModel from the stream. Widget _buildWidgets(BuildContext context, GameModel model) { return Container( padding: EdgeInsets.only(top: 30.0, left: 15.0, right: 15.0), decoration: Styling.mainWidgetDecoration, child: SafeArea( child: Column( children: [ _buildScoreBoxes(model), SizedBox(height: 10), _buildGameBoardDisplay(context, model), SizedBox(height: 10), _buildThinkingIndicator(model), SizedBox(height: 30), if (model.gameIsOver) _buildEndGameWidget(model), ], ), ), ); } }