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

1342
API.md Normal file

File diff suppressed because it is too large Load Diff

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<title>Infinsweeper</title>
<link rel="icon" type="image/png" href="src/assets/img/logo_sm.png" />
<meta
name="viewport"
content="initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/>
<link rel="stylesheet" href="src/assets/style.css" />
</head>
<body>
<canvas id="main-canvas"></canvas>
<script type="module" src="src/js/index.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src/assets/img/logo_sm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

4
src/assets/style.css Normal file
View File

@@ -0,0 +1,4 @@
body {
margin: 0;
overflow: hidden;
}

124
src/js/constants.js Normal file
View File

@@ -0,0 +1,124 @@
/**
* @author Syed Daanish <me@syedm.dev>
* @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";

75
src/js/event_bus.js Normal file
View File

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

142
src/js/event_handler.js Normal file
View File

@@ -0,0 +1,142 @@
/**
* @author Syed Daanish <me@syedm.dev>
* @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");
}
});
}
}

185
src/js/game_controller.js Normal file
View File

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

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;
}
}
}

896
src/js/game_renderer.js Normal file
View File

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

21
src/js/index.js Normal file
View File

@@ -0,0 +1,21 @@
/**
* @author Syed Daanish <me@syedm.dev>
* @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();
});

175
src/js/saver.js Normal file
View File

@@ -0,0 +1,175 @@
/**
* @author Syed Daanish <me@syedm.dev>
* @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();
}
}

175
src/js/third_party_utils.js Normal file
View File

@@ -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;
},
};

181
src/js/ui_renderer.js Normal file
View File

@@ -0,0 +1,181 @@
/**
* @author Syed Daanish <me@syedm.dev>
* @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),
];
}
}

266
src/js/utils.js Normal file
View File

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