Newer
Older
// 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 '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());
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
/// 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) {
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
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,
),
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
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);
},
),
),
)
]
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
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),
],
),
),