274 lines
9.0 KiB
TypeScript
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
|
|
}
|
|
}
|