From ddb27c8061d5a63e588c87ef5476a5d44c42b46c Mon Sep 17 00:00:00 2001 From: "Felix W. Dekker" Date: Wed, 2 Sep 2020 17:27:37 +0200 Subject: [PATCH] Remove duplication by separating types in Storage --- package.json | 2 +- src/main/js/Common.ts | 112 +++++++++++++------------- src/main/js/Display.ts | 2 +- src/main/js/Field.ts | 10 ++- src/main/js/Game.ts | 39 +--------- src/main/js/Main.ts | 3 +- src/main/js/Preferences.ts | 44 +++++++++++ src/main/js/Statistics.ts | 92 ++++------------------ src/main/js/Storage.ts | 156 +++++++++++++++++++++++++++---------- 9 files changed, 249 insertions(+), 211 deletions(-) create mode 100644 src/main/js/Preferences.ts diff --git a/package.json b/package.json index 2147f08..7819ac4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minesweeper", - "version": "0.81.10", + "version": "0.81.11", "description": "Just Minesweeper!", "author": "Felix W. Dekker", "browser": "dist/bundle.js", diff --git a/src/main/js/Common.ts b/src/main/js/Common.ts index 4b09588..7e99d28 100644 --- a/src/main/js/Common.ts +++ b/src/main/js/Common.ts @@ -2,24 +2,6 @@ import alea from "alea"; -/** - * Shuffles the given array in-place. - * - * @param array the array to shuffle - * @param seed the seed for the random number generator - * @returns the array that was given to this function to shuffle - */ -export function shuffleArrayInPlace(array: any[], seed: number): any[] { - const rng = alea("" + seed); - - for (let i = array.length - 1; i > 0; i--) { - const j = rng.uint32() % (i + 1); - [array[i], array[j]] = [array[j], array[i]]; - } - - return array; -} - /** * Blurs the currently active element, if possible. */ @@ -44,6 +26,28 @@ export function chunkifyArray(array: any[], chunkSize: number): any[] { return chunks; } +/** + * Formats the given time into a nice representation of `hh:mm:ss`. + * + * @param seconds the number of seconds; the time to be formatted + * @param minutes whether to include minutes + * @param hours whether to include hours; requires that `minutes` is true + * @return the formatted time + */ +export function formatTime(seconds: number, minutes: boolean, hours: boolean): string { + if (!minutes && hours) throw new Error("Cannot format time with hours but without minutes."); + if (!minutes && !hours) return "" + seconds; + + const secondsString = ("" + (seconds % 60)).padStart(2, '0'); + const minutesString = hours + ? ("" + Math.floor((seconds % 3600) / 60)).padStart(2, '0') + : ("" + Math.floor(seconds / 60)); + if (!hours) return `${minutesString}:${secondsString}`; + + const hoursString = Math.floor(seconds / 3600); + return `${hoursString}:${minutesString}:${secondsString}`; +} + /** * Creates an array of `size` consecutive integers starting at `startAt`. * @@ -57,6 +61,40 @@ export function range(length: number, beginAt: number = 0): number[] { return [...Array(length).keys()].map(i => i + beginAt); } +/** + * Shuffles the given array in-place. + * + * @param array the array to shuffle + * @param seed the seed for the random number generator + * @returns the array that was given to this function to shuffle + */ +export function shuffleArrayInPlace(array: any[], seed: number): any[] { + const rng = alea("" + seed); + + for (let i = array.length - 1; i > 0; i--) { + const j = rng.uint32() % (i + 1); + [array[i], array[j]] = [array[j], array[i]]; + } + + return array; +} + +/** + * Hashes the given string. + * + * @param string the string to hash + * @returns the hash of the string + */ +export function stringToHash(string: string): number { + let hash = 0; + for (let i = 0; i < string.length; i++) { + const chr = string.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; + } + return hash; +} + /** * Waits for FontAwesome to have loaded and then invokes the callback. * @@ -112,41 +150,3 @@ export function waitForForkAwesome(onSuccess: () => void, onFailure: () => void, return count; } } - -/** - * Hashes the given string. - * - * @param string the string to hash - * @returns the hash of the string - */ -export function stringToHash(string: string): number { - let hash = 0; - for (let i = 0; i < string.length; i++) { - const chr = string.charCodeAt(i); - hash = ((hash << 5) - hash) + chr; - hash |= 0; - } - return hash; -} - -/** - * Formats the given time into a nice representation of `hh:mm:ss`. - * - * @param seconds the number of seconds; the time to be formatted - * @param minutes whether to include minutes - * @param hours whether to include hours; requires that `minutes` is true - * @return the formatted time - */ -export function formatTime(seconds: number, minutes: boolean, hours: boolean): string { - if (!minutes && hours) throw new Error("Cannot format time with hours but without minutes."); - if (!minutes && !hours) return "" + seconds; - - const secondsString = ("" + (seconds % 60)).padStart(2, '0'); - const minutesString = hours - ? ("" + Math.floor((seconds % 3600) / 60)).padStart(2, '0') - : ("" + Math.floor(seconds / 60)); - if (!hours) return `${minutesString}:${secondsString}`; - - const hoursString = Math.floor(seconds / 3600); - return `${hoursString}:${minutesString}:${secondsString}`; -} diff --git a/src/main/js/Display.ts b/src/main/js/Display.ts index 653d8cd..95e325f 100644 --- a/src/main/js/Display.ts +++ b/src/main/js/Display.ts @@ -2,7 +2,7 @@ import confetti from "canvas-confetti"; import {formatTime, range} from "./Common"; import {Field, Square} from "./Field"; -import {Preferences} from "./Game"; +import {Preferences} from "./Preferences"; /** diff --git a/src/main/js/Field.ts b/src/main/js/Field.ts index 185904d..352f614 100644 --- a/src/main/js/Field.ts +++ b/src/main/js/Field.ts @@ -3,7 +3,8 @@ import alea from "alea"; import {Action, ActionHistory} from "./Action"; import {chunkifyArray, shuffleArrayInPlace} from "./Common"; import {Solver} from "./Solver"; -import {MemoryStatistics, Statistics} from "./Statistics"; +import {Statistics} from "./Statistics"; +import {MemoryStorage} from "./Storage"; import {Timer} from "./Timer"; @@ -95,7 +96,12 @@ export class Field { * @returns a deep copy of this field */ copy(): Field { - const copy = new Field(this.width, this.height, this.mineCount, false, 0, new MemoryStatistics()); + const copy = new Field( + this.width, this.height, + this.mineCount, + false, 0, + new Statistics(new MemoryStorage()) + ); copy.squareList.length = 0; copy.squareList.push(...this.squareList.map(it => it.copy(copy))); diff --git a/src/main/js/Game.ts b/src/main/js/Game.ts index 023cbb5..b00c77e 100644 --- a/src/main/js/Game.ts +++ b/src/main/js/Game.ts @@ -4,11 +4,11 @@ import {$} from "@fwdekker/template"; import alea from "alea"; import {blurActiveElement, stringToHash} from "./Common"; import {customDifficulty, defaultDifficulty, difficulties} from "./Difficulty"; -import {BasicIconFont, Display, ForkAwesomeFont, IconFont} from "./Display"; +import {Display} from "./Display"; import {Field} from "./Field"; +import {Preferences} from "./Preferences"; import {Solver} from "./Solver"; -import {LocalStatistics} from "./Statistics"; -import {Storage} from "./Storage"; +import {Statistics} from "./Statistics"; import {Overlay} from "./UI"; @@ -16,7 +16,7 @@ import {Overlay} from "./UI"; * Controls the interaction with a game of Minesweeper. */ export class Game { - private readonly statistics = new LocalStatistics(); + private readonly statistics = new Statistics(); private statisticsTimer: number | undefined; private readonly canvas: HTMLCanvasElement; @@ -452,34 +452,3 @@ export class Game { }, 1000); } } - -/** - * The player's preferences. - * - * Contains a mixture of persistent and transient preferences. - */ -export class Preferences { - private readonly storage = new Storage("/tools/minesweeper//preferences"); - - - /** - * The font to be used when drawing the display. - */ - font: IconFont = new BasicIconFont(); - - get marksEnabled(): boolean { - return this.storage.getBoolean("marksEnabled", true); - } - - set marksEnabled(value: boolean) { - this.storage.setBoolean("marksEnabled", value); - } - - get showTooManyFlagsHints(): boolean { - return this.storage.getBoolean("showTooManyFlagsHints", true); - } - - set showTooManyFlagsHints(value: boolean) { - this.storage.setBoolean("showTooManyFlagsHints", value); - } -} diff --git a/src/main/js/Main.ts b/src/main/js/Main.ts index 08ee41c..99d0dd0 100644 --- a/src/main/js/Main.ts +++ b/src/main/js/Main.ts @@ -4,7 +4,8 @@ import "../css/main.css"; import {$, doAfterLoad, footer, header, nav} from "@fwdekker/template"; import {waitForForkAwesome} from "./Common"; import {BasicIconFont, ForkAwesomeFont} from "./Display"; -import {Game, Preferences} from "./Game"; +import {Game} from "./Game"; +import {Preferences} from "./Preferences"; doAfterLoad(() => { diff --git a/src/main/js/Preferences.ts b/src/main/js/Preferences.ts new file mode 100644 index 0000000..56a47e5 --- /dev/null +++ b/src/main/js/Preferences.ts @@ -0,0 +1,44 @@ +import {BasicIconFont, IconFont} from "./Display"; +import {LocalStorage, Storage} from "./Storage"; + + +/** + * The player's preferences. + * + * Contains a mixture of persistent and transient preferences. + */ +export class Preferences { + private readonly storage: Storage; + + + /** + * Constructs a new preferences container. + * + * @param storage the underlying object to store preferences in + */ + constructor(storage: Storage = new LocalStorage("/tools/minesweeper//preferences")) { + this.storage = storage; + } + + + /** + * The font to be used when drawing the display. + */ + font: IconFont = new BasicIconFont(); + + get marksEnabled(): boolean { + return this.storage.getBoolean("marksEnabled", true); + } + + set marksEnabled(value: boolean) { + this.storage.setBoolean("marksEnabled", value); + } + + get showTooManyFlagsHints(): boolean { + return this.storage.getBoolean("showTooManyFlagsHints", true); + } + + set showTooManyFlagsHints(value: boolean) { + this.storage.setBoolean("showTooManyFlagsHints", value); + } +} diff --git a/src/main/js/Statistics.ts b/src/main/js/Statistics.ts index 03d1df3..139a84b 100644 --- a/src/main/js/Statistics.ts +++ b/src/main/js/Statistics.ts @@ -1,45 +1,27 @@ import {formatTime} from "./Common"; -import {Storage} from "./Storage"; +import {LocalStorage, Storage} from "./Storage"; -/** - * A storage for game statistics. - */ -export interface Statistics { - actionsUndone: number; - actionsRedone: number; - lossesUndone: number; - lossesRedone: number; - timeSpent: number; - - gamesStarted: number; - gamesWon: number; - gamesWonWithoutLosing: number; - - squaresChorded: number; - squaresChordedLeadingToLoss: number; - squaresFlagged: number; - squaresMarked: number; - squaresUncovered: number; - minesUncovered: number; - - hintsRequested: number; - solverUsages: number; - - - /** - * Resets the statistics. - */ - clear(): void; -} - /** * Stores game statistics in the browser's localstorage. */ -export class LocalStatistics implements Statistics { - private readonly storage = new Storage("/tools/minesweeper//statistics"); +export class Statistics implements Statistics { + private readonly storage: Storage; + /** + * Constructs a new statistics container. + * + * @param storage the underlying object to store statistics in + */ + constructor(storage: Storage = new LocalStorage("/tools/minesweeper//statistics")) { + this.storage = storage; + } + + + /** + * Clears all statistics. + */ clear(): void { this.storage.clear(); } @@ -261,45 +243,3 @@ export class LocalStatistics implements Statistics { `; } } - -/** - * Stores game statistics in memory, right here in the fields of this object. - */ -export class MemoryStatistics implements Statistics { - actionsUndone: number = 0; - actionsRedone: number = 0; - lossesUndone: number = 0; - lossesRedone: number = 0; - timeSpent: number = 0; - gamesStarted: number = 0; - gamesWon: number = 0; - gamesWonWithoutLosing: number = 0; - squaresChorded: number = 0; - squaresChordedLeadingToLoss: number = 0; - squaresFlagged: number = 0; - squaresMarked: number = 0; - squaresUncovered: number = 0; - minesUncovered: number = 0; - hintsRequested: number = 0; - solverUsages: number = 0; - - - clear() { - this.actionsUndone = 0; - this.actionsRedone = 0; - this.lossesUndone = 0; - this.lossesRedone = 0; - this.timeSpent = 0; - this.gamesStarted = 0; - this.gamesWon = 0; - this.gamesWonWithoutLosing = 0; - this.squaresChorded = 0; - this.squaresChordedLeadingToLoss = 0; - this.squaresFlagged = 0; - this.squaresMarked = 0; - this.squaresUncovered = 0; - this.minesUncovered = 0; - this.hintsRequested = 0; - this.solverUsages = 0; - } -} diff --git a/src/main/js/Storage.ts b/src/main/js/Storage.ts index 28418c6..b7588e0 100644 --- a/src/main/js/Storage.ts +++ b/src/main/js/Storage.ts @@ -1,9 +1,72 @@ /** - * Persistent storage, in particular `localStorage`. + * Stores key-value pairs. */ -export class Storage { +export interface Storage { + /** + * Removes the data from storage. + */ + clear(): void + + /** + * Retrieves an array from storage. + * + * @param name the name of the array to retrieve + * @param def the value to return if no array is stored with the given name + */ + getArray(name: string, def: any[]): any[] + + /** + * Stores an array. + * + * @param name the name of the array to store + * @param value the array to store under the given name + * @protected + */ + setArray(name: string, value: any[]): void + + /** + * Retrieves a boolean from storage. + * + * @param name the name of the boolean to retrieve + * @param def the value to return if no boolean is stored with the given name + * @protected + */ + getBoolean(name: string, def: boolean): boolean + + /** + * Stores a boolean. + * + * @param name the name of the boolean to store + * @param value the boolean to store under the given name + * @protected + */ + setBoolean(name: string, value: boolean): void + + /** + * Retrieves a number from storage. + * + * @param name the name of the number to retrieve + * @param def the value to return if no number is stored with the given name + * @protected + */ + getNumber(name: string, def: number): number + + /** + * Stores a number. + * + * @param name the name of the number to store + * @param value the number to store under the given name + * @protected + */ + setNumber(name: string, value: number): void +} + +/** + * Stores key-value pairs in a single entry in `localStorage`. + */ +export class LocalStorage implements Storage { private readonly key: string; - private _cache: { [key: string]: string } | null = null; + private cache: { [key: string]: string } | null = null; /** @@ -23,10 +86,10 @@ export class Storage { * @private */ private read(): { [key: string]: string } { - if (this._cache === null) - this._cache = JSON.parse(localStorage.getItem(this.key) ?? "{}"); + if (this.cache === null) + this.cache = JSON.parse(localStorage.getItem(this.key) ?? "{}"); - return this._cache!; + return this.cache!; } /** @@ -36,64 +99,79 @@ export class Storage { * @private */ private write(item: { [key: string]: string }): void { - this._cache = item; + this.cache = item; localStorage.setItem(this.key, JSON.stringify(item)); } - /** - * Removes the data from storage. - */ clear(): void { - this._cache = null; + this.cache = null; localStorage.removeItem(this.key); } - /** - * Retrieves a boolean from storage. - * - * @param name the name of the boolean to retrieve - * @param def the value to return if no boolean is stored with the given name - * @protected - */ + getArray(name: string, def: any[] = []): any[] { + return JSON.parse(this.read()[name]) ?? def; + } + + setArray(name: string, value: any[]): void { + const item = this.read(); + item[name] = JSON.stringify(value); + this.write(item); + } + getBoolean(name: string, def: boolean = false): boolean { return (this.read()[name] ?? `${def}`) === "true"; } - /** - * Stores a boolean. - * - * @param name the name of the boolean to store - * @param value the boolean to store under the given name - * @protected - */ setBoolean(name: string, value: boolean): void { const item = this.read(); item[name] = "" + value; this.write(item); } - /** - * Retrieves a number from storage. - * - * @param name the name of the number to retrieve - * @param def the value to return if no number is stored with the given name - * @protected - */ getNumber(name: string, def: number = 0): number { return +(this.read()[name] ?? def); } - /** - * Stores a number. - * - * @param name the name of the number to store - * @param value the number to store under the given name - * @protected - */ setNumber(name: string, value: number): void { const item = this.read(); item[name] = "" + value; this.write(item); } } + +/** + * Stores key-value pairs in an object. + */ +export class MemoryStorage implements Storage { + private storage: { [key: string]: any } = {}; + + + clear(): void { + this.storage = {}; + } + + setArray(name: string, value: any[] = []): void { + this.storage[name] = value; + } + + getArray(name: string, def: any[]): any[] { + return this.storage[name] ?? def; + } + + setBoolean(name: string, value: boolean): void { + this.storage[name] = value; + } + + getBoolean(name: string, def: boolean): boolean { + return this.storage[name] ?? def; + } + + setNumber(name: string, value: number): void { + this.storage[name] = value; + } + + getNumber(name: string, def: number): number { + return this.storage[name] ?? def; + } +}