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