Initialize Repo

This commit is contained in:
2025-06-19 17:15:24 +03:00
commit 9d387eb482
17 changed files with 4126 additions and 0 deletions

524
src/js/game_logic.js Normal file
View File

@@ -0,0 +1,524 @@
/**
* @author Syed Daanish <me@syedm.dev>
* @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<boolean>} - 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<Array<Array<number>>>} - 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<Array<Array<number>>>|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;
}
}
}