Initialize Repo
This commit is contained in:
16
index.html
Normal file
16
index.html
Normal 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>
|
BIN
src/assets/img/logo_large.png
Normal file
BIN
src/assets/img/logo_large.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/img/logo_sm.png
Normal file
BIN
src/assets/img/logo_sm.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 353 B |
BIN
src/assets/img/minesweeper.png
Normal file
BIN
src/assets/img/minesweeper.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.7 KiB |
4
src/assets/style.css
Normal file
4
src/assets/style.css
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
124
src/js/constants.js
Normal file
124
src/js/constants.js
Normal 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
75
src/js/event_bus.js
Normal 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
142
src/js/event_handler.js
Normal 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
185
src/js/game_controller.js
Normal 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
524
src/js/game_logic.js
Normal 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
896
src/js/game_renderer.js
Normal 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
21
src/js/index.js
Normal 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
175
src/js/saver.js
Normal 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
175
src/js/third_party_utils.js
Normal 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
181
src/js/ui_renderer.js
Normal 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
266
src/js/utils.js
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user