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