minesweeper/src/main/js/Display.ts

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";
}