518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
// @ts-ignore
|
|
import confetti from "canvas-confetti";
|
|
import {formatTime, range} from "./Common";
|
|
import {Field, Square} from "./Field";
|
|
import {Preferences} from "./Preferences";
|
|
|
|
|
|
/**
|
|
* Displays a Minesweeper field.
|
|
*/
|
|
export class Display {
|
|
private readonly errorColor: string = "rgba(255, 0, 0, 0.3)";
|
|
private readonly hintColor: string = "rgba(0, 0, 255, 0.3)";
|
|
private readonly safeColor: string = "rgba(0, 255, 0, 0.5)";
|
|
|
|
private readonly scale: number = 30;
|
|
private readonly minSquareWidth: number = 6;
|
|
|
|
private readonly canvas: HTMLCanvasElement;
|
|
private readonly preferences: Preferences;
|
|
|
|
private field: Field | null = null;
|
|
private winTime: number | null = null;
|
|
private loseTime: number | null = null;
|
|
hintSquare: Square | null = null;
|
|
mouseSquare: Square | null = null;
|
|
mouseHoldUncover: boolean = false;
|
|
mouseHoldChord: boolean = false;
|
|
|
|
private coverSymbol: HTMLCanvasElement | undefined;
|
|
private flagSymbol: HTMLCanvasElement | undefined;
|
|
private markSymbol: HTMLCanvasElement | undefined;
|
|
private uncoveredMineSymbol: HTMLCanvasElement | undefined;
|
|
private mineSymbol: HTMLCanvasElement | undefined;
|
|
private digitSymbols: HTMLCanvasElement[] | undefined;
|
|
private clockSymbol: HTMLCanvasElement | undefined;
|
|
private deathsSymbolA: HTMLCanvasElement | undefined;
|
|
private deathsSymbolB: HTMLCanvasElement | undefined;
|
|
|
|
|
|
/**
|
|
* Constructs a new display.
|
|
*
|
|
* @param canvas the canvas to draw the field in
|
|
* @param field the field to draw
|
|
* @param preferences the player's preferences; may be changed at any time
|
|
*/
|
|
constructor(canvas: HTMLCanvasElement, field: Field | null, preferences: Preferences) {
|
|
this.canvas = canvas;
|
|
this.preferences = preferences;
|
|
|
|
this.setField(field);
|
|
}
|
|
|
|
/**
|
|
* Initializes commonly-used symbols into pre-rendered canvases for easy copy-pasting during the draw loop.
|
|
*/
|
|
initSymbols(): void {
|
|
let ctx: CanvasRenderingContext2D;
|
|
const font = this.preferences.font;
|
|
const createCanvas = (width: number, height: number) => {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
return canvas;
|
|
};
|
|
const fillText = (text: string, font: string, color: string = "#000") => {
|
|
ctx.save();
|
|
ctx.fillStyle = color;
|
|
ctx.font = `${Math.floor(this.scale * 0.55)}px ${font}`;
|
|
ctx.textBaseline = "middle";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(text, Math.floor(this.scale / 2), Math.floor(this.scale / 2), this.scale);
|
|
ctx.restore();
|
|
};
|
|
|
|
this.coverSymbol = createCanvas(this.scale, this.scale);
|
|
this.drawCoverSymbol(this.coverSymbol.getContext("2d")!);
|
|
|
|
this.flagSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.flagSymbol.getContext("2d")!;
|
|
fillText(font.flag, font.fontFace, "#f00");
|
|
|
|
this.markSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.markSymbol.getContext("2d")!;
|
|
fillText(font.mark, font.fontFace, "#00f");
|
|
|
|
this.uncoveredMineSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.uncoveredMineSymbol.getContext("2d")!;
|
|
ctx.fillStyle = "#f00";
|
|
ctx.fillRect(1, 1, this.scale - 2, this.scale - 2);
|
|
fillText(font.uncoveredMine, font.fontFace, "#000");
|
|
|
|
this.mineSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.mineSymbol.getContext("2d")!;
|
|
fillText(font.mine, font.fontFace, "#00007b");
|
|
|
|
const digitColors =
|
|
["", "#0000ff", "#007b00", "#ff0000", "#00007b", "#7b0000", "#007b7b", "#000000", "#7b7b7b"];
|
|
this.digitSymbols = range(10).map(digit => {
|
|
const canvas = createCanvas(this.scale, this.scale);
|
|
ctx = canvas.getContext("2d")!;
|
|
if (digit !== 0) fillText("" + digit, "Courier New", digitColors[digit]);
|
|
return canvas;
|
|
});
|
|
|
|
this.clockSymbol = createCanvas(this.scale, this.scale);
|
|
ctx = this.clockSymbol.getContext("2d")!;
|
|
fillText(font.clock, font.fontFace);
|
|
|
|
this.deathsSymbolA = createCanvas(this.scale, this.scale);
|
|
ctx = this.deathsSymbolA.getContext("2d")!;
|
|
fillText(font.deaths, font.fontFace, "#fff");
|
|
|
|
this.deathsSymbolB = createCanvas(this.scale, this.scale);
|
|
ctx = this.deathsSymbolB.getContext("2d")!;
|
|
fillText(font.deaths, font.fontFace, "#f00");
|
|
}
|
|
|
|
/**
|
|
* Helper method for `#initSymbols` to fill the given canvas with the cover symbol.
|
|
*
|
|
* @param ctx the context to fill with the cover symbol
|
|
*/
|
|
drawCoverSymbol(ctx: CanvasRenderingContext2D): void {
|
|
const coverBorderThickness = Math.floor(this.scale / 10);
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.fillStyle = "#7b7b7b";
|
|
ctx.moveTo(this.scale, 0);
|
|
ctx.lineTo(this.scale, this.scale);
|
|
ctx.lineTo(0, this.scale);
|
|
ctx.lineTo(coverBorderThickness, this.scale - coverBorderThickness);
|
|
ctx.lineTo(this.scale - coverBorderThickness, this.scale - coverBorderThickness);
|
|
ctx.lineTo(this.scale - coverBorderThickness, coverBorderThickness);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.fillStyle = "#fff";
|
|
ctx.moveTo(0, this.scale);
|
|
ctx.lineTo(0, 0);
|
|
ctx.lineTo(this.scale, 0);
|
|
ctx.lineTo(this.scale - coverBorderThickness, coverBorderThickness);
|
|
ctx.lineTo(coverBorderThickness, coverBorderThickness);
|
|
ctx.lineTo(coverBorderThickness, this.scale - coverBorderThickness);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the offset of the field with respect to the borders.
|
|
*
|
|
* @returns the offset of the field with respect to the borders.
|
|
* @private
|
|
*/
|
|
private getFieldOffset(): { x: number, y: number } {
|
|
if (this.field == null) return {x: 0, y: 0};
|
|
if (this.field.width >= this.minSquareWidth) return {x: 0, y: 0};
|
|
return {x: Math.floor((this.minSquareWidth - this.field.width) * this.scale / 2), y: 0};
|
|
}
|
|
|
|
/**
|
|
* Changes the field to draw.
|
|
*
|
|
* @param field the field to draw, or `null` if no field should be drawn
|
|
*/
|
|
setField(field: Field | null): void {
|
|
this.hintSquare = null;
|
|
this.field = field;
|
|
if (this.field == null) return;
|
|
|
|
this.canvas.width = Math.max(this.minSquareWidth, this.field.width) * this.scale;
|
|
this.canvas.height = this.field.height * this.scale + this.scale;
|
|
this.initSymbols();
|
|
}
|
|
|
|
/**
|
|
* 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 grid coordinates corresponding to the given client coordinates
|
|
*/
|
|
posToSquare(pos: { x: number, y: number }): { x: number, y: number } {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const offset = this.getFieldOffset();
|
|
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)
|
|
};
|
|
}
|
|
|
|
|
|
/**
|
|
* Invokes `#draw` in every animation frame of this window.
|
|
*/
|
|
startDrawLoop(): void {
|
|
const cb = () => {
|
|
this.draw();
|
|
window.requestAnimationFrame(cb);
|
|
};
|
|
window.requestAnimationFrame(cb);
|
|
}
|
|
|
|
/**
|
|
* Draws the field.
|
|
*/
|
|
draw(): void {
|
|
const ctx = this.canvas.getContext("2d")!;
|
|
const {x, y} = this.getFieldOffset();
|
|
|
|
this.clearCanvas(ctx);
|
|
if (this.field == null) return;
|
|
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
this.drawGrid(ctx);
|
|
this.drawCovers(ctx);
|
|
this.drawHints(ctx);
|
|
this.drawSymbols(ctx);
|
|
ctx.restore();
|
|
|
|
this.drawStatusBar(ctx);
|
|
this.drawWinConfetti();
|
|
}
|
|
|
|
/**
|
|
* Empties the canvas.
|
|
*
|
|
* @param ctx the drawing context
|
|
* @private
|
|
*/
|
|
private clearCanvas(ctx: CanvasRenderingContext2D): void {
|
|
ctx.save();
|
|
ctx.fillStyle = "#bdbdbd";
|
|
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draws the square grid.
|
|
*
|
|
* @param ctx the drawing context
|
|
* @private
|
|
*/
|
|
private drawGrid(ctx: CanvasRenderingContext2D): void {
|
|
if (this.field == null) return;
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = "#7b7b7b";
|
|
for (let x = 0; x <= this.field.width; x++) {
|
|
ctx.moveTo(x * this.scale, 0);
|
|
ctx.lineTo(x * this.scale, this.field.height * this.scale);
|
|
}
|
|
for (let y = 0; y <= this.field.height; y++) {
|
|
ctx.moveTo(0, y * this.scale);
|
|
ctx.lineTo(this.field.width * this.scale, y * this.scale);
|
|
}
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draws covers over all covered squares.
|
|
*
|
|
* @param ctx the drawing context
|
|
* @private
|
|
*/
|
|
private drawCovers(ctx: CanvasRenderingContext2D): void {
|
|
if (this.field == null) return;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = "#555";
|
|
this.field.squareList
|
|
.filter(it => it.isCovered)
|
|
.filter(it => {
|
|
// True if square should be covered
|
|
if (this.field!.isOver || this.mouseSquare == null)
|
|
return true;
|
|
if (this.mouseHoldUncover && this.mouseSquare === it)
|
|
return it.hasFlag || it.hasMark;
|
|
if (this.mouseHoldChord && (this.mouseSquare === it || this.mouseSquare.neighbors.indexOf(it) >= 0))
|
|
return it.hasFlag || it.hasMark;
|
|
|
|
return true;
|
|
})
|
|
.forEach(square => ctx.drawImage(this.coverSymbol!, square.x * this.scale, square.y * this.scale));
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draws hints displaying squares with errors.
|
|
*
|
|
* @param ctx the drawing context
|
|
* @private
|
|
*/
|
|
private drawHints(ctx: CanvasRenderingContext2D): void {
|
|
if (this.field == null) return;
|
|
|
|
let madeMistakes = false;
|
|
let showsHint = false;
|
|
|
|
if (this.hintSquare != null) {
|
|
ctx.save();
|
|
ctx.fillStyle = this.hintColor;
|
|
ctx.fillRect(this.hintSquare.x * this.scale, this.hintSquare.y * this.scale, this.scale, this.scale);
|
|
ctx.restore();
|
|
|
|
showsHint = true;
|
|
}
|
|
|
|
if (!showsHint && this.preferences.showTooManyFlagsHints) {
|
|
ctx.save();
|
|
ctx.fillStyle = this.errorColor;
|
|
madeMistakes = madeMistakes || this.field.squareList
|
|
.filter(it => !it.isCovered)
|
|
.filter(it => it.getNeighborCount(it => it.hasMine) < it.getNeighborCount(it => it.hasFlag))
|
|
.map(square => ctx.fillRect(square.x * this.scale, square.y * this.scale, this.scale, this.scale))
|
|
.length > 0;
|
|
ctx.restore();
|
|
}
|
|
|
|
if (
|
|
!showsHint && !madeMistakes &&
|
|
(this.preferences.showChordableHints || this.preferences.showAllNeighborsAreMinesHints)
|
|
) {
|
|
ctx.save();
|
|
ctx.fillStyle = this.safeColor;
|
|
this.field.squareList
|
|
.filter(it => !it.isCovered)
|
|
.filter(it => {
|
|
const mines = it.getNeighborCount(it => it.hasMine);
|
|
const flags = it.getNeighborCount(it => it.hasFlag);
|
|
const covered = it.getNeighborCount(it => it.isCovered);
|
|
|
|
return (
|
|
(this.preferences.showChordableHints && mines === flags && covered !== flags) ||
|
|
(this.preferences.showAllNeighborsAreMinesHints && mines === covered && mines !== flags)
|
|
);
|
|
})
|
|
.forEach(square => ctx.fillRect(square.x * this.scale, square.y * this.scale, this.scale, this.scale));
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draws symbols such as flags, mines, and digits.
|
|
*
|
|
* @param ctx the drawing context
|
|
* @private
|
|
*/
|
|
private drawSymbols(ctx: CanvasRenderingContext2D): void {
|
|
if (this.field == null) return;
|
|
|
|
ctx.save();
|
|
this.field.squareList.forEach(square => {
|
|
if (this.field == null) return;
|
|
|
|
let icon;
|
|
if (square.hasFlag)
|
|
icon = this.flagSymbol;
|
|
else if (square.hasMark)
|
|
icon = this.markSymbol;
|
|
else if (square.hasMine && !square.isCovered)
|
|
icon = this.uncoveredMineSymbol;
|
|
else if (!square.isCovered)
|
|
icon = this.digitSymbols![square.getNeighborCount(it => it.hasMine)];
|
|
|
|
if (icon !== undefined) ctx.drawImage(icon, square.x * this.scale, square.y * this.scale);
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draws the status bar with remaining mines and the time.
|
|
*
|
|
* @param ctx the drawing context
|
|
* @private
|
|
*/
|
|
private drawStatusBar(ctx: CanvasRenderingContext2D): void {
|
|
if (this.field == null) return;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = "#000";
|
|
ctx.font = Math.floor(0.55 * this.scale) + "px Courier New";
|
|
ctx.textBaseline = "middle";
|
|
ctx.textAlign = "left";
|
|
|
|
// Mine count
|
|
ctx.drawImage(
|
|
this.mineSymbol!,
|
|
0,
|
|
Math.floor(this.canvas.height - this.scale)
|
|
);
|
|
ctx.fillText(
|
|
`${this.field.mineCount - this.field.flagCount}`,
|
|
this.scale,
|
|
Math.floor(this.canvas.height - 0.5 * this.scale),
|
|
this.scale
|
|
);
|
|
|
|
// Deaths
|
|
let deathsSymbol;
|
|
if (this.field.hasLost) {
|
|
if (this.loseTime == null)
|
|
this.loseTime = Date.now();
|
|
|
|
deathsSymbol = Math.floor((Date.now() - this.loseTime) / 1000) % 2 === 0
|
|
? this.deathsSymbolB
|
|
: this.deathsSymbolA;
|
|
} else {
|
|
this.loseTime = null;
|
|
deathsSymbol = this.deathsSymbolA;
|
|
}
|
|
ctx.drawImage(
|
|
deathsSymbol!,
|
|
Math.floor(this.canvas.width / 2 - this.scale),
|
|
Math.floor(this.canvas.height - this.scale)
|
|
);
|
|
ctx.fillText(
|
|
"" + this.field.deathCount,
|
|
Math.floor(this.canvas.width / 2),
|
|
Math.floor(this.canvas.height - 0.5 * this.scale),
|
|
this.scale
|
|
);
|
|
|
|
// Time
|
|
ctx.drawImage(
|
|
this.clockSymbol!,
|
|
Math.floor(this.canvas.width - 2 * this.scale),
|
|
Math.floor(this.canvas.height - this.scale)
|
|
);
|
|
ctx.fillText(
|
|
formatTime(Math.floor(this.field.elapsedTime / 1000), true, false),
|
|
Math.floor(this.canvas.width - this.scale),
|
|
Math.floor(this.canvas.height - 0.5 * this.scale),
|
|
this.scale
|
|
);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draws confetti once once the player wins.
|
|
*
|
|
* @private
|
|
*/
|
|
private drawWinConfetti(): void {
|
|
if (this.field == null) return;
|
|
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
if (this.field.hasWon && this.winTime == null) {
|
|
confetti({
|
|
origin: {
|
|
x: (rect.left + rect.width / 2) / document.documentElement.clientWidth,
|
|
y: (rect.top + rect.height / 2) / document.documentElement.clientHeight
|
|
},
|
|
spread: 360,
|
|
initialVelocity: 10
|
|
});
|
|
this.winTime = Date.now();
|
|
} else if (!this.field.hasWon) {
|
|
this.winTime = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* A font that can be used to display icons for the display.
|
|
*/
|
|
export interface IconFont {
|
|
fontFace: string;
|
|
|
|
flag: string;
|
|
mark: string;
|
|
uncoveredMine: string;
|
|
mine: string;
|
|
clock: string;
|
|
deaths: string;
|
|
}
|
|
|
|
/**
|
|
* A basic font that can be displayed in any browser.
|
|
*/
|
|
export class BasicIconFont implements IconFont {
|
|
fontFace = "Courier New";
|
|
|
|
flag = "F";
|
|
mark = "?";
|
|
uncoveredMine = "X";
|
|
mine = "Mines";
|
|
clock = "Time";
|
|
deaths = "Deaths";
|
|
}
|
|
|
|
/**
|
|
* ForkAwesome, which can be used on any browser that does not block external fonts.
|
|
*/
|
|
export class ForkAwesomeFont implements IconFont {
|
|
fontFace = "ForkAwesome";
|
|
|
|
flag = "\uf024";
|
|
mark = "\uf059";
|
|
uncoveredMine = "\uf1e2";
|
|
mine = "\uf1e2";
|
|
clock = "\uf017";
|
|
deaths = "\uf0f9";
|
|
}
|