Commit 4d2902f2 authored by Hixie's avatar Hixie

Many code improvements to Mine Digger.

Text styles are now a global constant instead of being initialised dynamically.
There are now sufficient text styles for 8 mines around a square.
I coallesced Game and MineDiggerApp.
Used an enum instead of constants for the cell state.
Used setState() instead of scheduleBuild().
Used an O(N) algorithm for mine laying instead of the previous potentially-never-ending algorithm.
Moved the listener on the cells to the app, so that the logic survives a rebuild.
Various other minor changes.
parent 6d304c03
...@@ -9,125 +9,152 @@ import 'package:sky/painting/text_style.dart'; ...@@ -9,125 +9,152 @@ import 'package:sky/painting/text_style.dart';
import 'package:sky/rendering/flex.dart'; import 'package:sky/rendering/flex.dart';
import 'package:sky/theme/colors.dart' as colors; import 'package:sky/theme/colors.dart' as colors;
import 'package:sky/widgets/basic.dart'; import 'package:sky/widgets/basic.dart';
import 'package:sky/widgets/widget.dart';
import 'package:sky/widgets/scaffold.dart'; import 'package:sky/widgets/scaffold.dart';
import 'package:sky/widgets/task_description.dart'; import 'package:sky/widgets/task_description.dart';
import 'package:sky/widgets/theme.dart'; import 'package:sky/widgets/theme.dart';
import 'package:sky/widgets/tool_bar.dart'; import 'package:sky/widgets/tool_bar.dart';
// Classic minesweeper-inspired game. The mouse controls are standard except // Classic minesweeper-inspired game. The mouse controls are standard
// for left + right combo which is not implemented. For touch, the duration of // except for left + right combo which is not implemented. For touch,
// the pointer determines probing versus flagging. // the duration of the pointer determines probing versus flagging.
// //
// There are only 3 classes to understand. Game, which is contains all the // There are only 3 classes to understand. MineDiggerApp, which is
// logic and two UI classes: CoveredMineNode and ExposedMineNode, none of them // contains all the logic and two classes that describe the mines:
// holding state. // CoveredMineNode and ExposedMineNode, none of them holding state.
// Colors for each mine count (0-8):
const List<TextStyle> textStyles = const <TextStyle>[
const TextStyle(color: const Color(0xFF555555), fontWeight: bold),
const TextStyle(color: const Color(0xFF0094FF), fontWeight: bold), // blue
const TextStyle(color: const Color(0xFF13A023), fontWeight: bold), // green
const TextStyle(color: const Color(0xFFDA1414), fontWeight: bold), // red
const TextStyle(color: const Color(0xFF1E2347), fontWeight: bold), // black
const TextStyle(color: const Color(0xFF7F0037), fontWeight: bold), // dark red
const TextStyle(color: const Color(0xFF000000), fontWeight: bold),
const TextStyle(color: const Color(0xFF000000), fontWeight: bold),
const TextStyle(color: const Color(0xFF000000), fontWeight: bold),
];
enum CellState { covered, exploded, cleared, flagged, shown }
class MineDiggerApp extends App {
void initState() {
resetGame();
}
class Game {
static const int rows = 9; static const int rows = 9;
static const int cols = 9; static const int cols = 9;
static const int totalMineCount = 11; static const int totalMineCount = 11;
static const int coveredCell = 0;
static const int explodedCell = 1;
static const int clearedCell = 2;
static const int flaggedCell = 3;
static const int shownCell = 4;
static final List<TextStyle> textStyles = new List<TextStyle>();
final App app;
bool alive; bool alive;
bool hasWon; bool hasWon;
int detectedCount; int detectedCount;
int randomSeed;
// |cells| keeps track of the positions of the mines. // |cells| keeps track of the positions of the mines.
List<List<bool>> cells; List<List<bool>> cells;
// |uiState| keeps track of the visible player progess. // |uiState| keeps track of the visible player progess.
List<List<int>> uiState; List<List<CellState>> uiState;
Game(this.app) {
randomSeed = 22;
// Colors for each mine count:
// 0 - none, 1 - blue, 2-green, 3-red, 4-black, 5-dark red .. etc.
textStyles.add(
new TextStyle(color: const Color(0xFF555555), fontWeight: bold));
textStyles.add(
new TextStyle(color: const Color(0xFF0094FF), fontWeight: bold));
textStyles.add(
new TextStyle(color: const Color(0xFF13A023), fontWeight: bold));
textStyles.add(
new TextStyle(color: const Color(0xFFDA1414), fontWeight: bold));
textStyles.add(
new TextStyle(color: const Color(0xFF1E2347), fontWeight: bold));
textStyles.add(
new TextStyle(color: const Color(0xFF7F0037), fontWeight: bold));
textStyles.add(
new TextStyle(color: const Color(0xFFE93BE9), fontWeight: bold));
initialize();
}
void initialize() { void resetGame() {
alive = true; alive = true;
hasWon = false; hasWon = false;
detectedCount = 0; detectedCount = 0;
// Build the arrays. // Build the arrays.
cells = new List<List<bool>>(); cells = new List<List<bool>>();
uiState = new List<List<int>>(); uiState = new List<List<CellState>>();
for (int iy = 0; iy != rows; iy++) { for (int iy = 0; iy != rows; iy++) {
cells.add(new List<bool>()); cells.add(new List<bool>());
uiState.add(new List<int>()); uiState.add(new List<CellState>());
for (int ix = 0; ix != cols; ix++) { for (int ix = 0; ix != cols; ix++) {
cells[iy].add(false); cells[iy].add(false);
uiState[iy].add(coveredCell); uiState[iy].add(CellState.covered);
} }
} }
// Place the mines. // Place the mines.
Random random = new Random(++randomSeed); Random random = new Random();
for (int mc = 0; mc != totalMineCount; mc++) { int cellsRemaining = rows * cols;
int rx = random.nextInt(rows); int minesRemaining = totalMineCount;
int ry = random.nextInt(cols); for (int x = 0; x < cols; x += 1) {
if (cells[ry][rx]) { for (int y = 0; y < rows; y += 1) {
// Mine already there. Try again. if (random.nextInt(cellsRemaining) < minesRemaining) {
--mc; cells[y][x] = true;
} else { minesRemaining -= 1;
cells[ry][rx] = true; if (minesRemaining <= 0)
return;
}
cellsRemaining -= 1;
} }
} }
assert(false);
}
Stopwatch longPressStopwatch;
PointerEventListener _pointerDownHandlerFor(int posX, int posY) {
return (sky.PointerEvent event) {
if (event.buttons == 1) {
probe(posX, posY);
} else if (event.buttons == 2) {
flag(posX, posY);
} else {
// Touch event.
longPressStopwatch = new Stopwatch()..start();
}
};
}
PointerEventListener _pointerUpHandlerFor(int posX, int posY) {
return (sky.PointerEvent event) {
if (longPressStopwatch == null)
return;
// Pointer down was a touch event.
if (longPressStopwatch.elapsedMilliseconds < 250) {
probe(posX, posY);
} else {
// Long press flags.
flag(posX, posY);
}
longPressStopwatch = null;
};
} }
Widget buildBoard() { Widget buildBoard() {
bool hasCoveredCell = false; bool hasCoveredCell = false;
List<Flex> flexRows = new List<Flex>(); List<Flex> flexRows = <Flex>[];
for (int iy = 0; iy != 9; iy++) { for (int iy = 0; iy != 9; iy++) {
List<Component> row = new List<Component>(); List<Widget> row = <Widget>[];
for (int ix = 0; ix != 9; ix++) { for (int ix = 0; ix != 9; ix++) {
int state = uiState[iy][ix]; CellState state = uiState[iy][ix];
int count = mineCount(ix, iy); int count = mineCount(ix, iy);
if (!alive) { if (!alive) {
if (state != explodedCell) if (state != CellState.exploded)
state = cells[iy][ix] ? shownCell : state; state = cells[iy][ix] ? CellState.shown : state;
} }
if (state == CellState.covered) {
if (state == coveredCell) { row.add(new Listener(
row.add(new CoveredMineNode( onPointerDown: _pointerDownHandlerFor(ix, iy),
this, onPointerUp: _pointerUpHandlerFor(ix, iy),
flagged: false, child: new CoveredMineNode(
posX: ix, posY: iy)); flagged: false,
// Mutating |hasCoveredCell| here is hacky, but convenient, same posX: ix,
// goes for mutating |hasWon| below. posY: iy
hasCoveredCell = true; )
} else if (state == flaggedCell) { ));
// Mutating |hasCoveredCell| here is hacky, but convenient, same
// goes for mutating |hasWon| below.
hasCoveredCell = true;
} else if (state == CellState.flagged) {
row.add(new CoveredMineNode( row.add(new CoveredMineNode(
this,
flagged: true, flagged: true,
posX: ix, posY: iy)); posX: ix, posY: iy)
);
} else { } else {
row.add(new ExposedMineNode( row.add(new ExposedMineNode(
state: state, state: state,
count: count)); count: count)
);
} }
} }
flexRows.add( flexRows.add(
...@@ -147,33 +174,32 @@ class Game { ...@@ -147,33 +174,32 @@ class Game {
} }
return new Container( return new Container(
key: 'minefield',
padding: new EdgeDims.all(10.0), padding: new EdgeDims.all(10.0),
margin: new EdgeDims.all(10.0), margin: new EdgeDims.all(10.0),
decoration: new BoxDecoration(backgroundColor: const Color(0xFF6B6B6B)), decoration: new BoxDecoration(backgroundColor: const Color(0xFF6B6B6B)),
child: new Flex( child: new Flex(
flexRows, flexRows,
direction: FlexDirection.vertical, direction: FlexDirection.vertical
key: 'flxv')); )
);
} }
Widget buildToolBar() { Widget buildToolBar() {
String banner = hasWon ? String toolbarCaption = hasWon ?
'Awesome!!' : alive ? 'Awesome!!' : alive ?
'Mine Digger [$detectedCount-$totalMineCount]': 'Kaboom! [press here]'; 'Mine Digger [$detectedCount-$totalMineCount]': 'Kaboom! [press here]';
return new ToolBar( return new ToolBar(
// FIXME: Strange to have the toolbar be tapable. // FIXME: Strange to have the toolbar be tapable.
center: new Listener( center: new Listener(
onPointerDown: handleBannerPointerDown, onPointerDown: handleToolbarPointerDown,
child: new Text(banner, style: Theme.of(this.app).text.title) child: new Text(toolbarCaption, style: Theme.of(this).text.title)
) )
); );
} }
Widget buildUI() { Widget build() {
// FIXME: We need to build the board before we build the toolbar because // We build the board before we build the toolbar because we compute the win state during build step.
// we compute the win state during build step.
Widget board = buildBoard(); Widget board = buildBoard();
return new TaskDescription( return new TaskDescription(
label: 'Mine Digger', label: 'Mine Digger',
...@@ -187,39 +213,42 @@ class Game { ...@@ -187,39 +213,42 @@ class Game {
); );
} }
void handleBannerPointerDown(sky.PointerEvent event) { void handleToolbarPointerDown(sky.PointerEvent event) {
initialize(); setState(() {
app.scheduleBuild(); resetGame();
});
} }
// User action. The user uncovers the cell which can cause losing the game. // User action. The user uncovers the cell which can cause losing the game.
void probe(int x, int y) { void probe(int x, int y) {
if (!alive) if (!alive)
return; return;
if (uiState[y][x] == flaggedCell) if (uiState[y][x] == CellState.flagged)
return; return;
// Allowed to probe. setState(() {
if (cells[y][x]) { // Allowed to probe.
// Probed on a mine --> dead!! if (cells[y][x]) {
uiState[y][x] = explodedCell; // Probed on a mine --> dead!!
alive = false; uiState[y][x] = CellState.exploded;
} else { alive = false;
// No mine, uncover nearby if possible. } else {
cull(x, y); // No mine, uncover nearby if possible.
} cull(x, y);
app.scheduleBuild(); }
});
} }
// User action. The user is sure a mine is at this location. // User action. The user is sure a mine is at this location.
void flag(int x, int y) { void flag(int x, int y) {
if (uiState[y][x] == flaggedCell) { setState(() {
uiState[y][x] = coveredCell; if (uiState[y][x] == CellState.flagged) {
--detectedCount; uiState[y][x] = CellState.covered;
} else { --detectedCount;
uiState[y][x] = flaggedCell; } else {
++detectedCount; uiState[y][x] = CellState.flagged;
} ++detectedCount;
app.scheduleBuild(); }
});
} }
// Recursively uncovers cells whose totalMineCount is zero. // Recursively uncovers cells whose totalMineCount is zero.
...@@ -229,9 +258,9 @@ class Game { ...@@ -229,9 +258,9 @@ class Game {
if ((y < 0) || (y > cols - 1)) if ((y < 0) || (y > cols - 1))
return; return;
if (uiState[y][x] == clearedCell) if (uiState[y][x] == CellState.cleared)
return; return;
uiState[y][x] = clearedCell; uiState[y][x] = CellState.cleared;
if (mineCount(x, y) > 0) if (mineCount(x, y) > 0)
return; return;
...@@ -269,106 +298,70 @@ class Game { ...@@ -269,106 +298,70 @@ class Game {
} }
} }
Widget makeCell(Widget widget) { Widget buildCell(Widget child) {
return new Container( return new Container(
padding: new EdgeDims.all(1.0), padding: new EdgeDims.all(1.0),
height: 27.0, width: 27.0, height: 27.0, width: 27.0,
decoration: new BoxDecoration(backgroundColor: const Color(0xFFC0C0C0)), decoration: new BoxDecoration(backgroundColor: const Color(0xFFC0C0C0)),
margin: new EdgeDims.all(2.0), margin: new EdgeDims.all(2.0),
child: widget); child: child
);
} }
Widget makeInnerCell(Widget widget) { Widget buildInnerCell(Widget child) {
return new Container( return new Container(
padding: new EdgeDims.all(1.0), padding: new EdgeDims.all(1.0),
margin: new EdgeDims.all(3.0), margin: new EdgeDims.all(3.0),
height: 17.0, width: 17.0, height: 17.0, width: 17.0,
child: widget); child: child
);
} }
class CoveredMineNode extends Component { class CoveredMineNode extends Component {
final Game game;
CoveredMineNode({ this.flagged, this.posX, this.posY });
final bool flagged; final bool flagged;
final int posX; final int posX;
final int posY; final int posY;
Stopwatch stopwatch;
CoveredMineNode(this.game, {this.flagged, this.posX, this.posY});
void _handlePointerDown(sky.PointerEvent event) {
if (event.buttons == 1) {
game.probe(posX, posY);
} else if (event.buttons == 2) {
game.flag(posX, posY);
} else {
// Touch event.
stopwatch = new Stopwatch()..start();
}
}
void _handlePointerUp(sky.PointerEvent event) {
if (stopwatch == null)
return;
// Pointer down was a touch event.
if (stopwatch.elapsedMilliseconds < 250) {
game.probe(posX, posY);
} else {
// Long press flags.
game.flag(posX, posY);
}
stopwatch = null;
}
Widget build() { Widget build() {
Widget text = flagged ? Widget text;
makeInnerCell(new StyledText(elements : [Game.textStyles[5], '\u2691'])) : if (flagged)
null; text = buildInnerCell(new StyledText(elements : [textStyles[5], '\u2691']));
Container inner = new Container( Container inner = new Container(
margin: new EdgeDims.all(2.0), margin: new EdgeDims.all(2.0),
height: 17.0, width: 17.0, height: 17.0, width: 17.0,
decoration: new BoxDecoration(backgroundColor: const Color(0xFFD9D9D9)), decoration: new BoxDecoration(backgroundColor: const Color(0xFFD9D9D9)),
child: text); child: text
);
return makeCell(new Listener( return buildCell(inner);
child: inner,
onPointerDown: _handlePointerDown,
onPointerUp: _handlePointerUp));
} }
} }
class ExposedMineNode extends Component { class ExposedMineNode extends Component {
final int state;
final int count;
ExposedMineNode({this.state, this.count}); ExposedMineNode({ this.state, this.count });
final CellState state;
final int count;
Widget build() { Widget build() {
StyledText text; StyledText text;
if (state == Game.clearedCell) { if (state == CellState.cleared) {
// Uncovered cell with nearby mine count. // Uncovered cell with nearby mine count.
if (count != 0) if (count != 0)
text = new StyledText(elements : [Game.textStyles[count], '$count']); text = new StyledText(elements : [textStyles[count], '$count']);
} else { } else {
// Exploded mine or shown mine for 'game over'. // Exploded mine or shown mine for 'game over'.
int color = state == Game.explodedCell ? 3 : 0; int color = state == CellState.exploded ? 3 : 0;
text = new StyledText(elements : [Game.textStyles[color], '\u2600']); text = new StyledText(elements : [textStyles[color], '\u2600']);
} }
return buildCell(buildInnerCell(text));
return makeCell(makeInnerCell(text));
} }
}
class MineDiggerApp extends App {
Game game;
MineDiggerApp() {
game = new Game(this);
}
Widget build() {
return game.buildUI();
}
} }
void main() { void main() {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment