minesweeper/src/main/js/Display.ts

274 lines
9.0 KiB
TypeScript

import {range} from "./Common";
import {Field, Square} from "./Field";
/**
* Displays a Minesweeper field.
*/
export class Display {
private readonly canvas: HTMLCanvasElement;
field: Field;
private scale: number;
mouseSquare: Square | null;
mouseHoldChord: boolean;
private coverSymbol: HTMLCanvasElement | undefined;
private flagSymbol: HTMLCanvasElement | undefined;
private bombSymbol: HTMLCanvasElement | undefined;
private deadSymbol: HTMLCanvasElement | undefined;
private wrongSymbol: HTMLCanvasElement | undefined;
private digitSymbols: HTMLCanvasElement[] | undefined;
private winSymbol: HTMLCanvasElement | undefined;
private loseSymbol: HTMLCanvasElement | undefined;
/**
* Constructs a new display.
*
* @param canvas the canvas to draw the field in
* @param field the field to draw
*/
constructor(canvas: HTMLCanvasElement, field: Field) {
this.canvas = canvas;
this.field = field;
this.scale = 10;
this.mouseSquare = null;
this.mouseHoldChord = false;
this.initSymbols();
}
/**
* Initializes commonly-used symbols into pre-rendered canvases for easy copy-pasting during the draw loop.
*/
initSymbols(): void {
let ctx: CanvasRenderingContext2D;
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 = `bold ${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));
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("\uf024", "ForkAwesome", "#f00");
this.bombSymbol = createCanvas(this.scale, this.scale);
ctx = this.bombSymbol.getContext("2d")!;
fillText("\uf1e2", "ForkAwesome");
this.deadSymbol = createCanvas(this.scale, this.scale);
ctx = this.deadSymbol.getContext("2d")!;
fillText("\uf1e2", "ForkAwesome", "#f00");
this.wrongSymbol = createCanvas(this.scale, this.scale);
ctx = this.wrongSymbol.getContext("2d")!;
fillText("\uf1e2", "ForkAwesome");
fillText("\uf00d", "ForkAwesome", "#f00");
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.winSymbol = createCanvas(this.scale, this.scale);
ctx = this.winSymbol.getContext("2d")!;
fillText("\uf25b", "ForkAwesome");
this.loseSymbol = createCanvas(this.scale, this.scale);
ctx = this.loseSymbol.getContext("2d")!;
fillText("\uf0f9", "ForkAwesome");
}
/**
* 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 square at the given coordinates, or `null` if there is no square there.
*
* @param pos the client-relative pixel coordinates to find the square at
* @return the square at the given coordinates
*/
posToSquare(pos: { x: number, y: number }): Square | null {
const rect = this.canvas.getBoundingClientRect();
return this.field.getSquareOrElse(
Math.floor((pos.x - rect.left) / this.scale),
Math.floor((pos.y - rect.top) / this.scale),
null
);
}
/**
* Rescales the display appropriately.
*
* @param scale the size of a square in pixels
*/
setScale(scale: number): void {
this.scale = scale;
this.canvas.width = this.field.width * this.scale;
this.canvas.height = this.field.height * this.scale + this.scale;
this.initSymbols();
}
/**
* 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", {alpha: false})!;
const rect = this.canvas.getBoundingClientRect();
const scale = this.scale;
// Clear
ctx.save();
ctx.fillStyle = "#bdbdbd";
ctx.fillRect(0, 0, rect.width, rect.height);
ctx.restore();
// Create grid
ctx.save();
ctx.strokeStyle = "#7b7b7b";
ctx.beginPath();
for (let x = 0; x <= this.field.width; x++) {
ctx.moveTo(x * scale, 0);
ctx.lineTo(x * scale, this.field.height * scale);
}
for (let y = 0; y <= this.field.height; y++) {
ctx.moveTo(0, y * scale);
ctx.lineTo(this.field.width * scale, y * scale);
}
ctx.stroke();
ctx.restore();
// Cover squares
ctx.save();
ctx.fillStyle = "#555";
this.field.squareList
.filter(it => it.isCovered)
.filter(it => {
// True if square should be covered
if (!this.mouseHoldChord || this.mouseSquare === null) return true;
if (this.mouseSquare === it || this.mouseSquare.getNeighbors().indexOf(it) >= 0) return it.hasFlag;
return true;
})
.forEach(square => ctx.drawImage(this.coverSymbol!, square.x * scale, square.y * scale));
ctx.restore();
// Fill squares
ctx.save();
ctx.fillStyle = "#000";
ctx.font = scale + "px serif";
ctx.textBaseline = "middle";
ctx.textAlign = "center";
this.field.squareList.forEach(square => {
let icon;
if (square.hasFlag) {
if (this.field.lost && !square.hasMine)
icon = this.wrongSymbol;
else
icon = this.flagSymbol;
} else if (square.hasMine) {
if (square.isCovered && this.field.lost)
icon = this.bombSymbol;
else if (!square.isCovered)
icon = this.deadSymbol;
} else if (!square.isCovered) {
icon = this.digitSymbols![square.getNeighborCount(it => it.hasMine)];
}
if (icon !== undefined) ctx.drawImage(icon, square.x * scale, square.y * scale);
});
ctx.restore();
// Draw bottom info
ctx.save();
ctx.fillStyle = "#000";
ctx.font = scale + "px serif";
ctx.textBaseline = "top";
ctx.textAlign = "left";
ctx.fillText(
`${this.field.squareList.filter(it => it.hasFlag).length}/${this.field.mineCount}`,
0, this.field.height * scale
);
if (this.field.won || this.field.lost) {
ctx.drawImage(
this.field.won ? this.winSymbol! : this.loseSymbol!,
Math.floor((this.field.width - 0.5) * scale / 2), this.field.height * scale
);
}
ctx.textAlign = "right";
ctx.fillText(
"" + Math.floor(this.field.getTime() / 1000),
this.field.width * scale, this.field.height * scale
);
ctx.restore();
// Done
}
}