Move change detection control to Field

This commit is contained in:
Florine W. Dekker 2020-08-04 20:26:05 +02:00
parent 551d36ca56
commit b7361256a2
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
5 changed files with 126 additions and 90 deletions

View File

@ -1,6 +1,6 @@
{
"name": "minesweeper",
"version": "0.72.2",
"version": "0.73.0",
"description": "Just Minesweeper!",
"author": "Felix W. Dekker",
"browser": "dist/bundle.js",

View File

@ -156,19 +156,20 @@ export class Display {
}
/**
* Returns the square at the given coordinates, or `null` if there is no square there.
* Returns the square grid coordinates for the given client coordinates.
*
* Note that the returned coordinates need not actually be valid in the current field.
*
* @param pos the client-relative pixel coordinates to find the square at
* @returns the square at the given coordinates
* @returns the square grid coordinates corresponding to the given client coordinates
*/
posToSquare(pos: { x: number, y: number }): Square | null {
posToSquare(pos: { x: number, y: number }): {x: number, y: number} {
const rect = this.canvas.getBoundingClientRect();
const offset = this.getFieldOffset();
return this.field?.getSquareOrElse(
Math.floor((pos.x - rect.left - this.canvas.clientLeft - offset.x) / this.scale),
Math.floor((pos.y - rect.top - this.canvas.clientTop - offset.y) / this.scale),
null
);
return {
x: Math.floor((pos.x - rect.left - this.canvas.clientLeft - offset.x) / this.scale),
y: Math.floor((pos.y - rect.top - this.canvas.clientTop - offset.y) / this.scale)
};
}

View File

@ -101,7 +101,7 @@ export class Field {
*/
onUncover(square: Square): void {
if (square.isCovered)
throw new Error(`Cannot invoke 'onUncover' on covered square at (${square.x}, ${square.y}).`);
throw new Error(`Cannot invoke 'onUncover' on covered square at (${square.coords}).`);
if (!this.started) {
this.history.addAction(new UnAction());
@ -114,7 +114,7 @@ export class Field {
this.coveredRemaining--;
if (this.coveredRemaining === 0) {
this.timer.stop();
this.squareList.filter(it => it.isCovered && !it.hasFlag).forEach(it => it.flag());
this.squareList.filter(it => it.isCovered && !it.hasFlag).forEach(it => this.flag(it.coords));
this.won = true;
}
} else {
@ -139,13 +139,13 @@ export class Field {
copy = this.copy();
copy.history.startSequence();
const copySquare = copy.getSquareOrElse(clickedSquare.x, clickedSquare.y)!;
const copySquare = copy.getSquareOrElse(clickedSquare.coords)!;
copySquare.isCovered = true;
copySquare.uncover();
copy.uncover(copySquare.coords);
copy.history.commitSequence();
new Solver().solve(copy);
} while(!copy.won);
} while (!copy.won);
}
this.clearMines(clickedSquare);
}
@ -181,13 +181,22 @@ export class Field {
/**
* Returns the square at the given coordinates, or `orElse` if there is no square there.
*
* @param x the horizontal coordinate of the square to look up
* @param y the vertical coordinate of the square to look up
* @param coords the coordinates of the square to look up
* @param orElse the value to return if there is no square at the given coordinates
* @returns the square at the given coordinates, or `orElse` if there is no square there
*/
getSquareOrElse(x: number, y: number, orElse: any = undefined): Square | any {
return this.squares[y]?.[x] ?? orElse;
getSquareOrElse(coords: { x: number, y: number }, orElse: any = undefined): Square | any {
return this.squares[coords.y]?.[coords.x] ?? orElse;
}
/**
* Returns `true` if and only if this field contains a square at the given coordinates.
*
* @param coords the coordinates to check
* @returns `true` if and only if this field contains a square at the given coordinates
*/
hasSquareAt(coords: { x: number, y: number }): boolean {
return coords.x >= 0 && coords.x < this.width && coords.y >= 0 && coords.y < this.height;
}
/**
@ -205,6 +214,63 @@ export class Field {
}
/**
* Chords the square at the given position, i.e. if the square is covered and the number of neighboring flags equals
* the number in the square, then all unflagged neighbors are uncovered.
*
* @param coords the coordinates of the square to chord
*/
chord(coords: { x: number, y: number }): void {
const square = this.squares[coords.y][coords.x];
if (square === undefined) throw new Error(`Cannot chord undefined square at (${coords}).`);
if (square.isCovered || this.won || this.lost) return;
if (square.getNeighborCount(it => it.hasFlag) !== square.getNeighborCount(it => it.hasMine)) return;
square.neighbors
.filter(it => it.isCovered && !it.hasFlag)
.forEach(it => this.uncover(it.coords));
}
/**
* Toggles the flag at the given square.
*
* @param coords the coordinates of the square to flag
*/
flag(coords: { x: number, y: number }): void {
const square = this.squares[coords.y][coords.x];
if (square === undefined) throw new Error(`Cannot toggle flag of undefined square at (${coords}).`);
if (!square.isCovered || this.won || this.lost) return;
this.addAction(new FlagAction(square));
square.hasFlag = !square.hasFlag;
}
/**
* Uncovers this square, revealing the contents beneath.
*
* @param coords the coordinates of the square to uncover
*/
uncover(coords: { x: number, y: number }): void {
const square = this.squares[coords.y][coords.x];
if (square === undefined) throw new Error(`Cannot uncover undefined square at (${coords}).`);
if (this.won || this.lost) return;
const uncoverQueue: Square[] = [square];
while (uncoverQueue.length > 0) {
const next = uncoverQueue.pop()!;
if (!next.isCovered || next.hasFlag) continue;
this.addAction(new UncoverAction(next));
next.isCovered = false;
this.onUncover(next); // Also moves mine on first click and swaps mines through field
if (!next.hasMine && next.getNeighborCount(it => it.hasMine || it.hasFlag) === 0)
uncoverQueue.push(...next.neighbors.filter(it => it.isCovered));
}
}
/**
* Runs the given callback such that all calls to `#addAction` can be undone with a single invocation of `#undo`.
*
@ -310,20 +376,27 @@ export class Square {
}
/**
* The coordinates of this square in the field.
*/
get coords(): { x: number, y: number } {
return {x: this.x, y: this.y};
}
/**
* The `Square`s that are adjacent to this square.
*/
get neighbors(): Square[] {
if (this._neighbors === undefined) {
this._neighbors = [
this.field.getSquareOrElse(this.x - 1, this.y - 1),
this.field.getSquareOrElse(this.x, this.y - 1),
this.field.getSquareOrElse(this.x + 1, this.y - 1),
this.field.getSquareOrElse(this.x - 1, this.y),
this.field.getSquareOrElse(this.x + 1, this.y),
this.field.getSquareOrElse(this.x - 1, this.y + 1),
this.field.getSquareOrElse(this.x, this.y + 1),
this.field.getSquareOrElse(this.x + 1, this.y + 1),
this.field.getSquareOrElse({x: this.x - 1, y: this.y - 1}),
this.field.getSquareOrElse({x: this.x, y: this.y - 1}),
this.field.getSquareOrElse({x: this.x + 1, y: this.y - 1}),
this.field.getSquareOrElse({x: this.x - 1, y: this.y}),
this.field.getSquareOrElse({x: this.x + 1, y: this.y}),
this.field.getSquareOrElse({x: this.x - 1, y: this.y + 1}),
this.field.getSquareOrElse({x: this.x, y: this.y + 1}),
this.field.getSquareOrElse({x: this.x + 1, y: this.y + 1}),
].filter(it => it !== undefined);
}
@ -339,52 +412,4 @@ export class Square {
getNeighborCount(property: (neighbor: Square) => boolean): number {
return this.neighbors.filter(property).length;
}
/**
* Chords this square, i.e. if this square is covered and the number of neighboring flags equals the number in this
* square, then all unflagged neighbors are uncovered.
*
* The `#startSequence` function on this square's field must be called before invoking this function.
*/
chord(): void {
if (this.isCovered || this.field.won || this.field.lost) return;
if (this.getNeighborCount(it => it.hasFlag) !== this.getNeighborCount(it => it.hasMine)) return;
this.neighbors
.filter(it => it.isCovered && !it.hasFlag)
.forEach(it => it.uncover());
}
/**
* Adds or removes a flag at this square.
*
* The `#startSequence` function on this square's field must be called before invoking this function.
*/
flag(): void {
if (!this.isCovered || this.field.won || this.field.lost) return;
this.field.addAction(new FlagAction(this));
this.hasFlag = !this.hasFlag;
}
/**
* Uncovers this square, revealing the contents beneath.
*/
uncover(): void {
if (this.field.won || this.field.lost) return;
const uncoverQueue: Square[] = [this];
while (uncoverQueue.length > 0) {
const next = uncoverQueue.pop()!;
if (!next.isCovered || next.hasFlag) continue;
this.field.addAction(new UncoverAction(next));
next.isCovered = false;
this.field.onUncover(next); // Also moves mine on first click and swaps mines through field
if (!next.hasMine && next.getNeighborCount(it => it.hasMine || it.hasFlag) === 0)
uncoverQueue.push(...next.neighbors.filter(it => it.isCovered));
}
}
}

View File

@ -206,7 +206,12 @@ export class Game {
// Canvas
this.canvas.addEventListener(
"mousemove",
event => this.display.mouseSquare = this.display.posToSquare({x: event.clientX, y: event.clientY})
event => {
this.display.mouseSquare = this.field?.getSquareOrElse(
this.display.posToSquare({x: event.clientX, y: event.clientY}),
null
);
}
);
this.canvas.addEventListener(
"mouseleave",
@ -226,13 +231,16 @@ export class Game {
if (this.field === null) return;
this.field.runUndoably(() => {
const square = this.display.posToSquare({x: event.clientX, y: event.clientY});
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
if (this.field === null || !this.field.hasSquareAt(coords)) return;
switch (event.button) {
case 0:
this.leftDown = true;
break;
case 2:
if (!this.leftDown && square !== null) square.flag();
if (!this.leftDown)
this.field.flag(coords);
this.rightDown = true;
break;
@ -249,23 +257,25 @@ export class Game {
if (this.field === null) return;
this.field.runUndoably(() => {
const square = this.display.posToSquare({x: event.clientX, y: event.clientY});
const coords = this.display.posToSquare({x: event.clientX, y: event.clientY});
if (this.field === null || !this.field.hasSquareAt(coords)) return;
switch (event.button) {
case 0:
if (square !== null && this.leftDown && this.rightDown)
square.chord();
else if (square !== null && !this.holdsAfterChord && this.leftDown)
square.uncover();
if (this.leftDown && this.rightDown)
this.field.chord(coords);
else if (!this.holdsAfterChord && this.leftDown)
this.field.uncover(coords);
this.leftDown = false;
this.holdsAfterChord = this.rightDown;
break;
case 1:
if (square !== null) square.chord();
this.field.chord(coords);
break;
case 2:
if (square !== null && this.leftDown && this.rightDown)
square.chord();
if (this.leftDown && this.rightDown)
this.field.chord(coords);
this.rightDown = false;
this.holdsAfterChord = this.leftDown;

View File

@ -16,13 +16,13 @@ export class Solver {
solve(field: Field) {
if (!field.started) {
field.runUndoably(() => {
field.squareList.filter(it => it.hasFlag).forEach(it => it.flag());
field.getSquareOrElse(Math.floor(field.width / 2), Math.floor(field.height / 2)).uncover();
field.squareList.filter(it => it.hasFlag).forEach(it => field.flag(it.coords));
field.uncover({x: Math.floor(field.width / 2), y: Math.floor(field.height / 2)});
});
}
field.runUndoably(() => {
field.squareList.filter(it => it.hasFlag).forEach(it => it.flag());
field.squareList.filter(it => it.hasFlag).forEach(it => field.flag(it.coords));
let flagCount = -1;
let coveredCount = -1;
@ -72,8 +72,8 @@ export class Solver {
system.forEach((it, i) => {
const square = neighs[i];
if (it === 0) square.uncover();
else if (it === 1) square.flag();
if (it === 0) field.uncover(square.coords);
else if (it === 1) field.flag(square.coords);
});
}
}