commit 9d387eb48240efb516a0e711388ebf522aa08d96 Author: Syed Daanish Date: Thu Jun 19 17:15:24 2025 +0300 Initialize Repo diff --git a/API.md b/API.md new file mode 100644 index 0000000..a0c4d6b --- /dev/null +++ b/API.md @@ -0,0 +1,1342 @@ +## Modules + +
+
Tile : Object
+
+
Colors : Object
+
+
SOLVED : number
+
+
MINE : number
+
+
SECTOR_SIZE : number
+
+
DIFFICULTY : number
+
+
CENTRAL_AREA_DIFFICULTY_MODIFIER : number
+
+
DETAIL_THRESHOLD : number
+
+
DRAG_THRESHOLD : number
+
+
MAX_TRIES : number
+
+
ANIMATION_SPEED_BASE : number
+
+
CANVAS_ID : string
+
+
EventBus
+
+
EventHandler
+
+
GameController
+
+
GameLogic
+
+
GameRenderer
+
+
Saver
+
+
SipHash
+
+
LZW
+
+
UIRendererGameRenderer
+
+
DataHasher : object
+

An object with methods hash and generate_key + such that hash(key, data) returns a number between 0 and 1 (deterministically), + and generate_key(seed) returns an approprite key for hash

+
+
DataCompressor : object
+

An object with methods zip and unzip + that accept/return strings and are reversable

+
+
LOOPS : object
+

An object with common loops used in game.

+
+
convertArray.<number>
+

Converts between tile and sector coordinates.

+
+
isMineboolean
+

Get the type of a tile (mine or not) deterministically.

+
+
+ +## Members + +
+
game : GameController
+

The game controller instance.

+
+
+ +## Events + +
+
"DOMContentLoaded"
+
+
+ + + +## Tile : Object +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| LOST | number | Tile is lost | +| HIDDEN | number | Tile is hidden | +| FLAGGED | number | Tile is flagged | +| REVEALED | number | Tile is revealed | + + + +## Colors : Object +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| BACKGROUND | string | Background color | +| BACKGROUND_ZOOMED | string | Background color when zoomed | +| TILE_DEFAULT | string | Default tile color | +| TILE_CLICKABLE | string | Tile color when it is clickable | +| TILE_REVEALED_NUMBERED | string | Tile color when it is revealed and has a number | +| TILE_REVEALED_EMPTY | string | Tile color when it is revealed and is empty | +| TILE_FLAGGED | string | Tile color when it is flagged | +| SECTOR_OVERLAY | string | Sector overlay color | +| SECTOR_LOST_OVERLAY | string | Lost sector overlay color | +| SECTOR_BORDER | string | Sector border color | +| FLAG_PARTICLE_COLOR | string | Flag particle color | +| SOLVED_PARTICLE_COLOR | string | Solved particle color | +| LOST_PARTICLE_COLOR | string | Lost particle color | + + + +## SOLVED : number + + +## MINE : number + + +## SECTOR\_SIZE : number + + +## DIFFICULTY : number + + +## CENTRAL\_AREA\_DIFFICULTY\_MODIFIER : number + + +## DETAIL\_THRESHOLD : number + + +## DRAG\_THRESHOLD : number + + +## MAX\_TRIES : number + + +## ANIMATION\_SPEED\_BASE : number + + +## CANVAS\_ID : string + + +## EventBus +**Access**: public + +* [EventBus](#module_EventBus) + * [~constructor()](#module_EventBus..constructor) + * [~on(event, callback)](#module_EventBus..on) + * [~emit(event, ...args)](#module_EventBus..emit) + * [~onRetrievable(event, callback)](#module_EventBus..onRetrievable) + * [~get(event, ...args)](#module_EventBus..get) ⇒ any \| undefined + + + +### EventBus~constructor() +Initializes the event bus + +**Kind**: inner method of [EventBus](#module_EventBus) + + +### EventBus~on(event, callback) +Sets up an event listener + +**Kind**: inner method of [EventBus](#module_EventBus) + +| Param | Type | Description | +| --- | --- | --- | +| event | string | Event name | +| callback | function | Callback function to call (multiple functions can be added) | + + + +### EventBus~emit(event, ...args) +Emits an event + +**Kind**: inner method of [EventBus](#module_EventBus) + +| Param | Type | Description | +| --- | --- | --- | +| event | string | Event name | +| ...args | \* | Arguments to pass to the callback functions (0 or more) | + + + +### EventBus~onRetrievable(event, callback) +Sets up a retrievable event listener + +**Kind**: inner method of [EventBus](#module_EventBus) + +| Param | Type | Description | +| --- | --- | --- | +| event | string | Event name | +| callback | function | Callback function to call (can only be used once) | + + + +### EventBus~get(event, ...args) ⇒ any \| undefined +Retrieves data from an event + +**Kind**: inner method of [EventBus](#module_EventBus) +**Returns**: any \| undefined - - Data from the event or undefined + +| Param | Type | Description | +| --- | --- | --- | +| event | string | Event name | +| ...args | \* | Arguments to pass to the callback function (only one) | + + + +## EventHandler +**Access**: public + +* [EventHandler](#module_EventHandler) + * [~constructor(canvas, bus)](#module_EventHandler..constructor) + * ["mousedown"](#event_mousedown) + * ["mousemove"](#event_mousemove) + * ["mouseup"](#event_mouseup) + * ["mouseleave"](#event_mouseleave) + * ["wheel"](#event_wheel) + * ["click"](#event_click) + * ["contextmenu"](#event_contextmenu) + * ["resize"](#event_resize) + * ["beforeunload"](#event_beforeunload) + * ["visibilitychange"](#event_visibilitychange) + + + +### EventHandler~constructor(canvas, bus) +Initializes the event handler + +**Kind**: inner method of [EventHandler](#module_EventHandler) + +| Param | Type | +| --- | --- | +| canvas | HTMLCanvasElement | +| bus | EventBus | + + + +### "mousedown" +**Kind**: event emitted by [EventHandler](#module_EventHandler) + + +### "mousemove" +**Kind**: event emitted by [EventHandler](#module_EventHandler) +**Emits**: EventBus#event:drag - optionally + + +### "mouseup" +**Kind**: event emitted by [EventHandler](#module_EventHandler) +**Emits**: EventBus#event:click - optionally, EventBus#event:clean\_cache - optionally + + +### "mouseleave" +**Kind**: event emitted by [EventHandler](#module_EventHandler) +**Emits**: EventBus#event:clean\_cache - optionally + + +### "wheel" +**Kind**: event emitted by [EventHandler](#module_EventHandler) +**Emits**: EventBus#event:zoom, EventBus#event:clean\_cache + + +### "click" +**Kind**: event emitted by [EventHandler](#module_EventHandler) + + +### "contextmenu" +**Kind**: event emitted by [EventHandler](#module_EventHandler) + + +### "resize" +**Kind**: event emitted by [EventHandler](#module_EventHandler) +**Emits**: EventBus#event:resize + + +### "beforeunload" +**Kind**: event emitted by [EventHandler](#module_EventHandler) +**Emits**: EventBus#event:save + + +### "visibilitychange" +**Kind**: event emitted by [EventHandler](#module_EventHandler) +**Emits**: EventBus#event:save - optionally + + +## GameController +**Access**: public + +* [GameController](#module_GameController) + * _instance_ + * [.seed](#module_GameController+seed) : string + * [.key](#module_GameController+key) : Uint32Array + * _inner_ + * [~constructor(seed)](#module_GameController..constructor) + * [~init()](#module_GameController..init) + * [~loadImage()](#module_GameController..loadImage) ⇒ Promise.<void> + * [~start(tries)](#module_GameController..start) + * [~reset(tries)](#module_GameController..reset) + + + +### gameController.seed : string +- Seed for the game (by default a stringified 16 digit random number) + +**Kind**: instance property of [GameController](#module_GameController) + + +### gameController.key : Uint32Array +**Kind**: instance property of [GameController](#module_GameController) + + +### GameController~constructor(seed) +Initializes the game controller + +**Kind**: inner method of [GameController](#module_GameController) + +| Param | Type | Description | +| --- | --- | --- | +| seed | string | Seed for the game | + + + +### GameController~init() +Initializes the game + +**Kind**: inner method of [GameController](#module_GameController) + + +### GameController~loadImage() ⇒ Promise.<void> +Loads the game image + +**Kind**: inner method of [GameController](#module_GameController) +**Returns**: Promise.<void> - - Promise that resolves when the image is loaded +**Throws**: + +- Error - If the image fails to load + + + +### GameController~start(tries) +Starts the game + +**Kind**: inner method of [GameController](#module_GameController) +**Emits**: EventBus#event:start + +| Param | Type | Description | +| --- | --- | --- | +| tries | number | Number of times the game has been reset | + + + +### GameController~reset(tries) +Resets the game + +**Kind**: inner method of [GameController](#module_GameController) + +| Param | Type | Description | +| --- | --- | --- | +| tries | number | Number of times the game has been reset | + + + +## GameLogic +**Access**: public + +* [GameLogic](#module_GameLogic) + * _instance_ + * [.start_time](#module_GameLogic+start_time) : Date + * _inner_ + * [~constructor(game_pos, key, bus)](#module_GameLogic..constructor) + * [~timeAndStats()](#module_GameLogic..timeAndStats) ⇒ Object + * [~setStats(stats)](#module_GameLogic..setStats) + * [~click(x, y, button)](#module_GameLogic..click) + * [~updateKey(key)](#module_GameLogic..updateKey) + * [~buy(x, y, [s_x], [s_y])](#module_GameLogic..buy) + * [~reveal(x, y, [s_x], [s_y], [no_animate])](#module_GameLogic..reveal) ⇒ Promise.<boolean> + * [~flag(x, y, [s_x], [s_y])](#module_GameLogic..flag) + * [~isClickable(x, y, [s_x], [s_y])](#module_GameLogic..isClickable) ⇒ boolean + * [~isFlagged(x, y, [s_x], [s_y])](#module_GameLogic..isFlagged) ⇒ boolean + * [~isRevealed(x, y, [s_x], [s_y])](#module_GameLogic..isRevealed) ⇒ boolean + * [~isSectorSolved(sector_key)](#module_GameLogic..isSectorSolved) ⇒ boolean + * [~collectStats(s_x, s_y)](#module_GameLogic..collectStats) + * [~flagCount(x, y, [s_x], [s_y])](#module_GameLogic..flagCount) ⇒ number + * [~mineCount(x, y, [s_x], [s_y], [force])](#module_GameLogic..mineCount) ⇒ number + * [~buildSector(s_x, s_y)](#module_GameLogic..buildSector) ⇒ Array.<Array.<Array.<number>>> + * [~buildSectorCache(s_x, s_y)](#module_GameLogic..buildSectorCache) ⇒ Array.<Array.<Array.<number>>> \| false + * [~cleanSectorCache()](#module_GameLogic..cleanSectorCache) + * [~animate(key, type, is_tile, [duration])](#module_GameLogic..animate) + + + +### gameLogic.start\_time : Date +**Kind**: instance property of [GameLogic](#module_GameLogic) + + +### GameLogic~constructor(game_pos, key, bus) +Initializes the game logic + +**Kind**: inner method of [GameLogic](#module_GameLogic) + +| Param | Type | +| --- | --- | +| game_pos | Object | +| key | Uint32Array | +| bus | EventBus | + + + +### GameLogic~timeAndStats() ⇒ Object +Returns the current time in seconds + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Returns**: Object - - Time and stats + + +### GameLogic~setStats(stats) +Sets the stats + +**Kind**: inner method of [GameLogic](#module_GameLogic) + +| Param | Type | Description | +| --- | --- | --- | +| stats | Object | Stats from the save | + + + +### GameLogic~click(x, y, button) +Handles a click + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Emits**: EventBus#event:click\_convert, EventBus#event:is\_buy\_button + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate in terms of screen | +| y | number | Y-coordinate in terms of screen | +| button | number | 0 for left, 1 for middle, 2 for right (from Event#button) | + + + +### GameLogic~updateKey(key) +Updates the game key + +**Kind**: inner method of [GameLogic](#module_GameLogic) + +| Param | Type | +| --- | --- | +| key | Uint32Array | + + + +### GameLogic~buy(x, y, [s_x], [s_y]) +Buys a sector + +**Kind**: inner method of [GameLogic](#module_GameLogic) + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +### GameLogic~reveal(x, y, [s_x], [s_y], [no_animate]) ⇒ Promise.<boolean> +Reveals a tile + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Returns**: Promise.<boolean> - - Resolves to whether the reveal caused more + than one tile to be revealed + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| x | number | | X-coordinate of tile | +| y | number | | Y-coordinate of tile | +| [s_x] | number | | X-coordinate of sector if `x` is relative | +| [s_y] | number | | Y-coordinate of sector if `y` is relative | +| [no_animate] | boolean | false | If true, no animation will be played | + + + +### GameLogic~flag(x, y, [s_x], [s_y]) +Flags a tile + +**Kind**: inner method of [GameLogic](#module_GameLogic) + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +### GameLogic~isClickable(x, y, [s_x], [s_y]) ⇒ boolean +Checks if a tile is clickable + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Returns**: boolean - - True if tile is clickable, false otherwise + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +### GameLogic~isFlagged(x, y, [s_x], [s_y]) ⇒ boolean +Checks if a tile is flagged + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Returns**: boolean - - True if tile is flagged, false otherwise + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +### GameLogic~isRevealed(x, y, [s_x], [s_y]) ⇒ boolean +Checks if a tile is revealed + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Returns**: boolean - - True if tile is revealed, false otherwise + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +### GameLogic~isSectorSolved(sector_key) ⇒ boolean +Checks if a sector is solved + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Returns**: boolean - - True if sector is solved, false otherwise + +| Param | Type | Description | +| --- | --- | --- | +| sector_key | string | Key of sector | + + + +### GameLogic~collectStats(s_x, s_y) +Collects statistics about a solved sector into the stats object + +**Kind**: inner method of [GameLogic](#module_GameLogic) + +| Param | Type | Description | +| --- | --- | --- | +| s_x | number | X-coordinate of sector | +| s_y | number | Y-coordinate of sector | + + + +### GameLogic~flagCount(x, y, [s_x], [s_y]) ⇒ number +Counts the number of flags adjacent to a tile + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Returns**: number - - The number of flags adjacent to the tile + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +### GameLogic~mineCount(x, y, [s_x], [s_y], [force]) ⇒ number +Counts the number of mines adjacent to a tile + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Returns**: number - - The number of mines adjacent to the tile + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| x | number | | X-coordinate of tile | +| y | number | | Y-coordinate of tile | +| [s_x] | number | | X-coordinate of sector if `x` is relative | +| [s_y] | number | | Y-coordinate of sector if `y` is relative | +| [force] | boolean | false | Force count even if tile is revealed | + + + +### GameLogic~buildSector(s_x, s_y) ⇒ Array.<Array.<Array.<number>>> +Builds a new sector + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Returns**: Array.<Array.<Array.<number>>> - - The sector + +| Param | Type | Description | +| --- | --- | --- | +| s_x | number | X-coordinate of sector | +| s_y | number | Y-coordinate of sector | + + + +### GameLogic~buildSectorCache(s_x, s_y) ⇒ Array.<Array.<Array.<number>>> \| false +Builds a sector cache + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Returns**: Array.<Array.<Array.<number>>> \| false - - The cached sector + +| Param | Type | Description | +| --- | --- | --- | +| s_x | number | X-coordinate of sector | +| s_y | number | Y-coordinate of sector | + + + +### GameLogic~cleanSectorCache() +Cleans the sector cache + +**Kind**: inner method of [GameLogic](#module_GameLogic) +**Emits**: EventBus#event:sector\_bounds + + +### GameLogic~animate(key, type, is_tile, [duration]) +Animate a tile or sector + +**Kind**: inner method of [GameLogic](#module_GameLogic) + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| key | string | | Key to use for hashing | +| type | string | | Type of animation | +| is_tile | boolean | | Whether the animation is for a tile | +| [duration] | number | ANIMATION_SPEED_BASE | Duration of the animation if it is a tile (in ms). | + + + +## GameRenderer +**Access**: public + +* [GameRenderer](#module_GameRenderer) + * _instance_ + * [.canvas](#module_GameRenderer+canvas) : HTMLCanvasElement + * [.ctx](#module_GameRenderer+ctx) : CanvasRenderingContext2D + * _inner_ + * [~constructor(img, game_pos, key, bus)](#module_GameRenderer..constructor) + * [~getViewPos()](#module_GameRenderer..getViewPos) ⇒ Object + * [~setViewPos(view_pos)](#module_GameRenderer..setViewPos) + * [~draw()](#module_GameRenderer..draw) + * [~drawBuyButtons(s_x, s_y)](#module_GameRenderer..drawBuyButtons) + * [~drawRoundedRect(x, y, width, height, radius)](#module_GameRenderer..drawRoundedRect) + * [~drawStaticTiles(sector, s_x, s_y, x, y)](#module_GameRenderer..drawStaticTiles) + * [~drawAnimatedTiles(sector, s_x, s_y, x, y)](#module_GameRenderer..drawAnimatedTiles) + * [~drawParticles(tile_x, tile_y, frame_time, [color])](#module_GameRenderer..drawParticles) + * [~drawSectorBorders(start_x, start_y)](#module_GameRenderer..drawSectorBorders) + * [~drawSectorOverlays(s_x, s_y)](#module_GameRenderer..drawSectorOverlays) + * [~drawSolvedAnimations(s_x, s_y)](#module_GameRenderer..drawSolvedAnimations) + * [~drawSolvedParticles(sector_x_pos, sector_y_pos, frame_time)](#module_GameRenderer..drawSolvedParticles) + * [~drawLostAnimations(s_x, s_y)](#module_GameRenderer..drawLostAnimations) + + + +### gameRenderer.canvas : HTMLCanvasElement +**Kind**: instance property of [GameRenderer](#module_GameRenderer) + + +### gameRenderer.ctx : CanvasRenderingContext2D +**Kind**: instance property of [GameRenderer](#module_GameRenderer) + + +### GameRenderer~constructor(img, game_pos, key, bus) +Initializes the game renderer + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | +| --- | --- | +| img | Image | +| game_pos | Object | +| key | Uint32Array | +| bus | EventBus | + + + +### GameRenderer~getViewPos() ⇒ Object +Returns the current view position (for saving) + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) +**Returns**: Object - - The view position (offset, tile_size) + + +### GameRenderer~setViewPos(view_pos) +Sets the view position (from saved data) + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) +**Emits**: EventBus#event:disable\_click - optionally + +| Param | Type | Description | +| --- | --- | --- | +| view_pos | Object | The view position (offset, tile_size) | + + + +### GameRenderer~draw() +Draws the main game frame + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + + +### GameRenderer~drawBuyButtons(s_x, s_y) +Draws the buy buttons for a sector + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | Description | +| --- | --- | --- | +| s_x | number | X-coordinate of sector | +| s_y | number | Y-coordinate of sector | + + + +### GameRenderer~drawRoundedRect(x, y, width, height, radius) +Draws a rounded rectangle + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate of top-left corner | +| y | number | Y-coordinate of top-left corner | +| width | number | Width of rectangle | +| height | number | Height of rectangle | +| radius | number | Radius of rounded corners | + + + +### GameRenderer~drawStaticTiles(sector, s_x, s_y, x, y) +Draws a static tile + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | Description | +| --- | --- | --- | +| sector | Array.<Array.<Array.<number>>> \| false | | +| s_x | number | X-coordinate of sector | +| s_y | number | Y-coordinate of sector | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | + + + +### GameRenderer~drawAnimatedTiles(sector, s_x, s_y, x, y) +Draws an animated tile + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | Description | +| --- | --- | --- | +| sector | Array.<Array.<Array.<number>>> \| false | | +| s_x | number | X-coordinate of sector | +| s_y | number | Y-coordinate of sector | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | + + + +### GameRenderer~drawParticles(tile_x, tile_y, frame_time, [color]) +Draws particles + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| tile_x | number | | X-coordinate of top-left corner of tile | +| tile_y | number | | Y-coordinate of top-left corner of tile | +| frame_time | number | | Time since animation start (0-1) | +| [color] | string | "COLORS.FLAG_PARTICLE_COLOR" | Color of particle | + + + +### GameRenderer~drawSectorBorders(start_x, start_y) +Draws sector borders + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | Description | +| --- | --- | --- | +| start_x | number | X-coordinate of top-left corner of sector | +| start_y | number | Y-coordinate of top-left corner of sector | + + + +### GameRenderer~drawSectorOverlays(s_x, s_y) +Draws sector overlays + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | Description | +| --- | --- | --- | +| s_x | number | X-coordinate of sector | +| s_y | number | Y-coordinate of sector | + + + +### GameRenderer~drawSolvedAnimations(s_x, s_y) +Draws solved sector animations + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | Description | +| --- | --- | --- | +| s_x | number | X-coordinate of sector | +| s_y | number | Y-coordinate of sector | + + + +### GameRenderer~drawSolvedParticles(sector_x_pos, sector_y_pos, frame_time) +Draws solved sector particles + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | Description | +| --- | --- | --- | +| sector_x_pos | number | X-coordinate of sector | +| sector_y_pos | number | Y-coordinate of sector | +| frame_time | number | Current frame time (0-1) | + + + +### GameRenderer~drawLostAnimations(s_x, s_y) +Draws lost sector animations + +**Kind**: inner method of [GameRenderer](#module_GameRenderer) + +| Param | Type | Description | +| --- | --- | --- | +| s_x | number | X-coordinate of sector | +| s_y | number | Y-coordinate of sector | + + + +## Saver +**Access**: public + +* [Saver](#module_Saver) + * _instance_ + * [.save_interval_id](#module_Saver+save_interval_id) : number + * _inner_ + * [~constructor(game, bus)](#module_Saver..constructor) + * [~startAutosaver()](#module_Saver..startAutosaver) + * [~stopAutosaver()](#module_Saver..stopAutosaver) + * [~isSaved()](#module_Saver..isSaved) ⇒ boolean + * [~save()](#module_Saver..save) ⇒ string + * [~load([compressed])](#module_Saver..load) + * [~saveToFile()](#module_Saver..saveToFile) + * [~deleteSave()](#module_Saver..deleteSave) + * [~loadFromFile()](#module_Saver..loadFromFile) + + + +### saver.save\_interval\_id : number +- ID of the autosave interval + +**Kind**: instance property of [Saver](#module_Saver) + + +### Saver~constructor(game, bus) +Initializes the game saver + +**Kind**: inner method of [Saver](#module_Saver) + +| Param | Type | +| --- | --- | +| game | GameController | +| bus | EventBus | + + + +### Saver~startAutosaver() +Starts the autosave feature + +**Kind**: inner method of [Saver](#module_Saver) + + +### Saver~stopAutosaver() +Stops the autosave feature + +**Kind**: inner method of [Saver](#module_Saver) + + +### Saver~isSaved() ⇒ boolean +Checks if a save exists + +**Kind**: inner method of [Saver](#module_Saver) +**Returns**: boolean - - Whether a save exists or not + + +### Saver~save() ⇒ string +Saves the game data + +**Kind**: inner method of [Saver](#module_Saver) +**Returns**: string - - Compressed game data +**Emits**: EventBus#event:view\_pos, EventBus#event:time\_and\_stats + + +### Saver~load([compressed]) +Loads the game data + +**Kind**: inner method of [Saver](#module_Saver) +**Emits**: EventBus#event:set\_stats, EventBus#event:set\_view\_pos, EventBus#event:update\_key, EventBus#event:reset + +| Param | Type | Description | +| --- | --- | --- | +| [compressed] | string | Compressed game data | + + + +### Saver~saveToFile() +Saves the game data to a file + +**Kind**: inner method of [Saver](#module_Saver) + + +### Saver~deleteSave() +Deletes the save + +**Kind**: inner method of [Saver](#module_Saver) + + +### Saver~loadFromFile() +Loads the game data from a file + +**Kind**: inner method of [Saver](#module_Saver) + + +## SipHash +**Access**: package +**Important**: - Siphash implementation taken from https://github.com/jedisct1/siphash-js (minified browser version) +**Author**: Frank Denis + + +## LZW +**Access**: package + + +## UIRenderer ⇐ GameRenderer +**Extends**: GameRenderer +**Access**: public + +* [UIRenderer](#module_UIRenderer) ⇐ GameRenderer + * _instance_ + * [.loop](#module_UIRenderer+loop) : function + * [.frame_id](#module_UIRenderer+frame_id) : number + * _inner_ + * [~constructor(img, game_pos, key, bus)](#module_UIRenderer..constructor) + * [~loop()](#module_UIRenderer..loop) + * [~drag(x, y)](#module_UIRenderer..drag) + * [~resize()](#module_UIRenderer..resize) + * [~updateKey(key)](#module_UIRenderer..updateKey) + * [~zoom(x, y, is_zoom_in)](#module_UIRenderer..zoom) ⇒ boolean + * [~isBuyButton(x, y)](#module_UIRenderer..isBuyButton) ⇒ boolean + * [~sectorBounds()](#module_UIRenderer..sectorBounds) ⇒ Array + * [~clickConvert(x, y)](#module_UIRenderer..clickConvert) ⇒ Array + + + +### uiRenderer.loop : function +- Bound `loop` function so `requestAnimationFrame` keeps `this` context + +**Kind**: instance property of [UIRenderer](#module_UIRenderer) + + +### uiRenderer.frame\_id : number +- ID of the animation frame + +**Kind**: instance property of [UIRenderer](#module_UIRenderer) + + +### UIRenderer~constructor(img, game_pos, key, bus) +Initializes the renderer + +**Kind**: inner method of [UIRenderer](#module_UIRenderer) + +| Param | Type | +| --- | --- | +| img | Image | +| game_pos | Object | +| key | Uint32Array | +| bus | EventBus | + + + +### UIRenderer~loop() +Loops the animation frame and calls `draw` + +**Kind**: inner method of [UIRenderer](#module_UIRenderer) + + +### UIRenderer~drag(x, y) +Updates the offset + +**Kind**: inner method of [UIRenderer](#module_UIRenderer) + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-offset | +| y | number | Y-offset | + + + +### UIRenderer~resize() +Resizes the canvas + +**Kind**: inner method of [UIRenderer](#module_UIRenderer) + + +### UIRenderer~updateKey(key) +Updates the key + +**Kind**: inner method of [UIRenderer](#module_UIRenderer) + +| Param | Type | +| --- | --- | +| key | Uint32Array | + + + +### UIRenderer~zoom(x, y, is_zoom_in) ⇒ boolean +Zooms in or out + +**Kind**: inner method of [UIRenderer](#module_UIRenderer) +**Returns**: boolean - - True if details are hidden, false otherwise + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate of center of zoom | +| y | number | Y-coordinate of center of zoom | +| is_zoom_in | boolean | True if zooming in, false if zooming out | + + + +### UIRenderer~isBuyButton(x, y) ⇒ boolean +Checks if the tile is a buy button + +**Kind**: inner method of [UIRenderer](#module_UIRenderer) +**Returns**: boolean - True if the tile is a buy button, false otherwise + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate of the tile | +| y | number | Y-coordinate of the tile | + + + +### UIRenderer~sectorBounds() ⇒ Array +Returns the sector bounds + +**Kind**: inner method of [UIRenderer](#module_UIRenderer) +**Returns**: Array - - Array of [start_x, start_y, end_x, end_y] + + +### UIRenderer~clickConvert(x, y) ⇒ Array +Converts raw click coords to sector adjusted coords + +**Kind**: inner method of [UIRenderer](#module_UIRenderer) +**Returns**: Array - - Array of [x, y] + +| Param | Type | Description | +| --- | --- | --- | +| x | number | X-coordinate of the click | +| y | number | Y-coordinate of the click | + + + +## DataHasher : object +An object with methods `hash` and `generate_key` + such that `hash(key, data)` returns a number between 0 and 1 (deterministically), + and `generate_key(seed)` returns an approprite key for `hash` + +**Access**: public +**Properties** + +| Name | Type | +| --- | --- | +| generate_key | function | +| hash | function | + + +* [DataHasher](#module_DataHasher) : object + * [~generate_key(seed)](#module_DataHasher..generate_key) ⇒ Uint32Array + * [~hash(key, data)](#module_DataHasher..hash) ⇒ number + + + +### DataHasher~generate\_key(seed) ⇒ Uint32Array +Generates a key + +**Kind**: inner method of [DataHasher](#module_DataHasher) +**Returns**: Uint32Array - - Generated key + It can be the input itself for hashing + functions that dont require keys. + +| Param | Type | Description | +| --- | --- | --- | +| seed | string | Seed for the hash function | + + + +### DataHasher~hash(key, data) ⇒ number +Hashes data + +**Kind**: inner method of [DataHasher](#module_DataHasher) +**Returns**: number - - Psudo-random number between 0 and 1 + +| Param | Type | Description | +| --- | --- | --- | +| key | Uint32Array | Key for the hash function For hashing functions that dont require keys it can be concatenated to the data | +| data | string | Data to hash | + + + +## DataCompressor : object +An object with methods `zip` and `unzip` + that accept/return strings and are reversable + +**Access**: public +**Properties** + +| Name | Type | +| --- | --- | +| zip | function | +| unzip | function | + + +* [DataCompressor](#module_DataCompressor) : object + * [~zip(data)](#module_DataCompressor..zip) ⇒ string + * [~unzip(data)](#module_DataCompressor..unzip) ⇒ string + + + +### DataCompressor~zip(data) ⇒ string +Compresses data + +**Kind**: inner method of [DataCompressor](#module_DataCompressor) +**Returns**: string - - Compressed data + +| Param | Type | Description | +| --- | --- | --- | +| data | string | Uncompressed data | + + + +### DataCompressor~unzip(data) ⇒ string +Uncompresses data + +**Kind**: inner method of [DataCompressor](#module_DataCompressor) +**Returns**: string - - Uncompressed data +**Throws**: + +- Error - If the compressed data is invalid + + +| Param | Type | Description | +| --- | --- | --- | +| data | string | Compressed data | + + + +## LOOPS : object +An object with common loops used in game. + +**Access**: public +**Properties** + +| Name | Type | +| --- | --- | +| overAdjacent | function | +| overAdjacentSum | function | +| anyAdjacent | function | +| overOnScreenSectors | function | +| overTilesInSector | function | +| anyTilesInSector | function | +| overTilesInSectorSum | function | + + +* [LOOPS](#module_LOOPS) : object + * [~overAdjacent(f, x, y, [s_x], [s_y])](#module_LOOPS..overAdjacent) + * [~overAdjacentSum(condition, x, y, [s_x], [s_y])](#module_LOOPS..overAdjacentSum) ⇒ number + * [~anyAdjacent(condition, x, y, [s_x], [s_y])](#module_LOOPS..anyAdjacent) ⇒ boolean + * [~overOnScreenSectors(f, start_x, start_y, sector_size_in_px, canvas)](#module_LOOPS..overOnScreenSectors) + * [~overTilesInSector(f)](#module_LOOPS..overTilesInSector) + * [~anyTilesInSector(condition)](#module_LOOPS..anyTilesInSector) ⇒ boolean + * [~overTilesInSectorSum(condition)](#module_LOOPS..overTilesInSectorSum) ⇒ number + + + +### LOOPS~overAdjacent(f, x, y, [s_x], [s_y]) +Loops over adjacent tiles and calls a function + +**Kind**: inner method of [LOOPS](#module_LOOPS) + +| Param | Type | Description | +| --- | --- | --- | +| f | function | Function to call on each tile | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +### LOOPS~overAdjacentSum(condition, x, y, [s_x], [s_y]) ⇒ number +Loops over adjacent tiles and checks if they satisfy a condition + +**Kind**: inner method of [LOOPS](#module_LOOPS) +**Returns**: number - - Number of adjacent tiles that satisfy the condition + +| Param | Type | Description | +| --- | --- | --- | +| condition | function | The condition to check | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +### LOOPS~anyAdjacent(condition, x, y, [s_x], [s_y]) ⇒ boolean +Loops over adjacent tiles and checks if they satisfy a condition + +**Kind**: inner method of [LOOPS](#module_LOOPS) +**Returns**: boolean - - Whether any adjacent tile satisfies the condition or not + +| Param | Type | Description | +| --- | --- | --- | +| condition | function | The condition to check | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +### LOOPS~overOnScreenSectors(f, start_x, start_y, sector_size_in_px, canvas) +Loops over all sectors in view + +**Kind**: inner method of [LOOPS](#module_LOOPS) + +| Param | Type | Description | +| --- | --- | --- | +| f | function | Function to call on each sector | +| start_x | number | X-coordinate of starting sector | +| start_y | number | Y-coordinate of starting sector | +| sector_size_in_px | number | Size of sector in pixels | +| canvas | HTMLCanvasElement | Canvas element | + + + +### LOOPS~overTilesInSector(f) +Loops over all tiles in a sector + +**Kind**: inner method of [LOOPS](#module_LOOPS) + +| Param | Type | Description | +| --- | --- | --- | +| f | function | Function to call on each tile | + + + +### LOOPS~anyTilesInSector(condition) ⇒ boolean +Checks if any tile in a sector satisfies a condition + +**Kind**: inner method of [LOOPS](#module_LOOPS) +**Returns**: boolean - - Whether any tile satisfies the condition or not + +| Param | Type | Description | +| --- | --- | --- | +| condition | function | The condition to check | + + + +### LOOPS~overTilesInSectorSum(condition) ⇒ number +Checks if all tiles in a sector satisfy a condition + +**Kind**: inner method of [LOOPS](#module_LOOPS) +**Returns**: number - - Number of tiles that satisfy the condition + +| Param | Type | Description | +| --- | --- | --- | +| condition | function | The condition to check | + + + +## convert ⇒ Array.<number> +Converts between tile and sector coordinates. + +**Returns**: Array.<number> - - An array containing resultant coords + +| Param | Type | Description | +| --- | --- | --- | +| to_local_coords | boolean | Whether to convert to local coordinates or not | +| x | number | X-coordinate of tile | +| y | number | Y-coordinate of tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +## isMine ⇒ boolean +Get the type of a tile (mine or not) deterministically. + +**Returns**: boolean - - Whether the tile is a mine or not + +| Param | Type | Description | +| --- | --- | --- | +| key | string | Key to use for hashing | +| x | number | X-coordinate of the tile | +| y | number | Y-coordinate of the tile | +| [s_x] | number | X-coordinate of sector if `x` is relative | +| [s_y] | number | Y-coordinate of sector if `y` is relative | + + + +## game : GameController +The game controller instance. + +**Kind**: global variable +**Access**: public + + +## "DOMContentLoaded" +**Kind**: event emitted diff --git a/index.html b/index.html new file mode 100644 index 0000000..0eab6ad --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + Infinsweeper + + + + + + + + + diff --git a/src/assets/img/logo_large.png b/src/assets/img/logo_large.png new file mode 100644 index 0000000..3988755 Binary files /dev/null and b/src/assets/img/logo_large.png differ diff --git a/src/assets/img/logo_sm.png b/src/assets/img/logo_sm.png new file mode 100644 index 0000000..0f9b2e5 Binary files /dev/null and b/src/assets/img/logo_sm.png differ diff --git a/src/assets/img/minesweeper.png b/src/assets/img/minesweeper.png new file mode 100644 index 0000000..c51e6e1 Binary files /dev/null and b/src/assets/img/minesweeper.png differ diff --git a/src/assets/style.css b/src/assets/style.css new file mode 100644 index 0000000..4221350 --- /dev/null +++ b/src/assets/style.css @@ -0,0 +1,4 @@ +body { + margin: 0; + overflow: hidden; +} diff --git a/src/js/constants.js b/src/js/constants.js new file mode 100644 index 0000000..5ddb3d3 --- /dev/null +++ b/src/js/constants.js @@ -0,0 +1,124 @@ +/** + * @author Syed Daanish + * @file `constants.js` + */ + +/** + * @constant Tile states + * @type {Object} + * @exports TILE_STATES + * @property {number} LOST - Tile is lost + * @property {number} HIDDEN - Tile is hidden + * @property {number} FLAGGED - Tile is flagged + * @property {number} REVEALED - Tile is revealed + */ +export const TILE_STATES = Object.freeze({ + LOST: -1, + HIDDEN: 0, + FLAGGED: 1, + REVEALED: 2, +}); + +/** + * @constant Colors to be used with the game + * @type {Object} + * @exports COLORS + * @property {string} BACKGROUND - Background color + * @property {string} BACKGROUND_ZOOMED - Background color when zoomed + * @property {string} TILE_DEFAULT - Default tile color + * @property {string} TILE_CLICKABLE - Tile color when it is clickable + * @property {string} TILE_REVEALED_NUMBERED - Tile color when it is revealed and has a number + * @property {string} TILE_REVEALED_EMPTY - Tile color when it is revealed and is empty + * @property {string} TILE_FLAGGED - Tile color when it is flagged + * @property {string} SECTOR_OVERLAY - Sector overlay color + * @property {string} SECTOR_LOST_OVERLAY - Lost sector overlay color + * @property {string} SECTOR_BORDER - Sector border color + * @property {string} FLAG_PARTICLE_COLOR - Flag particle color + * @property {string} SOLVED_PARTICLE_COLOR - Solved particle color + * @property {string} LOST_PARTICLE_COLOR - Lost particle color + */ +export const COLORS = Object.freeze({ + BACKGROUND: "#1b262c", + BACKGROUND_ZOOMED: "#0e4c75", + TILE_DEFAULT: "#0e4c75", + TILE_CLICKABLE: "#115d8f", + TILE_REVEALED_NUMBERED: "#1b262c", + TILE_REVEALED_EMPTY: "#1b262c", + TILE_FLAGGED: "#25353d", + SECTOR_OVERLAY: "#90bdd9", + SECTOR_LOST_OVERLAY: "#fa908c", + SECTOR_BORDER: "#90bdd9", + FLAG_PARTICLE_COLOR: "#90bdd9", + SOLVED_PARTICLE_COLOR: "#90bdd9", + LOST_PARTICLE_COLOR: "#fa908c", +}); + +/** + * @constant + * @type {number} + * @exports SOLVED + */ +export const SOLVED = 1; + +/** + * @constant + * @type {number} + * @exports MINE + */ +export const MINE = -1; + +/** + * @constant + * @type {number} + * @exports SECTOR_SIZE + */ +export const SECTOR_SIZE = 9; + +/** + * @constant + * @type {number} + * @exports DIFFICULTY + */ +export const DIFFICULTY = 19; + +/** + * @constant + * @type {number} + * @exports CENTRAL_AREA_DIFFICULTY_MODIFIER + */ +export const CENTRAL_AREA_DIFFICULTY_MODIFIER = 0.8; + +/** + * @constant + * @type {number} + * @exports DETAIL_THRESHOLD + */ +export const DETAIL_THRESHOLD = 25; + +/** + * @constant + * @type {number} + * @exports DRAG_THRESHOLD + */ +export const DRAG_THRESHOLD = 20; + +/** + * @constant + * @type {number} + * @exports MAX_TRIES + */ +export const MAX_TRIES = 50; + +/** + * @constant + * @type {number} + * @exports ANIMATION_SPEED_BASE + */ +export const ANIMATION_SPEED_BASE = 150; + +/** + * @constant + * @type {string} + * @exports CANVAS_ID + */ +export const CANVAS_ID = "main-canvas"; diff --git a/src/js/event_bus.js b/src/js/event_bus.js new file mode 100644 index 0000000..89a7b5e --- /dev/null +++ b/src/js/event_bus.js @@ -0,0 +1,75 @@ +/** + * @author Syed Daanish + * @file `event_bus.js` + */ + +/** + * @name EventBus + * @access public + * @classdesc Handles event emission & listening + * to connect with other modules. + * @exports EventBus + * @default + */ +export default class EventBus { + /** + * @constructs EventBus + * @function constructor + * @description Initializes the event bus + */ + constructor() { + this.retreivable_events = {}; + this.events = {}; + } + /** + * @function on + * @description Sets up an event listener + * @param {string} event - Event name + * @param {Function} callback - Callback function to call + * (multiple functions can be added) + */ + on(event, callback) { + if (!this.events[event]) { + this.events[event] = []; + } + this.events[event].push(callback); + } + /** + * @function emit + * @description Emits an event + * @param {string} event - Event name + * @param {...*} args - Arguments to pass to the callback + * functions (0 or more) + */ + emit(event, ...args) { + if (this.events[event]) { + this.events[event].forEach((fn) => { + fn(...args); + }); + } + } + /** + * @function onRetrievable + * @description Sets up a retrievable event listener + * @param {string} event - Event name + * @param {Function} callback - Callback function to call + * (can only be used once) + */ + onRetrievable(event, callback) { + this.retreivable_events[event] = callback; + } + /** + * @function get + * @description Retrieves data from an event + * @param {string} event - Event name + * @param {...*} args - Arguments to pass to the callback + * function (only one) + * @returns {any|undefined} - Data from the event or undefined + */ + get(event, ...args) { + if (this.retreivable_events[event]) { + return this.retreivable_events[event](...args); + } + return undefined; + } +} diff --git a/src/js/event_handler.js b/src/js/event_handler.js new file mode 100644 index 0000000..f6f34fc --- /dev/null +++ b/src/js/event_handler.js @@ -0,0 +1,142 @@ +/** + * @author Syed Daanish + * @file `event_handler.js` + * @requires `constants.js` + * @requires `event_bus.js` + */ + +import { DRAG_THRESHOLD } from "./constants.js"; +import EventBus from "./event_bus.js"; + +/** + * @name EventHandler + * @access public + * @classdesc Handles user input & other events + * @exports EventHandler + * @default + */ +export default class EventHandler { + /** + * @constructs EventHandler + * @function constructor + * @description Initializes the event handler + * @param {HTMLCanvasElement} canvas + * @param {EventBus} bus + */ + constructor(canvas, bus) { + this.canvas = canvas; + this.bus = bus; + this.is_dragging = false; + this.is_mouse_down = false; + this.drag_start_pos = [0, 0]; + this.disable_click = false; + /** @listens EventBus#disable_click */ + this.bus.on("disable_click", () => { + this.disable_click = true; + }); + // Event listeners for game control + /** @event mousedown */ + this.canvas.addEventListener("mousedown", (e) => { + e.preventDefault(); + this.is_dragging = false; + this.is_mouse_down = true; + this.drag_start_pos = [e.offsetX, e.offsetY]; + }); + /** + * @event mousemove + * @fires EventBus#drag - optionally + */ + this.canvas.addEventListener("mousemove", (e) => { + if (this.is_dragging) { + const DELTAX = e.offsetX - this.drag_start_pos[0]; + const DELTAY = e.offsetY - this.drag_start_pos[1]; + this.bus.emit("drag", DELTAX, DELTAY); + this.drag_start_pos = [e.offsetX, e.offsetY]; + } else if (this.is_mouse_down) { + const DELTAX = Math.abs(e.offsetX - this.drag_start_pos[0]); + const DELTAY = Math.abs(e.offsetY - this.drag_start_pos[1]); + if (DELTAX > DRAG_THRESHOLD || DELTAY > DRAG_THRESHOLD) { + this.is_dragging = true; + } + } + }); + /** + * @event mouseup + * @fires EventBus#click - optionally + * @fires EventBus#clean_cache - optionally + */ + this.canvas.addEventListener("mouseup", (e) => { + this.is_mouse_down = false; + if (this.is_dragging) { + this.is_dragging = false; + this.bus.emit("clean_cache"); + return; + } + if (this.disable_click) return; + this.bus.emit("click", e.offsetX, e.offsetY, e.button); + }); + /** + * @event mouseleave + * @fires EventBus#clean_cache - optionally + */ + this.canvas.addEventListener("mouseleave", (_) => { + this.is_mouse_down = false; + if (this.is_dragging) { + this.is_dragging = false; + this.bus.emit("clean_cache"); + return; + } + }); + /** + * @event wheel + * @fires EventBus#zoom + * @fires EventBus#clean_cache + */ + this.canvas.addEventListener( + "wheel", + (e) => { + e.preventDefault(); + this.disable_click = this.bus.get( + "zoom", + e.offsetX, + e.offsetY, + e.deltaY > 0 ? false : true, + ); + this.bus.emit("clean_cache"); + }, + { passive: false }, + ); + /** @event click */ + this.canvas.addEventListener("click", (e) => { + e.preventDefault(); + }); + /** @event contextmenu */ + this.canvas.addEventListener("contextmenu", (e) => { + e.preventDefault(); + }); + // Event Listeners for other events + /** + * @event resize + * @fires EventBus#resize + */ + window.addEventListener("resize", () => { + this.bus.emit("resize"); + }); + /** + * @event beforeunload + * @fires EventBus#save + */ + window.addEventListener("beforeunload", () => { + this.bus.emit("save"); + }); + /** + * @event visibilitychange + * @fires EventBus#save - optionally + */ + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + this.bus.emit("save"); + } + }); + } +} diff --git a/src/js/game_controller.js b/src/js/game_controller.js new file mode 100644 index 0000000..5d15507 --- /dev/null +++ b/src/js/game_controller.js @@ -0,0 +1,185 @@ +/** + * @author Syed Daanish + * @file `game_controller.js` + * @requires `constants.js` + * @requires `utils.js` + * @requires `event_bus.js` + * @requires `event_handler.js` + * @requires `ui_renderer.js` + * @requires `game_logic.js` + * @requires `saver.js` + */ + +import { SECTOR_SIZE, MAX_TRIES } from "./constants.js"; +import { DataHasher } from "./utils.js"; +import EventBus from "./event_bus.js"; +import EventHandler from "./event_handler.js"; +import UIRenderer from "./ui_renderer.js"; +import GameLogic from "./game_logic.js"; +import Saver from "./saver.js"; + +/** + * @name GameController + * @access public + * @classdesc Entry point for all the game logic + * @exports GameController + * @default + */ +export default class GameController { + /** + * @constructs GameController + * @function constructor + * @description Initializes the game controller + * @param {string} seed - Seed for the game + */ + constructor(seed) { + this.is_seeded = seed !== undefined; + /** @type {string} - Seed for the game (by default a stringified 16 digit random number) */ + this.seed = seed || (Math.floor(Math.random() * 9e15) + 1e15).toString(); + /** @type {Uint32Array} */ + this.key = DataHasher.generate_key(this.seed); + this.game_pos = { + data_sectors: {}, + cached_sectors: {}, + lost_sectors: {}, + animated_tiles: { flagged: {}, revealed: {}, hidden: {}, bombed: {} }, + animated_sectors: { solved: {}, lost: {}, bought: {} }, + }; + this.img = new Image(); + this.bus = new EventBus(); + this.game_logic = new GameLogic(this.game_pos, this.key, this.bus); + this.renderer = new UIRenderer(this.img, this.game_pos, this.key, this.bus); + this.event_handler = new EventHandler(this.renderer.canvas, this.bus); + this.saver = new Saver(this, this.bus); + /** @listens EventBus#reset */ + this.bus.on("reset", this.reset.bind(this)); + } + /** + * @function init + * @description Initializes the game + * @async + */ + async init() { + try { + await this.loadImage(); + } catch (err) { + console.error("Failed to load image:", err); + } + this.start(); + } + /** + * @function loadImage + * @description Loads the game image + * @returns {Promise} - Promise that resolves when the image is loaded + * @throws {Error} - If the image fails to load + */ + loadImage() { + return new Promise((resolve, reject) => { + this.img.onload = () => resolve(); + this.img.onerror = () => reject(new Error("Image failed to load")); + this.img.src = "src/assets/img/minesweeper.png"; + }); + } + /** + * @function start + * @description Starts the game + * @async + * @param {number} tries - Number of times the game has been reset + * @fires EventBus#start + */ + async start(tries = 0) { + if (this.bus.get("is_saved")) { + this._loadSavedGame(); + } else { + if (!(await this._tryOpening(tries))) return; + this.bus.emit("start"); + } + } + /** + * @function _loadSavedGame + * @description Loads a saved game + * @access private + * @fires EventBus#load_save + * @fires EventBus#start_autosaver + * @fires EventBus#start + */ + _loadSavedGame() { + this.bus.emit("load_save"); + this.bus.emit("start_autosaver"); + this.bus.emit("start"); + } + /** + * @function _tryOpening + * @description Tries to open the game + * @async + * @access private + * @param {number} tries - Number of times the game has been reset + * @returns {Promise} - Promise that resolves to true if the game was opened successfully + * @fires EventBus#reveal + * @fires EventBus#start_autosaver + */ + async _tryOpening(tries) { + const CENTRE = (SECTOR_SIZE - 1) / 2; + if (!this.is_seeded && tries < MAX_TRIES) { + if ( + !(await this.bus.get( + "reveal", + CENTRE, + CENTRE, + undefined, + undefined, + true, + )) + ) { + await this.reset(tries + 1); + return false; + } + } else { + this.bus.emit("reveal", CENTRE, CENTRE); + } + this.bus.emit("start_autosaver"); + return true; + } + /** + * @function reset + * @description Resets the game + * @async + * @param {number} tries - Number of times the game has been reset + */ + async reset(tries = 0) { + this._clearGameState(); + this._generateNewSeed(); + await this.start(tries); + } + /** + * @function _clearGameState + * @description Clears the game state + * @access private + */ + _clearGameState() { + const { + data_sectors, + cached_sectors, + lost_sectors, + animated_tiles, + animated_sectors, + } = this.game_pos; + [data_sectors, cached_sectors, lost_sectors].forEach((sector) => { + for (const key in sector) delete sector[key]; + }); + [animated_tiles, animated_sectors].forEach((group) => { + for (const type in group) group[type] = {}; + }); + } + /** + * @function _generateNewSeed + * @description Generates a new seed and key + * @access private + * @fires EventBus#update_key + */ + _generateNewSeed() { + this.seed = (Math.floor(Math.random() * 9e15) + 1e15).toString(); + this.key = DataHasher.generate_key(this.seed); + this.bus.emit("update_key", this.key); + } +} diff --git a/src/js/game_logic.js b/src/js/game_logic.js new file mode 100644 index 0000000..90c7c98 --- /dev/null +++ b/src/js/game_logic.js @@ -0,0 +1,524 @@ +/** + * @author Syed Daanish + * @file `game_logic.js` + * @requires `constants.js` + * @requires `utils.js` + * @requires `event_bus.js` + */ + +import { + TILE_STATES, + SOLVED, + MINE, + SECTOR_SIZE, + ANIMATION_SPEED_BASE, + DIFFICULTY, +} from "./constants.js"; +import { DataHasher, LOOPS, convert, isMine } from "./utils.js"; +import EventBus from "./event_bus.js"; + +/** + * @name GameLogic + * @access public + * @classdesc Handles primary game logic + * @exports GameLogic + * @default + */ +export default class GameLogic { + /** + * @constructs GameLogic + * @function constructor + * @description Initializes the game logic + * @param {Object} game_pos + * @param {Uint32Array} key + * @param {EventBus} bus + */ + constructor(game_pos, key, bus) { + this.key = key; + this.bus = bus; + this.game_pos = game_pos; + this.stats = { + mines: 0, + flags: 0, + solved: 0, + numbers: Array(9).fill(0), + time: 0, + }; + /** @type {Date} */ + this.start_time = Date.now(); + this.goldmines = 0; + /** @listens EventBus#click */ + this.bus.on("click", this.click.bind(this)); + /** @listens EventBus#clean_cache */ + this.bus.on("clean_cache", this.cleanSectorCache.bind(this)); + /** @listens EventBus#update_key */ + this.bus.on("update_key", this.updateKey.bind(this)); + /** @listens EventBus#set_stats */ + this.bus.on("set_stats", this.setStats.bind(this)); + /** @listens EventBus#reveal */ + this.bus.onRetrievable("reveal", this.reveal.bind(this)); + /** @listens EventBus#request_cache */ + this.bus.onRetrievable("request_cache", this.buildSectorCache.bind(this)); + /** @listens EventBus#is_clickable */ + this.bus.onRetrievable("is_clickable", this.isClickable.bind(this)); + /** @listens EventBus#time */ + this.bus.onRetrievable("time_and_stats", this.timeAndStats.bind(this)); + } + /** + * @function timeAndStats + * @description Returns the current time in seconds + * @returns {Object} - Time and stats + */ + timeAndStats() { + this.stats.time += Date.now() - this.start_time; + this.start_time = Date.now(); + return this.stats; + } + /** + * @function setStats + * @description Sets the stats + * @param {Object} stats - Stats from the save + */ + setStats(stats) { + this.start_time = Date.now(); + ["mines", "flags", "solved", "numbers", "time"].forEach((key) => { + if (stats.hasOwnProperty(key)) { + this.stats[key] = stats[key]; + } + }); + } + /** + * @function click + * @description Handles a click + * @param {number} x - X-coordinate in terms of screen + * @param {number} y - Y-coordinate in terms of screen + * @param {number} button - 0 for left, 1 for middle, 2 for right (from Event#button) + * @fires EventBus#click_convert + * @fires EventBus#is_buy_button + */ + click(x, y, button) { + const [X, Y] = this.bus.get("click_convert", x, y) || [ + undefined, + undefined, + ]; + if (X === undefined || Y === undefined) return; + if (this.bus.get("is_buy_button", X, Y)) { + if (button == 0) this.buy(X, Y); + return; + } + if (!this.isClickable(X, Y)) return; + if (button == 0) { + if (this.isFlagged(X, Y)) return; + if (this.isRevealed(X, Y)) { + if (this.flagCount(X, Y) === this.mineCount(X, Y)) { + for (let i = -1; i <= 1; i++) { + for (let j = -1; j <= 1; j++) { + if ( + (i != 0 || j != 0) && + !this.isRevealed(X + i, Y + j) && + !this.isFlagged(X + i, Y + j) + ) { + this.reveal(X + i, Y + j); + } + } + } + } + return; + } + this.reveal(X, Y); + } else if (button === 2) { + this.flag(X, Y); + } + } + /** + * @function updateKey + * @description Updates the game key + * @param {Uint32Array} key + */ + updateKey(key) { + this.key = key; + } + /** + * @function buy + * @description Buys a sector + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + */ + buy(x, y, s_x, s_y) { + const [S_X, S_Y] = convert(true, x, y, s_x, s_y); + const SECTOR_KEY = `${S_X}:${S_Y}`; + const PRICE = this.game_pos.lost_sectors[SECTOR_KEY]; + if (this.goldmines >= PRICE) { + this.goldmines -= PRICE; + if (this.game_pos.lost_sectors.hasOwnProperty(SECTOR_KEY)) { + delete this.game_pos.lost_sectors[SECTOR_KEY]; + delete this.game_pos.animated_sectors.lost[SECTOR_KEY]; + } + } + } + /** + * @function reveal + * @description Reveals a tile + * @async + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + * @param {boolean} [no_animate=false] - If true, no animation will be played + * @returns {Promise} - Resolves to whether the reveal caused more + * than one tile to be revealed + */ + async reveal(x, y, s_x, s_y, no_animate = false) { + let recursed = false; + [s_x, s_y, x, y] = convert(true, x, y, s_x, s_y); + const SECTOR_KEY = `${s_x}:${s_y}`; + if (this.game_pos.data_sectors?.[SECTOR_KEY] === SOLVED) return; + if (!this.game_pos.data_sectors.hasOwnProperty(SECTOR_KEY)) { + this.buildSector(s_x, s_y); + } + const TILE = this.game_pos.data_sectors[SECTOR_KEY][y][x]; + if (TILE[1] != TILE_STATES.REVEALED) { + if (TILE[0] == MINE) { + TILE[1] = TILE_STATES.LOST; + this.game_pos.lost_sectors[SECTOR_KEY] = + 3 + + Math.floor( + ((DIFFICULTY - 14) / 10 + + DataHasher.hash(this.key, `${SECTOR_KEY}`)) * + 5, + ); + this.animate(SECTOR_KEY, "lost", false); + this.stats.mines++; + this.animate( + `${SECTOR_KEY}:${x}:${y}`, + "bombed", + true, + ANIMATION_SPEED_BASE * 3, + ); + } else if (TILE[0] === 0) { + TILE[1] = TILE_STATES.REVEALED; + if (!no_animate) { + this.animate( + `${SECTOR_KEY}:${x}:${y}`, + "revealed", + true, + ANIMATION_SPEED_BASE * 0.2, + ); + await new Promise((resolve) => + setTimeout(resolve, ANIMATION_SPEED_BASE * 0.2), + ); + } + recursed = true; + let promises = []; + LOOPS.overAdjacent( + (x, y) => { + if (!this.isRevealed(x, y)) { + promises.push(this.reveal(x, y)); + } + }, + x, + y, + s_x, + s_y, + ); + await Promise.all(promises); + } else if (TILE[0] > 0) { + TILE[1] = TILE_STATES.REVEALED; + if (!no_animate) { + this.animate(`${SECTOR_KEY}:${x}:${y}`, "revealed", true); + await new Promise((resolve) => + setTimeout(resolve, ANIMATION_SPEED_BASE), + ); + } + } + } + if ( + this.isSectorSolved(SECTOR_KEY) && + this.game_pos.data_sectors[SECTOR_KEY] !== SOLVED + ) { + if (this.game_pos.lost_sectors.hasOwnProperty(SECTOR_KEY)) + delete this.game_pos.lost_sectors[SECTOR_KEY]; + this.game_pos.data_sectors[SECTOR_KEY] = SOLVED; + this.animate(SECTOR_KEY, "solved", false); + this.collectStats(s_x, s_y); + this.goldmines++; + } + return recursed; + } + /** + * @function flag + * @description Flags a tile + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + */ + flag(x, y, s_x, s_y) { + [s_x, s_y, x, y] = convert(true, x, y, s_x, s_y); + const BOARD_SECTOR = this.game_pos.data_sectors[`${s_x}:${s_y}`]; + const TILE = + BOARD_SECTOR === SOLVED + ? null + : BOARD_SECTOR?.[y][x] || this.buildSector(s_x, s_y)?.[y][x] || null; + if (!TILE) return; + if (TILE[1] !== TILE_STATES.REVEALED) { + if (TILE[1] === TILE_STATES.FLAGGED) { + TILE[1] = TILE_STATES.HIDDEN; + } else { + TILE[1] = TILE_STATES.FLAGGED; + this.game_pos.animated_tiles.flagged[`${s_x}:${s_y}:${x}:${y}`] = [ + Date.now(), + ANIMATION_SPEED_BASE * 1.75, + ]; + } + } + } + /** + * @function isClickable + * @description Checks if a tile is clickable + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + * @returns {boolean} - True if tile is clickable, false otherwise + */ + isClickable(x, y, s_x, s_y) { + [x, y] = convert(false, x, y, s_x, s_y); + [s_x, s_y] = convert(true, x, y); + if (this.game_pos.lost_sectors.hasOwnProperty(`${s_x}:${s_y}`)) + return false; + if (this.isRevealed(x, y)) return true; + return LOOPS.anyAdjacent( + (x, y) => this.isRevealed(x, y) || this.isFlagged(x, y), + x, + y, + ); + } + /** + * @function isFlagged + * @description Checks if a tile is flagged + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + * @returns {boolean} - True if tile is flagged, false otherwise + */ + isFlagged(x, y, s_x, s_y) { + [s_x, s_y, x, y] = convert(true, x, y, s_x, s_y); + const BOARD_SECTOR = this.game_pos.data_sectors[`${s_x}:${s_y}`]; + const TILE = + BOARD_SECTOR === SOLVED + ? this.buildSectorCache(s_x, s_y)?.[y][x] || null + : BOARD_SECTOR?.[y][x] || null; + return TILE?.[1] === TILE_STATES.FLAGGED; + } + /** + * @function isRevealed + * @description Checks if a tile is revealed + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + * @returns {boolean} - True if tile is revealed, false otherwise + */ + isRevealed(x, y, s_x, s_y) { + [s_x, s_y, x, y] = convert(true, x, y, s_x, s_y); + const BOARD_SECTOR = this.game_pos.data_sectors[`${s_x}:${s_y}`]; + const TILE = + BOARD_SECTOR === SOLVED + ? this.buildSectorCache(s_x, s_y)?.[y][x] || null + : BOARD_SECTOR?.[y][x] || null; + return TILE?.[1] === TILE_STATES.REVEALED; + } + /** + * @function isSectorSolved + * @description Checks if a sector is solved + * @param {string} sector_key - Key of sector + * @returns {boolean} - True if sector is solved, false otherwise + */ + isSectorSolved(sector_key) { + const SECTOR = this.game_pos.data_sectors[sector_key]; + if (!SECTOR) return false; + if (SECTOR === SOLVED) return true; + return ( + LOOPS.overTilesInSectorSum((x, y) => { + const [TILE_NUM, TILE_STATE] = SECTOR[y][x]; + if (TILE_NUM !== MINE && TILE_STATE !== TILE_STATES.REVEALED) { + return false; + } + return true; + }) == + SECTOR_SIZE ** 2 + ); + } + /** + * @function collectStats + * @description Collects statistics about a solved sector into the stats object + * @param {number} s_x - X-coordinate of sector + * @param {number} s_y - Y-coordinate of sector + */ + collectStats(s_x, s_y) { + const SECTOR_KEY = `${s_x}:${s_y}`; + if (!this.game_pos.data_sectors[SECTOR_KEY]) return null; + if (this.game_pos.data_sectors[SECTOR_KEY] != SOLVED) return null; + const SECTOR = this.buildSectorCache(s_x, s_y); + this.stats.solved++; + LOOPS.overTilesInSector((x, y) => { + const [TILE_NUM, TILE_STATE] = SECTOR[y][x]; + if (TILE_STATE === TILE_STATES.FLAGGED) { + this.stats.flags++; + } else if (TILE_STATE === TILE_STATES.REVEALED) { + this.stats.numbers[TILE_NUM]++; + } + }); + } + /** + * @function flagCount + * @description Counts the number of flags adjacent to a tile + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + * @returns {number} - The number of flags adjacent to the tile + */ + flagCount(x, y, s_x, s_y) { + [x, y] = convert(false, x, y, s_x, s_y); + return LOOPS.overAdjacentSum((x, y) => this.isFlagged(x, y), x, y); + } + /** + * @function mineCount + * @description Counts the number of mines adjacent to a tile + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + * @param {boolean} [force=false] - Force count even if tile is revealed + * @returns {number} - The number of mines adjacent to the tile + */ + mineCount(x, y, s_x, s_y, force = false) { + [s_x, s_y, x, y] = convert(true, x, y, s_x, s_y); + const [G_X, G_Y] = convert(false, x, y, s_x, s_y); + if (!force) { + const BOARD_SECTOR = this.game_pos.data_sectors[`${s_x}:${s_y}`]; + const TILE = + BOARD_SECTOR === SOLVED + ? this.buildSectorCache(s_x, s_y)[y][x] + : BOARD_SECTOR?.[y][x] || null; + if (TILE) return TILE[0]; + } + return LOOPS.overAdjacentSum( + (x, y) => { + const [S_X, S_Y, X, Y] = convert(true, x, y); + const BOARD_SECTOR = this.game_pos.data_sectors[`${S_X}:${S_Y}`]; + if (force) { + if (isMine(this.key, X, Y, S_X, S_Y)) return true; + } else { + const TILE = + BOARD_SECTOR === SOLVED + ? this.buildSectorCache(S_X, S_Y)[Y][X] + : BOARD_SECTOR?.[Y][X] || null; + if (TILE) { + if (TILE[0] === MINE) return true; + } else { + if (isMine(this.key, X, Y, S_X, S_Y)) return true; + } + } + return false; + }, + G_X, + G_Y, + ); + } + /** + * @function buildSector + * @description Builds a new sector + * @param {number} s_x - X-coordinate of sector + * @param {number} s_y - Y-coordinate of sector + * @returns {Array>>} - The sector + */ + buildSector(s_x, s_y) { + const SECTOR_KEY = `${s_x}:${s_y}`; + if (this.game_pos.data_sectors.hasOwnProperty(SECTOR_KEY)) + return this.game_pos.data_sectors[SECTOR_KEY]; + this.game_pos.data_sectors[SECTOR_KEY] = Array.from( + { length: SECTOR_SIZE }, + (_, y) => + Array.from({ length: SECTOR_SIZE }, (_, x) => { + return isMine(this.key, x, y, s_x, s_y) + ? [MINE, TILE_STATES.HIDDEN] + : [undefined, TILE_STATES.HIDDEN]; + }), + ); + LOOPS.overTilesInSector((x, y) => { + if (this.game_pos.data_sectors[SECTOR_KEY][y][x][0] !== MINE) { + this.game_pos.data_sectors[SECTOR_KEY][y][x][0] = this.mineCount( + x, + y, + s_x, + s_y, + true, + ); + } + }); + return this.game_pos.data_sectors[SECTOR_KEY]; + } + /** + * @function buildSectorCache + * @description Builds a sector cache + * @param {number} s_x - X-coordinate of sector + * @param {number} s_y - Y-coordinate of sector + * @returns {Array>>|false} - The cached sector + */ + buildSectorCache(s_x, s_y) { + const SECTOR_KEY = `${s_x}:${s_y}`; + if (this.game_pos.data_sectors[SECTOR_KEY] !== SOLVED) return false; + if (this.game_pos.cached_sectors.hasOwnProperty(SECTOR_KEY)) + return this.game_pos.cached_sectors[SECTOR_KEY]; + this.game_pos.cached_sectors[SECTOR_KEY] = Array.from( + { length: SECTOR_SIZE }, + () => new Array(SECTOR_SIZE), + ); + LOOPS.overTilesInSector((x, y) => { + const TILE = isMine(this.key, x, y, s_x, s_y) + ? [MINE, TILE_STATES.FLAGGED] + : [this.mineCount(x, y, s_x, s_y, true), TILE_STATES.REVEALED]; + this.game_pos.cached_sectors[SECTOR_KEY][y][x] = TILE; + }); + return this.game_pos.cached_sectors[SECTOR_KEY]; + } + /** + * @function cleanSectorCache + * @description Cleans the sector cache + * @fires EventBus#sector_bounds + */ + cleanSectorCache() { + const BOUNDS = this.bus.get("sector_bounds"); + if (!BOUNDS) return; + const [START_X, START_Y, END_X, END_Y] = BOUNDS; + for (const KEY in this.game_pos.cached_sectors) { + const [X, Y] = KEY.split(":").map(Number); + if (X < START_X - 1 || X >= END_X || Y < START_Y - 1 || Y >= END_Y) { + delete this.game_pos.cached_sectors[KEY]; + } + } + } + /** + * @function animate + * @description Animate a tile or sector + * @param {string} key - Key to use for hashing + * @param {string} type - Type of animation + * @param {boolean} is_tile - Whether the animation is for a tile + * @param {number} [duration=ANIMATION_SPEED_BASE] - Duration of the animation + * if it is a tile (in ms). + */ + animate(key, type, is_tile = true, duration = ANIMATION_SPEED_BASE) { + const now = Date.now(); + if (is_tile) { + this.game_pos.animated_tiles[type][key] = [now, duration]; + } else { + this.game_pos.animated_sectors[type][key] = now; + } + } +} diff --git a/src/js/game_renderer.js b/src/js/game_renderer.js new file mode 100644 index 0000000..d78c89b --- /dev/null +++ b/src/js/game_renderer.js @@ -0,0 +1,896 @@ +/** + * @author Syed Daanish + * @file `game_renderer.js` + * @requires `constants.js` + * @requires `utils.js` + * @requires `event_bus.js` + */ + +import { + TILE_STATES, + COLORS, + SOLVED, + SECTOR_SIZE, + ANIMATION_SPEED_BASE, + DETAIL_THRESHOLD, + CANVAS_ID, +} from "./constants.js"; +import { DataHasher, LOOPS } from "./utils.js"; +import EventBus from "./event_bus.js"; + +/** + * @name GameRenderer + * @access public + * @classdesc Handles game rendering + * @exports GameRenderer + * @default + */ +export default class GameRenderer { + /** + * @constructs GameRenderer + * @function constructor + * @description Initializes the game renderer + * @param {Image} img + * @param {Object} game_pos + * @param {Uint32Array} key + * @param {EventBus} bus + */ + constructor(img, game_pos, key, bus) { + /** @type {HTMLCanvasElement} */ + this.canvas = document.getElementById(CANVAS_ID); + this.canvas.width = window.innerWidth; + this.canvas.height = window.innerHeight; + /** @type {CanvasRenderingContext2D} */ + this.ctx = this.canvas.getContext("2d"); + this.ctx.imageSmoothingEnabled = false; + this.img = img; + this.game_pos = game_pos; + this.key = key; + this.bus = bus; + this.tile_size = 40; + this.offset = [ + this.canvas.width / 2 - + (this.tile_size * SECTOR_SIZE) / 2 + + this.tile_size / 2, + this.canvas.height / 2 - + (this.tile_size * SECTOR_SIZE) / 2 + + this.tile_size / 2, + ]; + this.border_width = this.tile_size * 0.065; + this.hide_details = false; + this.sector_pixel_size = SECTOR_SIZE * this.tile_size; + /** @listens EventBus#set_view_pos */ + this.bus.on("set_view_pos", this.setViewPos.bind(this)); + /** @listens EventBus#view_pos */ + this.bus.onRetrievable("view_pos", this.getViewPos.bind(this)); + } + /** + * @function getViewPos + * @description Returns the current view position (for saving) + * @return {Object} - The view position (offset, tile_size) + */ + getViewPos() { + return { + offset: this.offset, + tile_size: this.tile_size, + }; + } + /** + * @function setViewPos + * @description Sets the view position (from saved data) + * @param {Object} view_pos - The view position (offset, tile_size) + * @fires EventBus#disable_click - optionally + */ + setViewPos(view_pos) { + this.offset = view_pos.offset; + this.tile_size = view_pos.tile_size; + this.sector_pixel_size = SECTOR_SIZE * this.tile_size; + this.hide_details = this.tile_size < DETAIL_THRESHOLD; + if (this.hide_details) this.bus.emit("disable_click"); + this.border_width = this.hide_details ? 0 : this.tile_size * 0.065; + } + /** + * @function drawGame + * @description Draws the main game frame + */ + drawGame() { + this.ctx.fillStyle = !this.hide_details + ? COLORS.BACKGROUND + : COLORS.BACKGROUND_ZOOMED; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + const START_X = -Math.floor(this.offset[0] / this.sector_pixel_size); + const START_Y = -Math.ceil(this.offset[1] / this.sector_pixel_size); + LOOPS.overOnScreenSectors( + (s_x, s_y) => { + const BOARD_SECTOR = this.game_pos.data_sectors[`${s_x}:${s_y}`]; + const SECTOR = + BOARD_SECTOR === SOLVED + ? this.bus.get("request_cache", s_x, s_y) || false + : BOARD_SECTOR || false; + LOOPS.overTilesInSector((x, y) => { + this.drawStaticTiles(SECTOR, s_x, s_y, x, y); + }); + }, + START_X, + START_Y, + this.sector_pixel_size, + this.canvas, + ); + LOOPS.overOnScreenSectors( + (s_x, s_y) => { + const BOARD_SECTOR = this.game_pos.data_sectors[`${s_x}:${s_y}`]; + const SECTOR = + BOARD_SECTOR === SOLVED + ? this.bus.get("request_cache", s_x, s_y) || false + : BOARD_SECTOR || false; + LOOPS.overTilesInSector((x, y) => { + this.drawAnimatedTiles(SECTOR, s_x, s_y, x, y); + }); + this.drawSectorOverlays(s_x, s_y); + this.drawSolvedAnimations(s_x, s_y); + this.drawLostAnimations(s_x, s_y); + if (!this.hide_details) { + this.drawSectorBorders(s_x, s_y); + this.drawBuyButtons(s_x, s_y); + } + }, + START_X, + START_Y, + this.sector_pixel_size, + this.canvas, + ); + if (!this.hide_details) { + this.drawSectorBorders(START_X, START_Y); + } + } + /** + * @function drawBuyButtons + * @description Draws the buy buttons for a sector + * @param {number} s_x - X-coordinate of sector + * @param {number} s_y - Y-coordinate of sector + */ + drawBuyButtons(s_x, s_y) { + const KEY = `${s_x}:${s_y}`; + if ( + this.game_pos.animated_sectors.lost.hasOwnProperty(KEY) || + !this.game_pos.lost_sectors.hasOwnProperty(KEY) + ) { + return; + } + const BASE_X = s_x * this.sector_pixel_size + this.offset[0]; + const BASE_Y = s_y * this.sector_pixel_size + this.offset[1]; + const B_WIDTH = this.sector_pixel_size - this.border_width; + const EMOJI_SIZE = B_WIDTH / 4; + this.ctx.drawImage( + this.img, + 50, + 12, + 14, + 14, + BASE_X + this.sector_pixel_size / 2 - EMOJI_SIZE / 2, + BASE_Y + this.sector_pixel_size / 5 - EMOJI_SIZE / 2, + EMOJI_SIZE, + EMOJI_SIZE, + ); + this.ctx.drawImage( + this.img, + 26, + 46, + 47, + 7, + BASE_X + this.sector_pixel_size / 8, + BASE_Y + (7 * this.sector_pixel_size) / 10, + ((B_WIDTH / 15) * 47) / 7, + B_WIDTH / 15, + ); + this.ctx.drawImage( + this.img, + 0, + 60, + 31, + 8, + BASE_X + this.sector_pixel_size / 5, + BASE_Y + (8 * this.sector_pixel_size) / 10, + ((B_WIDTH / 13) * 31) / 8, + B_WIDTH / 13, + ); + this.ctx.drawImage( + this.img, + 49, + 54, + 11, + 14, + BASE_X + (13 * this.sector_pixel_size) / 20, + BASE_Y + (13 * this.sector_pixel_size) / 20, + ((B_WIDTH / 4) * 11) / 14, + B_WIDTH / 4, + ); + const WIDTH_BUTTON = ((B_WIDTH / 5) * 25) / 13; + const HEIGHT_BUTTON = B_WIDTH / 5; + this.ctx.drawImage( + this.img, + 0, + 46, + 25, + 13, + BASE_X + this.sector_pixel_size / 2 - WIDTH_BUTTON / 2, + BASE_Y + this.sector_pixel_size / 2 - HEIGHT_BUTTON / 2, + WIDTH_BUTTON, + HEIGHT_BUTTON, + ); + const DIGIT_1 = Math.floor(this.game_pos.lost_sectors[KEY] / 10); + const DIGIT_2 = this.game_pos.lost_sectors[KEY] % 10; + const DIGIT_WIDTH = ((B_WIDTH / 13) * 3) / 5; + const DIGIT_HEIGHT = B_WIDTH / 13; + if (DIGIT_1 != 0) { + this.ctx.drawImage( + this.img, + DIGIT_1 * 4, + 6, + 3, + 5, + BASE_X + + this.sector_pixel_size / 2 - + WIDTH_BUTTON / 2 + + (WIDTH_BUTTON / 25) * 6, + BASE_Y + + this.sector_pixel_size / 2 - + HEIGHT_BUTTON / 2 + + (HEIGHT_BUTTON / 13) * 4, + DIGIT_WIDTH, + DIGIT_HEIGHT, + ); + } + const DIGIT_OFFSET = DIGIT_1 == 0 ? (WIDTH_BUTTON / 25) * 2 : 0; + this.ctx.drawImage( + this.img, + DIGIT_2 * 4, + 6, + 3, + 5, + BASE_X + + this.sector_pixel_size / 2 - + WIDTH_BUTTON / 2 + + (WIDTH_BUTTON / 25) * 10 - + DIGIT_OFFSET, + BASE_Y + + this.sector_pixel_size / 2 - + HEIGHT_BUTTON / 2 + + (HEIGHT_BUTTON / 13) * 4, + DIGIT_WIDTH, + DIGIT_HEIGHT, + ); + } + /** + * @function drawRoundedRect + * @description Draws a rounded rectangle + * @param {number} x - X-coordinate of top-left corner + * @param {number} y - Y-coordinate of top-left corner + * @param {number} width - Width of rectangle + * @param {number} height - Height of rectangle + * @param {number} radius - Radius of rounded corners + */ + drawRoundedRect(x, y, width, height, radius) { + if (this.hide_details) { + this.ctx.fillRect(x, y, width, height); + return; + } + this.ctx.beginPath(); + this.ctx.moveTo(x + radius, y); + this.ctx.lineTo(x + width - radius, y); + this.ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + this.ctx.lineTo(x + width, y + height - radius); + this.ctx.quadraticCurveTo( + x + width, + y + height, + x + width - radius, + y + height, + ); + this.ctx.lineTo(x + radius, y + height); + this.ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + this.ctx.lineTo(x, y + radius); + this.ctx.quadraticCurveTo(x, y, x + radius, y); + this.ctx.closePath(); + this.ctx.fill(); + } + /** + * @function drawStaticTiles + * @description Draws a static tile + * @param {Array>>|false} sector + * @param {number} s_x - X-coordinate of sector + * @param {number} s_y - Y-coordinate of sector + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + */ + drawStaticTiles(sector, s_x, s_y, x, y) { + const TILE_X = (s_x * SECTOR_SIZE + x) * this.tile_size + this.offset[0]; + const TILE_Y = (s_y * SECTOR_SIZE + y) * this.tile_size + this.offset[1]; + if (!sector) { + if (this.hide_details) return; + this._drawEmptyOrClickable( + TILE_X, + TILE_Y, + this.tile_size - this.border_width, + x, + y, + s_x, + s_y, + ); + return; + } + const [TILE_NUM, TILE_STATE] = sector[y][x]; + const KEY = `${s_x}:${s_y}:${x}:${y}`; + if (Object.values(this.game_pos.animated_tiles).some((sub) => KEY in sub)) { + return; + } + switch (TILE_STATE) { + case TILE_STATES.REVEALED: + this._drawRevealedStaticTile(TILE_X, TILE_Y, TILE_NUM); + break; + case TILE_STATES.FLAGGED: + case TILE_STATES.LOST: + this._drawFlaggedOrLostTile(TILE_X, TILE_Y, TILE_STATE); + break; + default: + if (this.hide_details) return; + this._drawEmptyOrClickable( + TILE_X, + TILE_Y, + this.tile_size - this.border_width, + x, + y, + s_x, + s_y, + ); + } + if (this.hide_details) return; + this._drawStaticTileDetails( + TILE_X, + TILE_Y, + TILE_NUM, + TILE_STATE, + this.tile_size - this.border_width, + ); + } + /** + * @function _drawEmptyOrClickable + * @description Draws an empty or clickable tile + * @access private + * @param {number} tile_x - X-coordinate of top-left corner of tile + * @param {number} tile_y - Y-coordinate of top-left corner of tile + * @param {number} size - Size of tile + * @param {number} x - local X-coordinate of tile + * @param {number} y - local Y-coordinate of tile + * @param {number} s_x - X-coordinate of sector + * @param {number} s_y - Y-coordinate of sector + */ + _drawEmptyOrClickable(tile_x, tile_y, size, x, y, s_x, s_y) { + if (this.bus.get("is_clickable", x, y, s_x, s_y)) { + this.ctx.fillStyle = COLORS.TILE_CLICKABLE; + this.drawRoundedRect(tile_x, tile_y, size, size, this.tile_size / 15); + } else { + this.ctx.fillStyle = COLORS.TILE_DEFAULT; + this.ctx.fillRect(tile_x, tile_y, size, size); + } + } + /** + * @function _drawRevealedStaticTile + * @description Draws a revealed tile + * @access private + * @param {number} tile_x - X-coordinate of top-left corner of tile + * @param {number} tile_y - Y-coordinate of top-left corner of tile + * @param {number} tile_num - Number of mines adjacent to tile + */ + _drawRevealedStaticTile(tile_x, tile_y, tile_num) { + this.ctx.fillStyle = + tile_num !== 0 + ? COLORS.TILE_REVEALED_NUMBERED + : COLORS.TILE_REVEALED_EMPTY; + this.ctx.fillRect(tile_x, tile_y, this.tile_size, this.tile_size); + } + /** + * @function _drawFlaggedOrLostTile + * @description Draws a flagged or lost tile + * @access private + * @param {number} tile_x - X-coordinate of top-left corner of tile + * @param {number} tile_y - Y-coordinate of top-left corner of tile + * @param {number} tile_state - State of tile + */ + _drawFlaggedOrLostTile(tile_x, tile_y, tile_state) { + this.ctx.fillStyle = + tile_state === TILE_STATES.LOST + ? COLORS.TILE_CLICKABLE + : COLORS.TILE_FLAGGED; + this.drawRoundedRect( + tile_x, + tile_y, + this.tile_size - this.border_width, + this.tile_size - this.border_width, + this.tile_size / 6, + ); + } + /** + * @function _drawStaticTileDetails + * @description Draws the details of a static tile + * @access private + * @param {number} tile_x - X-coordinate of top-left corner of tile + * @param {number} tile_y - Y-coordinate of top-left corner of tile + * @param {number} tile_num - Number of mines adjacent to tile + * @param {number} tile_state - State of tile + * @param {number} size - Size of tile without border + */ + _drawStaticTileDetails(tile_x, tile_y, tile_num, tile_state, size) { + let sx, sW, sH; + let dx, dy, dW, dH; + switch (tile_state) { + case TILE_STATES.REVEALED: + if (tile_num === 0) return; + (sx = (tile_num - 1) * 4), (sW = 3), (sH = 5); + dx = tile_x + (3 * this.tile_size) / 10; + dy = tile_y + this.tile_size / 6; + dW = (2 * this.tile_size) / 5; + dH = (2 * this.tile_size) / 3; + break; + case TILE_STATES.FLAGGED: + (sx = 44), (sW = 4), (sH = 7); + dx = tile_x + (3 * this.tile_size) / 10 - this.border_width / 2; + dy = tile_y + this.tile_size / 6; + dW = (2 * size) / 5; + dH = (2 * size) / 3; + break; + case TILE_STATES.LOST: + (sx = 32), (sW = sH = 5); + dx = tile_x + size / 4; + dy = tile_y + size / 4; + dW = dH = size / 2; + break; + default: + return; + } + this.ctx.drawImage(this.img, sx, 0, sW, sH, dx, dy, dW, dH); + } + /** + * @function drawAnimatedTiles + * @description Draws an animated tile + * @param {Array>>|false} sector + * @param {number} s_x - X-coordinate of sector + * @param {number} s_y - Y-coordinate of sector + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + */ + drawAnimatedTiles(sector, s_x, s_y, x, y) { + const ANIMATE_KEY = `${s_x}:${s_y}:${x}:${y}`; + const ANIMATEDTILES = this.game_pos.animated_tiles; + let type = null; + let animation_data = null; + for (const [T, SUB] of Object.entries(ANIMATEDTILES)) { + if (SUB.hasOwnProperty(ANIMATE_KEY)) { + type = T; + animation_data = SUB[ANIMATE_KEY]; + break; + } + } + if (!animation_data || !sector) return; + const [START_TIME, SPEED] = animation_data; + const FRAME_TIME = (Date.now() - START_TIME) / SPEED; + const TILE_X = (s_x * SECTOR_SIZE + x) * this.tile_size + this.offset[0]; + const TILE_Y = (s_y * SECTOR_SIZE + y) * this.tile_size + this.offset[1]; + this.ctx.fillStyle = COLORS.TILE_REVEALED_EMPTY; + this.ctx.fillRect(TILE_X, TILE_Y, this.tile_size, this.tile_size); + const current_scale = this._calculateCurrentScale(FRAME_TIME); + const [TILE_NUM] = sector[y][x]; + switch (type) { + case "revealed": + this._drawRevealedTileAnimated(TILE_X, TILE_Y, TILE_NUM, current_scale); + break; + case "flagged": + this._drawFlaggedTileAnimated( + TILE_X, + TILE_Y, + FRAME_TIME, + current_scale, + ); + break; + case "bombed": + this._drawBombedTileAnimated(TILE_X, TILE_Y, FRAME_TIME, current_scale); + break; + } + if (FRAME_TIME >= 1) { + delete ANIMATEDTILES[type][ANIMATE_KEY]; + } + } + /** + * @function _calculateCurrentScale + * @description Calculates the current scale of an animated tile + * @access private + * @param {number} frame_time - Time since animation start + * @return {number} - Current scale of tile + */ + _calculateCurrentScale(frame_time) { + if (frame_time >= 1) { + return 1.0; + } + const BASE_PROGRESS = + frame_time < 0.5 + ? 2 * frame_time * frame_time + : 1 - Math.pow(-2 * frame_time + 2, 2) / 2; + return Math.max( + BASE_PROGRESS <= 0.8 + ? 0.5 * BASE_PROGRESS + : 1.2 - 0.2 * (1 - (5 * (BASE_PROGRESS - 0.8) - 1) ** 3), + 0, + ); + } + /** + * @function _drawRevealedTileAnimated + * @description Draws a revealed tiles animation + * @access private + * @param {number} tile_x - X-coordinate of top-left corner of tile + * @param {number} tile_y - Y-coordinate of top-left corner of tile + * @param {number} tile_num - Tile number + * @param {number} current_scale - Current scale of tile + */ + _drawRevealedTileAnimated(tile_x, tile_y, tile_num, current_scale) { + if (this.hide_details || tile_num === 0) return; + const W = ((2 * this.tile_size) / 5) * current_scale; + const H = ((2 * this.tile_size) / 3) * current_scale; + this.ctx.drawImage( + this.img, + (tile_num - 1) * 4, + 0, + 3, + 5, + tile_x + (3 * this.tile_size) / 10 + ((2 * this.tile_size) / 5 - W) / 2, + tile_y + this.tile_size / 6 + ((2 * this.tile_size) / 3 - H) / 2, + W, + H, + ); + } + /** + * @function _drawFlaggedTileAnimated + * @description Draws a flagged tiles animation + * @access private + * @param {number} tile_x - X-coordinate of top-left corner of tile + * @param {number} tile_y - Y-coordinate of top-left corner of tile + * @param {number} frame_time - Time since animation start (0-1) + * @param {number} current_scale - Current scale of tile + */ + _drawFlaggedTileAnimated(tile_x, tile_y, frame_time, current_scale) { + this.ctx.fillStyle = COLORS.TILE_FLAGGED; + this.drawRoundedRect( + tile_x, + tile_y, + this.tile_size - this.border_width, + this.tile_size - this.border_width, + this.tile_size / 6, + ); + if (this.hide_details) return; + const W = ((2 * (this.tile_size - this.border_width)) / 5) * current_scale; + const H = ((2 * (this.tile_size - this.border_width)) / 3) * current_scale; + this.ctx.drawImage( + this.img, + 44, + 0, + 4, + 7, + tile_x + + (3 * this.tile_size) / 10 + + ((2 * (this.tile_size - this.border_width)) / 5 - W) / 2 - + this.border_width / 2, + tile_y + + this.tile_size / 6 + + ((2 * (this.tile_size - this.border_width)) / 3 - H) / 2, + W, + H, + ); + this.drawParticles(tile_x, tile_y, frame_time, COLORS.FLAG_PARTICLE_COLOR); + } + /** + * @function _drawBombedTileAnimated + * @description Draws a bombed tiles animation + * @access private + * @param {number} tile_x - X-coordinate of top-left corner of tile + * @param {number} tile_y - Y-coordinate of top-left corner of tile + * @param {number} frame_time - Time since animation start (0-1) + * @param {number} current_scale - Current scale of tile + */ + _drawBombedTileAnimated(tile_x, tile_y, frame_time, current_scale) { + this.ctx.fillStyle = COLORS.TILE_DEFAULT; + this.drawRoundedRect( + tile_x, + tile_y, + this.tile_size - this.border_width, + this.tile_size - this.border_width, + this.tile_size / 6, + ); + if (this.hide_details) return; + const S = ((this.tile_size - this.border_width) / 2) * current_scale; + this.ctx.drawImage( + this.img, + 32, + 0, + 5, + 5, + tile_x + (this.tile_size - this.border_width) / 2 - S / 2, + tile_y + (this.tile_size - this.border_width) / 2 - S / 2, + S, + S, + ); + this.drawParticles(tile_x, tile_y, frame_time, COLORS.LOST_PARTICLE_COLOR); + } + /** + * @function drawParticles + * @description Draws particles + * @param {number} tile_x - X-coordinate of top-left corner of tile + * @param {number} tile_y - Y-coordinate of top-left corner of tile + * @param {number} frame_time - Time since animation start (0-1) + * @param {string} [color=COLORS.FLAG_PARTICLE_COLOR] - Color of particle + */ + drawParticles( + tile_x, + tile_y, + frame_time, + color = COLORS.FLAG_PARTICLE_COLOR, + ) { + if (frame_time >= 1) return; + for (let i = 0; i < 5; i++) { + const HASH = DataHasher.hash(this.key, `${tile_x}:${tile_y}:${i}`); + const ANGLE = + HASH * Math.PI * 2 + frame_time * Math.PI * 4 * (1 + HASH * 0.7); + const RADIUS = frame_time * this.tile_size * 1.5 * (0.9 + HASH * 0.6); + const X = tile_x + this.tile_size / 2 + Math.cos(ANGLE) * RADIUS; + const Y = tile_y + this.tile_size / 2 + Math.sin(ANGLE) * RADIUS; + this.ctx.globalAlpha = Math.max(0, 1 - Math.pow(frame_time, 1.5)); + this.ctx.fillStyle = color; + this.ctx.beginPath(); + this.ctx.arc( + X, + Y, + (this.tile_size * 0.55 * (0.8 + HASH * 0.4) * (1 - frame_time)) / 2, + 0, + Math.PI * 2, + ); + this.ctx.fill(); + this.ctx.globalAlpha = 1.0; + } + } + /** + * @function drawSectorBorders + * @description Draws sector borders + * @param {number} start_x - X-coordinate of top-left corner of sector + * @param {number} start_y - Y-coordinate of top-left corner of sector + */ + drawSectorBorders(start_x, start_y) { + this.ctx.strokeStyle = COLORS.SECTOR_BORDER; + this.ctx.lineWidth = this.border_width; + for ( + let s_x = start_x - 1; + s_x < Math.ceil(start_x + this.canvas.width / this.sector_pixel_size + 1); + s_x++ + ) { + const SECTOR_X_POS = s_x * this.sector_pixel_size + this.offset[0]; + this.ctx.beginPath(); + this.ctx.moveTo( + SECTOR_X_POS + this.sector_pixel_size - this.border_width / 2, + 0, + ); + this.ctx.lineTo( + SECTOR_X_POS + this.sector_pixel_size - this.border_width / 2, + this.canvas.height, + ); + this.ctx.stroke(); + } + for ( + let s_y = start_y - 1; + s_y < + Math.ceil(start_y + this.canvas.height / this.sector_pixel_size + 1); + s_y++ + ) { + const SECTOR_Y_POS = s_y * this.sector_pixel_size + this.offset[1]; + this.ctx.beginPath(); + this.ctx.moveTo( + 0, + SECTOR_Y_POS + this.sector_pixel_size - this.border_width / 2, + ); + this.ctx.lineTo( + this.canvas.width, + SECTOR_Y_POS + this.sector_pixel_size - this.border_width / 2, + ); + this.ctx.stroke(); + } + } + /** + * @function drawSectorOverlays + * @description Draws sector overlays + * @param {number} s_x - X-coordinate of sector + * @param {number} s_y - Y-coordinate of sector + */ + drawSectorOverlays(s_x, s_y) { + const KEY = `${s_x}:${s_y}`; + const SECTOR_X_POS = s_x * this.sector_pixel_size + this.offset[0]; + const SECTOR_Y_POS = s_y * this.sector_pixel_size + this.offset[1]; + const fill = (spx = this.sector_pixel_size - this.border_width) => { + this.ctx.fillRect(SECTOR_X_POS, SECTOR_Y_POS, spx, spx); + }; + if ( + this.game_pos.data_sectors.hasOwnProperty(KEY) && + this.game_pos.data_sectors[KEY] === SOLVED && + !this.game_pos.animated_sectors.solved.hasOwnProperty(KEY) + ) { + this.ctx.fillStyle = COLORS.SECTOR_OVERLAY; + this.ctx.globalAlpha = 0.1; + fill(); + } else if ( + this.game_pos.data_sectors.hasOwnProperty(KEY) && + !this.game_pos.animated_sectors.lost.hasOwnProperty(KEY) && + this.game_pos.lost_sectors.hasOwnProperty(KEY) + ) { + this.ctx.fillStyle = COLORS.SECTOR_LOST_OVERLAY; + this.ctx.globalAlpha = 0.4; + fill(); + } + this.ctx.globalAlpha = 1.0; + } + /** + * @function drawSolvedAnimations + * @description Draws solved sector animations + * @param {number} s_x - X-coordinate of sector + * @param {number} s_y - Y-coordinate of sector + */ + drawSolvedAnimations(s_x, s_y) { + this.ctx.fillStyle = COLORS.SECTOR_OVERLAY; + const KEY = `${s_x}:${s_y}`; + if (!this.game_pos.animated_sectors.solved.hasOwnProperty(KEY)) return; + const FRAME_TIME = + (Date.now() - this.game_pos.animated_sectors.solved[KEY]) / + (ANIMATION_SPEED_BASE * 12); + const SECTOR_X_POS = s_x * this.sector_pixel_size + this.offset[0]; + const SECTOR_Y_POS = s_y * this.sector_pixel_size + this.offset[1]; + this.ctx.globalAlpha = Math.min(FRAME_TIME, 1) * 0.1; + this.ctx.fillRect( + SECTOR_X_POS, + SECTOR_Y_POS, + this.sector_pixel_size - this.border_width, + this.sector_pixel_size - this.border_width, + ); + if (FRAME_TIME <= 0.25) { + this.drawSolvedParticles( + SECTOR_X_POS, + SECTOR_Y_POS, + Math.min(FRAME_TIME * 4, 1), + ); + } + const CURRENT_ANIMATION_SCALE = 0.5 + 0.7 * FRAME_TIME; + const ANIMATED_WIDTH = this.sector_pixel_size * CURRENT_ANIMATION_SCALE; + const ANIMATED_HEIGHT = + ((this.sector_pixel_size * 16) / 80) * CURRENT_ANIMATION_SCALE; + this.ctx.globalAlpha = Math.max(0, 1 - Math.pow(FRAME_TIME, 1.5)); + this.ctx.drawImage( + this.img, + 0, + 29, + 80, + 16, + SECTOR_X_POS + this.sector_pixel_size / 2 - ANIMATED_WIDTH / 2, + SECTOR_Y_POS + + this.sector_pixel_size / 2 - + ANIMATED_HEIGHT / 2 - + this.sector_pixel_size * 0.3 * FRAME_TIME, + ANIMATED_WIDTH, + ANIMATED_HEIGHT, + ); + if (FRAME_TIME >= 1) { + delete this.game_pos.animated_sectors.solved[KEY]; + } + this.ctx.globalAlpha = 1.0; + } + /** + * @function drawSolvedParticles + * @description Draws solved sector particles + * @param {number} sector_x_pos - X-coordinate of sector + * @param {number} sector_y_pos - Y-coordinate of sector + * @param {number} frame_time - Current frame time (0-1) + */ + drawSolvedParticles(sector_x_pos, sector_y_pos, frame_time) { + if (frame_time >= 1) return; + for (let i = 0; i < 20; i++) { + const HASH = DataHasher.hash( + this.key, + `${sector_x_pos}:${sector_y_pos}:${i}`, + ); + const ANGLE = + HASH * Math.PI * 2 + frame_time * Math.PI * 2 * (0.5 + HASH * 0.5); + const RADIUS = frame_time * this.sector_pixel_size * 0.7 * HASH; + this.ctx.globalAlpha = Math.max(0, 1 - Math.pow(frame_time, 2)); + this.ctx.fillStyle = COLORS.SOLVED_PARTICLE_COLOR; + this.ctx.beginPath(); + this.ctx.arc( + sector_x_pos + this.sector_pixel_size / 2 + Math.cos(ANGLE) * RADIUS, + sector_y_pos + this.sector_pixel_size / 2 + Math.sin(ANGLE) * RADIUS, + (this.sector_pixel_size * 0.2 * (0.6 + HASH * 0.8) * (1 - frame_time)) / + 2, + 0, + Math.PI * 2, + ); + this.ctx.fill(); + this.ctx.globalAlpha = 1.0; + } + } + /** + * @function drawLostAnimations + * @description Draws lost sector animations + * @param {number} s_x - X-coordinate of sector + * @param {number} s_y - Y-coordinate of sector + */ + drawLostAnimations(s_x, s_y) { + this.ctx.fillStyle = COLORS.SECTOR_LOST_OVERLAY; + const KEY = `${s_x}:${s_y}`; + if (!this.game_pos.animated_sectors.lost.hasOwnProperty(KEY)) return; + const FRAME_TIME = + (Date.now() - this.game_pos.animated_sectors.lost[KEY]) / + (ANIMATION_SPEED_BASE * 12); + const SECTOR_X_POS = s_x * this.sector_pixel_size + this.offset[0]; + const SECTOR_Y_POS = s_y * this.sector_pixel_size + this.offset[1]; + this.ctx.globalAlpha = Math.min(FRAME_TIME, 1) * 0.4; + this.ctx.fillRect( + SECTOR_X_POS, + SECTOR_Y_POS, + this.sector_pixel_size - this.border_width, + this.sector_pixel_size - this.border_width, + ); + const MAX_JITTER = 20; + const DAMPEN = Math.max(1 - FRAME_TIME, 0); + const JITTERX = (Math.random() - 0.5) * MAX_JITTER * DAMPEN; + const JITTERY = (Math.random() - 0.5) * MAX_JITTER * DAMPEN; + const CURRENT_ANIMATION_SCALE = 0.5 + 0.7 * FRAME_TIME; + const ANIMATED_WIDTH = this.sector_pixel_size * CURRENT_ANIMATION_SCALE; + const ANIMATED_HEIGHT = + ((this.sector_pixel_size * 16) / 55) * CURRENT_ANIMATION_SCALE; + this.ctx.globalAlpha = Math.max(0, 1 - Math.pow(FRAME_TIME, 0.8)) * 0.9; + this.ctx.drawImage( + this.img, + 0, + 12, + 49, + 16, + SECTOR_X_POS + this.sector_pixel_size / 2 - ANIMATED_WIDTH / 2 + JITTERX, + SECTOR_Y_POS + + this.sector_pixel_size / 2 - + ANIMATED_HEIGHT / 2 - + this.sector_pixel_size * 0.3 * FRAME_TIME + + JITTERY, + ANIMATED_WIDTH, + ANIMATED_HEIGHT, + ); + for (let i = 0; i < 3; i++) { + const ANGLE = (Math.PI * 2 * i) / 3; + const RADIUS = 20 + 10 * Math.random(); + const JITTERX = (Math.random() - 0.5) * 10; + const JITTERY = (Math.random() - 0.5) * 10; + const EMOJISIZE = 14 * (2 + 1 * FRAME_TIME); + this.ctx.drawImage( + this.img, + 50, + 12, + 14, + 14, + SECTOR_X_POS + + this.sector_pixel_size / 2 + + Math.cos(ANGLE) * RADIUS + + JITTERX - + EMOJISIZE / 2, + SECTOR_Y_POS + + (3 * this.sector_pixel_size) / 4 + + Math.sin(ANGLE) * RADIUS + + JITTERY - + EMOJISIZE / 2, + EMOJISIZE, + EMOJISIZE, + ); + } + if (FRAME_TIME >= 1) { + delete this.game_pos.animated_sectors.lost[KEY]; + } + this.ctx.globalAlpha = 1.0; + } +} diff --git a/src/js/index.js b/src/js/index.js new file mode 100644 index 0000000..2e2034f --- /dev/null +++ b/src/js/index.js @@ -0,0 +1,21 @@ +/** + * @author Syed Daanish + * @file `index.js` + * @requires `game_controller.js` + */ + +import GameController from "./game_controller.js"; + +/** + * @event DOMContentLoaded + */ +document.addEventListener("DOMContentLoaded", () => { + /** + * @name game + * @type {GameController} + * @description The game controller instance. + * @access public + */ + window.game = new GameController(); + window.game.init(); +}); diff --git a/src/js/saver.js b/src/js/saver.js new file mode 100644 index 0000000..615d4cf --- /dev/null +++ b/src/js/saver.js @@ -0,0 +1,175 @@ +/** + * @author Syed Daanish + * @file `saver.js` + * @requires `utils.js` + * @requires `event_bus.js` + * @requires `game_controller.js` + */ + +import { DataHasher, DataCompressor } from "./utils.js"; +import EventBus from "./event_bus.js"; +import GameController from "./game_controller.js"; + +/** + * @name Saver + * @access public + * @classdesc Handles saving & loading game data + * @exports Saver + * @default + */ +export default class Saver { + /** + * @constructs Saver + * @function constructor + * @description Initializes the game saver + * @param {GameController} game + * @param {EventBus} bus + */ + constructor(game, bus) { + /** @type {number} - ID of the autosave interval */ + this.save_interval_id = undefined; + this.delete_save = false; + this.game = game; + this.bus = bus; + /** @listens EventBus#is_saved */ + this.bus.onRetrievable("is_saved", this.isSaved.bind(this)); + /** @listens EventBus#save */ + this.bus.on("save", this.save.bind(this)); + /** @listens EventBus#load_save */ + this.bus.on("load_save", this.load.bind(this)); + /** @listens EventBus#delete_save */ + this.bus.on("delete_save", this.deleteSave.bind(this)); + /** @listens EventBus#start_autosaver */ + this.bus.on("start_autosaver", this.startAutosaver.bind(this)); + /** @listens EventBus#stop_autosaver */ + this.bus.on("stop_autosaver", this.stopAutosaver.bind(this)); + } + /** + * @function startAutosaver + * @description Starts the autosave feature + */ + startAutosaver() { + this.save_interval_id = setInterval( + () => { + this.bus.emit("save"); + }, + 2 * 60 * 1000, + ); + } + /** + * @function stopAutosaver + * @description Stops the autosave feature + */ + stopAutosaver() { + clearInterval(this.save_interval_id); + this.save_interval_id = undefined; + } + /** + * @function isSaved + * @description Checks if a save exists + * @returns {boolean} - Whether a save exists or not + */ + isSaved() { + return localStorage.getItem("save") !== null; + } + /** + * @function save + * @description Saves the game data + * @returns {string} - Compressed game data + * @fires EventBus#view_pos + * @fires EventBus#time_and_stats + */ + save() { + if (this.delete_save) return; + const data = { + game_pos: this.game.game_pos, + goldmines: this.game.goldmines, + seed: this.game.seed, + difficulty: this.game.difficulty, + view_pos: this.bus.get("view_pos"), + stats: this.bus.get("time_and_stats"), + }; + const compressed = DataCompressor.zip(JSON.stringify(data)); + localStorage.setItem("save", compressed); + return compressed; + } + /** + * @function load + * @description Loads the game data + * @param {string} [compressed] - Compressed game data + * @fires EventBus#set_stats + * @fires EventBus#set_view_pos + * @fires EventBus#update_key + * @fires EventBus#reset + */ + load(compressed = undefined) { + try { + compressed = compressed || localStorage.getItem("save"); + if (!compressed) throw new Error("No save found"); + const uncompressed = DataCompressor.unzip(compressed); + const savedData = JSON.parse(uncompressed); + Object.assign(this.game, { + goldmines: savedData.goldmines, + seed: savedData.seed, + key: DataHasher.generate_key(savedData.seed), + difficulty: savedData.difficulty, + }); + this.bus.emit("set_stats", savedData.stats); + this.bus.emit("set_view_pos", savedData.view_pos); + this.bus.emit("update_key", this.game.key); + const savedGamePos = savedData.game_pos || {}; + Object.assign(this.game.game_pos, { + animated_tiles: { flagged: {}, revealed: {}, hidden: {}, bombed: {} }, + animated_sectors: { solved: {}, lost: {}, bought: {} }, + data_sectors: savedGamePos.data_sectors || {}, + cached_sectors: savedGamePos.cached_sectors || {}, + lost_sectors: savedGamePos.lost_sectors || {}, + }); + } catch (e) { + this.deleteSave(); + this.bus.emit("reset"); + } + } + /** + * @function saveToFile + * @description Saves the game data to a file + */ + saveToFile() { + const compressed = this.save(); + let url = URL.createObjectURL( + new Blob([compressed], { type: "application/octet-stream" }), + "save.txt", + ); + let a = document.createElement("a"); + a.href = url; + a.download = "save.txt"; + a.click(); + } + /** + * @function deleteSave + * @description Deletes the save + */ + deleteSave() { + localStorage.removeItem("save"); + this.delete_save = true; + this.stopAutosaver(); + } + /** + * @function loadFromFile + * @description Loads the game data from a file + */ + loadFromFile() { + let input = document.createElement("input"); + input.type = "file"; + input.accept = "application/octet-stream"; + input.onchange = () => { + let file = input.files[0]; + let reader = new FileReader(); + reader.onload = () => { + return this.load(reader.result); + }; + reader.readAsText(file); + }; + input.click(); + } +} diff --git a/src/js/third_party_utils.js b/src/js/third_party_utils.js new file mode 100644 index 0000000..f65c0f1 --- /dev/null +++ b/src/js/third_party_utils.js @@ -0,0 +1,175 @@ +/** + * @name SipHash + * @IMPORTANT - Siphash implementation taken from https://github.com/jedisct1/siphash-js (minified browser version) + * @author Frank Denis + * @access package + * @exports SipHash + */ +export const SipHash = (function () { + "use strict"; + function r(r, n) { + var t = r.l + n.l, + h = { h: (r.h + n.h + ((t / 2) >>> 31)) >>> 0, l: t >>> 0 }; + (r.h = h.h), (r.l = h.l); + } + function n(r, n) { + (r.h ^= n.h), (r.h >>>= 0), (r.l ^= n.l), (r.l >>>= 0); + } + function t(r, n) { + var t = { + h: (r.h << n) | (r.l >>> (32 - n)), + l: (r.l << n) | (r.h >>> (32 - n)), + }; + (r.h = t.h), (r.l = t.l); + } + function h(r) { + var n = r.l; + (r.l = r.h), (r.h = n); + } + function e(e, l, o, u) { + r(e, l), + r(o, u), + t(l, 13), + t(u, 16), + n(l, e), + n(u, o), + h(e), + r(o, l), + r(e, u), + t(l, 17), + t(u, 21), + n(l, o), + n(u, e), + h(o); + } + function l(r, n) { + return (r[n + 3] << 24) | (r[n + 2] << 16) | (r[n + 1] << 8) | r[n]; + } + function o(r, t) { + "string" == typeof t && (t = u(t)); + var h = { h: r[1] >>> 0, l: r[0] >>> 0 }, + o = { h: r[3] >>> 0, l: r[2] >>> 0 }, + i = { h: h.h, l: h.l }, + a = h, + f = { h: o.h, l: o.l }, + c = o, + s = t.length, + v = s - 7, + g = new Uint8Array(new ArrayBuffer(8)); + n(i, { h: 1936682341, l: 1886610805 }), + n(f, { h: 1685025377, l: 1852075885 }), + n(a, { h: 1819895653, l: 1852142177 }), + n(c, { h: 1952801890, l: 2037671283 }); + for (var y = 0; y < v; ) { + var d = { h: l(t, y + 4), l: l(t, y) }; + n(c, d), e(i, f, a, c), e(i, f, a, c), n(i, d), (y += 8); + } + g[7] = s; + for (var p = 0; y < s; ) g[p++] = t[y++]; + for (; p < 7; ) g[p++] = 0; + var w = { + h: (g[7] << 24) | (g[6] << 16) | (g[5] << 8) | g[4], + l: (g[3] << 24) | (g[2] << 16) | (g[1] << 8) | g[0], + }; + n(c, w), + e(i, f, a, c), + e(i, f, a, c), + n(i, w), + n(a, { h: 0, l: 255 }), + e(i, f, a, c), + e(i, f, a, c), + e(i, f, a, c), + e(i, f, a, c); + var _ = i; + return n(_, f), n(_, a), n(_, c), _; + } + function u(r) { + if ("function" == typeof TextEncoder) return new TextEncoder().encode(r); + r = unescape(encodeURIComponent(r)); + for (var n = new Uint8Array(r.length), t = 0, h = r.length; t < h; t++) + n[t] = r.charCodeAt(t); + return n; + } + return { + hash: o, + hash_hex: function (r, n) { + var t = o(r, n); + return ( + ("0000000" + t.h.toString(16)).substr(-8) + + ("0000000" + t.l.toString(16)).substr(-8) + ); + }, + hash_uint: function (r, n) { + var t = o(r, n); + return 4294967296 * (2097151 & t.h) + t.l; + }, + string16_to_key: function (r) { + var n = u(r); + if (16 !== n.length) throw Error("Key length must be 16 bytes"); + var t = new Uint32Array(4); + return ( + (t[0] = l(n, 0)), + (t[1] = l(n, 4)), + (t[2] = l(n, 8)), + (t[3] = l(n, 12)), + t + ); + }, + string_to_u8: u, + }; +})(); + +/** + * @name LZW + * @access package + * @exports LZW + */ +export const LZW = { + compress: (uncompressed) => { + const dict = {}; + const data = (uncompressed + "").split(""); + const out = []; + let dictSize = 256; + for (let i = 0; i < 256; i++) { + dict[String.fromCharCode(i)] = i; + } + let w = ""; + for (let i = 0; i < data.length; i++) { + const c = data[i]; + const wc = w + c; + if (dict.hasOwnProperty(wc)) { + w = wc; + } else { + out.push(dict[w]); + dict[wc] = dictSize++; + w = c; + } + } + if (w !== "") out.push(dict[w]); + return out; + }, + uncompress: (compressed) => { + const dict = []; + let dictSize = 256; + for (let i = 0; i < 256; i++) { + dict[i] = String.fromCharCode(i); + } + let w = String.fromCharCode(compressed[0]); + let result = w; + for (let i = 1; i < compressed.length; i++) { + const k = compressed[i]; + let entry; + if (dict[k]) { + entry = dict[k]; + } else if (k === dictSize) { + entry = w + w[0]; + } else { + throw new Error("Bad compressed k: " + k); + } + result += entry; + dict[dictSize++] = w + entry[0]; + w = entry; + } + return result; + }, +}; diff --git a/src/js/ui_renderer.js b/src/js/ui_renderer.js new file mode 100644 index 0000000..2a2d310 --- /dev/null +++ b/src/js/ui_renderer.js @@ -0,0 +1,181 @@ +/** + * @author Syed Daanish + * @file `ui_renderer.js` + * @requires `constants.js` + * @requires `utils.js` + * @requires `game_renderer.js` + * @requires `event_bus.js` + */ + +import { SECTOR_SIZE, DETAIL_THRESHOLD } from "./constants.js"; +import { LOOPS, convert } from "./utils.js"; +import GameRenderer from "./game_renderer.js"; +import EventBus from "./event_bus.js"; + +/** + * @name UIRenderer + * @access public + * @extends GameRenderer + * @classdesc Entry point for game rendering & ui renderer + * @exports UIRenderer + * @default + */ +export default class UIRenderer extends GameRenderer { + /** + * @constructs UIRenderer + * @function constructor + * @description Initializes the renderer + * @param {Image} img + * @param {Object} game_pos + * @param {Uint32Array} key + * @param {EventBus} bus + */ + constructor(img, game_pos, key, bus) { + super(img, game_pos, key, bus); + /** @type {Function} - Bound `loop` function so `requestAnimationFrame` keeps `this` context */ + this.loop = this.loop.bind(this); + /** @type {number} - ID of the animation frame */ + this.frame_id = undefined; + /** @listens EventBus#start */ + this.bus.on("start", this.loop); + /** @listens EventBus#drag */ + this.bus.on("drag", this.drag.bind(this)); + /** @listens EventBus#resize */ + this.bus.on("resize", this.resize.bind(this)); + /** @listens EventBus#update_key */ + this.bus.on("update_key", this.updateKey.bind(this)); + /** @listens EventBus#zoom */ + this.bus.onRetrievable("zoom", this.zoom.bind(this)); + /** @listens EventBus#is_buy_button */ + this.bus.onRetrievable("is_buy_button", this.isBuyButton.bind(this)); + /** @listens EventBus#sector_bounds */ + this.bus.onRetrievable("sector_bounds", this.sectorBounds.bind(this)); + /** @listens EventBus#click_convert */ + this.bus.onRetrievable("click_convert", this.clickConvert.bind(this)); + } + /** + * @function loop + * @description Loops the animation frame and calls `draw` + */ + loop() { + this.drawGame(); + this.drawUI(); + this.frame_id = requestAnimationFrame(this.loop); + } + /** + * @function drawUI + * @description Draws the UI + */ + drawUI() {} + /** + * @function drag + * @description Updates the offset + * @param {number} x - X-offset + * @param {number} y - Y-offset + */ + drag(x, y) { + this.offset[0] += x; + this.offset[1] += y; + } + /** + * @function resize + * @description Resizes the canvas + */ + resize() { + this.canvas.width = window.innerWidth; + this.canvas.height = window.innerHeight; + this.ctx.imageSmoothingEnabled = false; + } + /** + * @function updateKey + * @description Updates the key + * @param {Uint32Array} key + */ + updateKey(key) { + this.key = key; + } + /** + * @function zoom + * @description Zooms in or out + * @param {number} x - X-coordinate of center of zoom + * @param {number} y - Y-coordinate of center of zoom + * @param {boolean} is_zoom_in - True if zooming in, false if zooming out + * @returns {boolean} - True if details are hidden, false otherwise + */ + zoom(x, y, is_zoom_in) { + const NEW_TILE_SIZE = Math.max( + 10, + Math.min(is_zoom_in ? this.tile_size * 1.1 : this.tile_size / 1.1, 100), + ); + this.offset[0] = + x - ((x - this.offset[0]) * NEW_TILE_SIZE) / this.tile_size; + this.offset[1] = + y - ((y - this.offset[1]) * NEW_TILE_SIZE) / this.tile_size; + this.tile_size = NEW_TILE_SIZE; + this.sector_pixel_size = SECTOR_SIZE * this.tile_size; + this.hide_details = this.tile_size < DETAIL_THRESHOLD; + this.border_width = this.hide_details ? 0 : this.tile_size * 0.065; + return this.hide_details; + } + /** + * @function isBuyButton + * @description Checks if the tile is a buy button + * @param {number} x - X-coordinate of the tile + * @param {number} y - Y-coordinate of the tile + * @returns {boolean} True if the tile is a buy button, false otherwise + */ + isBuyButton(x, y) { + const [S_X, S_Y, X, Y] = convert(true, x, y); + const START_X = -Math.floor(this.offset[0] / this.sector_pixel_size); + const START_Y = -Math.ceil(this.offset[1] / this.sector_pixel_size); + let found = false; + LOOPS.overOnScreenSectors( + (s_x, s_y) => { + if ( + this.game_pos.lost_sectors.hasOwnProperty(`${s_x}:${s_y}`) && + s_x === S_X && + s_y === S_Y && + X > 1 && + X < 7 && + Y > 2 && + Y < 6 + ) { + found = true; + } + }, + START_X, + START_Y, + this.sector_pixel_size, + this.canvas, + ); + return found; + } + /** + * @function sectorBounds + * @description Returns the sector bounds + * @returns {Array} - Array of [start_x, start_y, end_x, end_y] + */ + sectorBounds() { + const START_X = -Math.floor(this.offset[0] / this.sector_pixel_size); + const START_Y = -Math.ceil(this.offset[1] / this.sector_pixel_size); + return [ + START_X, + START_Y, + Math.ceil(START_X + this.canvas.width / this.sector_pixel_size + 1), + Math.ceil(START_Y + this.canvas.height / this.sector_pixel_size + 1), + ]; + } + /** + * @function clickConvert + * @description Converts raw click coords to sector adjusted coords + * @param {number} x - X-coordinate of the click + * @param {number} y - Y-coordinate of the click + * @returns {Array} - Array of [x, y] + */ + clickConvert(x, y) { + return [ + Math.floor((x - this.offset[0]) / this.tile_size), + Math.floor((y - this.offset[1]) / this.tile_size), + ]; + } +} diff --git a/src/js/utils.js b/src/js/utils.js new file mode 100644 index 0000000..a92683e --- /dev/null +++ b/src/js/utils.js @@ -0,0 +1,266 @@ +/** + * @author Syed Daanish + * @file `utils.js` + * @requires `third_party_utils.js` + * @requires `constants.js` + */ + +import { SipHash, LZW } from "./third_party_utils.js"; +import { + SECTOR_SIZE, + DIFFICULTY, + CENTRAL_AREA_DIFFICULTY_MODIFIER, +} from "./constants.js"; + +/** + * @name DataHasher + * @type {object} + * @description An object with methods `hash` and `generate_key` + * such that `hash(key, data)` returns a number between 0 and 1 (deterministically), + * and `generate_key(seed)` returns an approprite key for `hash` + * @property {function} generate_key + * @property {function} hash + * @access public + * @exports DataHasher + */ +export const DataHasher = { + /** + * @function generate_key + * @description Generates a key + * @param {string} seed - Seed for the hash function + * @returns {Uint32Array} - Generated key + * It can be the input itself for hashing + * functions that dont require keys. + */ + generate_key: SipHash.string16_to_key, + /** + * @function hash + * @description Hashes data + * @param {Uint32Array} key - Key for the hash function + * For hashing functions that dont require keys + * it can be concatenated to the data + * @param {string} data - Data to hash + * @returns {number} - Psudo-random number between 0 and 1 + */ + hash: (key, data) => + (({ h, l }) => (((h >>> 0) * 0x100000000 + (l >>> 0)) % 101) / 100)( + SipHash.hash(key, data), + ), +}; + +/** + * @name DataCompressor + * @type {object} + * @description An object with methods `zip` and `unzip` + * that accept/return strings and are reversable + * @property {function} zip + * @property {function} unzip + * @access public + * @exports DataCompressor + */ +export const DataCompressor = { + /** + * @function zip + * @description Compresses data + * @param {string} data - Uncompressed data + * @returns {string} - Compressed data + */ + zip: (data) => String.fromCharCode(...LZW.compress(data)), + /** + * @function unzip + * @description Uncompresses data + * @param {string} data - Compressed data + * @returns {string} - Uncompressed data + * @throws {Error} - If the compressed data is invalid + */ + unzip: (data) => + LZW.uncompress(Uint16Array.from(data, (ch) => ch.charCodeAt(0))), +}; + +/** + * @name LOOPS + * @type {object} + * @description An object with common loops used in game. + * @property {function} overAdjacent + * @property {function} overAdjacentSum + * @property {function} anyAdjacent + * @property {function} overOnScreenSectors + * @property {function} overTilesInSector + * @property {function} anyTilesInSector + * @property {function} overTilesInSectorSum + * @access public + * @exports LOOPS + */ +export const LOOPS = { + /** + * @function overAdjacent + * @description Loops over adjacent tiles and calls a function + * @param {function} f - Function to call on each tile + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + */ + overAdjacent: (f, x, y, s_x, s_y) => { + [x, y] = convert(false, x, y, s_x, s_y); + for (let i = -1; i <= 1; i++) { + for (let j = -1; j <= 1; j++) { + if (i == 0 && j == 0) continue; + f(x + i, y + j); + } + } + }, + /** + * @function overAdjacentSum + * @description Loops over adjacent tiles and checks if they satisfy a condition + * @param {function} condition - The condition to check + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + * @returns {number} - Number of adjacent tiles that satisfy the condition + */ + overAdjacentSum: (condition, x, y, s_x, s_y) => { + [x, y] = convert(false, x, y, s_x, s_y); + let sum = 0; + for (let i = -1; i <= 1; i++) { + for (let j = -1; j <= 1; j++) { + if (i == 0 && j == 0) continue; + if (condition(x + i, y + j)) sum++; + } + } + return sum; + }, + /** + * @function anyAdjacent + * @description Loops over adjacent tiles and checks if they satisfy a condition + * @param {function} condition - The condition to check + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + * @returns {boolean} - Whether any adjacent tile satisfies the condition or not + */ + anyAdjacent: (condition, x, y, s_x, s_y) => { + [x, y] = convert(false, x, y, s_x, s_y); + for (let i = -1; i <= 1; i++) { + for (let j = -1; j <= 1; j++) { + if (i == 0 && j == 0) continue; + if (condition(x + i, y + j)) return true; + } + } + return false; + }, + /** + * @function overOnScreenSectors + * @description Loops over all sectors in view + * @param {function} f - Function to call on each sector + * @param {number} start_x - X-coordinate of starting sector + * @param {number} start_y - Y-coordinate of starting sector + * @param {number} sector_size_in_px - Size of sector in pixels + * @param {HTMLCanvasElement} canvas - Canvas element + */ + overOnScreenSectors: (f, start_x, start_y, sector_size_in_px, canvas) => { + let end_x = Math.ceil(start_x + canvas.width / sector_size_in_px); + let end_y = Math.ceil(start_y + canvas.height / sector_size_in_px); + for (let i = start_x - 1; i < end_x + 1; i++) { + for (let j = start_y - 1; j < end_y + 1; j++) { + f(i, j); + } + } + }, + /** + * @function overTilesInSector + * @description Loops over all tiles in a sector + * @param {function} f - Function to call on each tile + */ + overTilesInSector: (f) => { + for (let i = 0; i < SECTOR_SIZE; i++) { + for (let j = 0; j < SECTOR_SIZE; j++) { + f(i, j); + } + } + }, + /** + * @function anyTilesInSector + * @description Checks if any tile in a sector satisfies a condition + * @param {function} condition - The condition to check + * @returns {boolean} - Whether any tile satisfies the condition or not + */ + anyTilesInSector: (condition) => { + for (let i = 0; i < SECTOR_SIZE; i++) { + for (let j = 0; j < SECTOR_SIZE; j++) { + if (condition(i, j)) return true; + } + } + return false; + }, + /** + * @function overTilesInSectorSum + * @description Checks if all tiles in a sector satisfy a condition + * @param {function} condition - The condition to check + * @returns {number} - Number of tiles that satisfy the condition + */ + overTilesInSectorSum: (condition) => { + let sum = 0; + for (let i = 0; i < SECTOR_SIZE; i++) { + for (let j = 0; j < SECTOR_SIZE; j++) { + if (condition(i, j)) sum++; + } + } + return sum; + }, +}; + +/** + * @function convert + * @description Converts between tile and sector coordinates. + * @param {boolean} to_local_coords - Whether to convert to local coordinates or not + * @param {number} x - X-coordinate of tile + * @param {number} y - Y-coordinate of tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + * @returns {Array} - An array containing resultant coords + * @exports convert + */ +export function convert(to_local_coords, x, y, s_x, s_y) { + if (to_local_coords) { + if (s_x != undefined && s_y != undefined) { + return [s_x, s_y, x, y]; + } else { + s_x = Math.floor(x / SECTOR_SIZE); + s_y = Math.floor(y / SECTOR_SIZE); + x = ((x % SECTOR_SIZE) + SECTOR_SIZE) % SECTOR_SIZE; + y = ((y % SECTOR_SIZE) + SECTOR_SIZE) % SECTOR_SIZE; + return [s_x, s_y, x, y]; + } + } else { + if (s_x != undefined && s_y != undefined) { + return [s_x * SECTOR_SIZE + x, s_y * SECTOR_SIZE + y]; + } else { + return [x, y]; + } + } +} + +/** + * @function isMine + * @description Get the type of a tile (mine or not) deterministically. + * @param {string} key - Key to use for hashing + * @param {number} x - X-coordinate of the tile + * @param {number} y - Y-coordinate of the tile + * @param {number} [s_x] - X-coordinate of sector if `x` is relative + * @param {number} [s_y] - Y-coordinate of sector if `y` is relative + * @returns {boolean} - Whether the tile is a mine or not + * @exports isMine + */ +export function isMine(key, x, y, s_x, s_y) { + [s_x, s_y, x, y] = convert(true, x, y, s_x, s_y); + if (s_x == 0 && s_y == 0 && x == 4 && y == 4) return false; + return ( + DataHasher.hash(key, `${s_x}:${s_y}:${x}:${y}`) * 100 < + ([s_x, s_y].every((c) => c >= -1 && c <= 1) + ? DIFFICULTY * CENTRAL_AREA_DIFFICULTY_MODIFIER + : DIFFICULTY) + ); +}