mirror of
https://github.com/tobspr/shapez.io.git
synced 2026-03-02 03:39:21 +00:00
Initial commit
This commit is contained in:
71
src/js/core/animation_frame.js
Normal file
71
src/js/core/animation_frame.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Signal } from "./signal";
|
||||
|
||||
// @ts-ignore
|
||||
import BackgroundAnimationFrameEmitterWorker from "../webworkers/background_animation_frame_emittter.worker";
|
||||
|
||||
import { createLogger } from "./logging";
|
||||
import { performanceNow } from "./builtins";
|
||||
|
||||
const logger = createLogger("animation_frame");
|
||||
|
||||
const maxDtMs = 1000;
|
||||
const resetDtMs = 16;
|
||||
|
||||
export class AnimationFrame {
|
||||
constructor() {
|
||||
this.frameEmitted = new Signal();
|
||||
this.bgFrameEmitted = new Signal();
|
||||
|
||||
this.lastTime = null;
|
||||
this.bgLastTime = null;
|
||||
|
||||
this.boundMethod = this.handleAnimationFrame.bind(this);
|
||||
|
||||
/** @type {Worker} */
|
||||
this.backgroundWorker = new BackgroundAnimationFrameEmitterWorker();
|
||||
this.backgroundWorker.addEventListener("error", err => {
|
||||
logger.error("Error in background fps worker:", err);
|
||||
});
|
||||
this.backgroundWorker.addEventListener("message", this.handleBackgroundTick.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MessageEvent} event
|
||||
*/
|
||||
handleBackgroundTick(event) {
|
||||
const time = performanceNow();
|
||||
if (!this.bgLastTime) {
|
||||
// First update, first delta is always 16ms
|
||||
this.bgFrameEmitted.dispatch(1000 / 60);
|
||||
} else {
|
||||
let dt = time - this.bgLastTime;
|
||||
if (dt > maxDtMs) {
|
||||
dt = resetDtMs;
|
||||
}
|
||||
this.bgFrameEmitted.dispatch(dt);
|
||||
}
|
||||
this.bgLastTime = time;
|
||||
}
|
||||
|
||||
start() {
|
||||
assertAlways(window.requestAnimationFrame, "requestAnimationFrame is not supported!");
|
||||
this.handleAnimationFrame();
|
||||
}
|
||||
|
||||
handleAnimationFrame(time) {
|
||||
if (!this.lastTime) {
|
||||
// First update, first delta is always 16ms
|
||||
this.frameEmitted.dispatch(1000 / 60);
|
||||
} else {
|
||||
let dt = time - this.lastTime;
|
||||
if (dt > maxDtMs) {
|
||||
// warn(this, "Clamping", dt, "to", resetDtMs);
|
||||
dt = resetDtMs;
|
||||
}
|
||||
this.frameEmitted.dispatch(dt);
|
||||
}
|
||||
this.lastTime = time;
|
||||
window.requestAnimationFrame(this.boundMethod);
|
||||
}
|
||||
}
|
||||
26
src/js/core/assert.js
Normal file
26
src/js/core/assert.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createLogger } from "./logging";
|
||||
|
||||
const logger = createLogger("assert");
|
||||
|
||||
let assertionErrorShown = false;
|
||||
|
||||
function initAssert() {
|
||||
/**
|
||||
* Expects a given condition to be true
|
||||
* @param {Boolean} condition
|
||||
* @param {...String} failureMessage
|
||||
*/
|
||||
// @ts-ignore
|
||||
window.assert = function (condition, ...failureMessage) {
|
||||
if (!condition) {
|
||||
logger.error("assertion failed:", ...failureMessage);
|
||||
if (!assertionErrorShown) {
|
||||
// alert("Assertion failed (the game will try to continue to run): \n\n" + failureMessage);
|
||||
assertionErrorShown = true;
|
||||
}
|
||||
throw new Error("AssertionError: " + failureMessage.join(" "));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
initAssert();
|
||||
143
src/js/core/async_compression.js
Normal file
143
src/js/core/async_compression.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// @ts-ignore
|
||||
import CompressionWorker from "../webworkers/compression.worker";
|
||||
import { createLogger } from "./logging";
|
||||
import { compressX64 } from "./lzstring";
|
||||
import { performanceNow, JSON_stringify } from "./builtins";
|
||||
|
||||
const logger = createLogger("async_compression");
|
||||
|
||||
export let compressionPrefix = String.fromCodePoint(1);
|
||||
|
||||
function checkCryptPrefix(prefix) {
|
||||
try {
|
||||
window.localStorage.setItem("prefix_test", prefix);
|
||||
window.localStorage.removeItem("prefix_test");
|
||||
return true;
|
||||
} catch (ex) {
|
||||
logger.warn("Prefix '" + prefix + "' not available");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkCryptPrefix(compressionPrefix)) {
|
||||
logger.warn("Switching to basic prefix");
|
||||
compressionPrefix = " ";
|
||||
if (!checkCryptPrefix(compressionPrefix)) {
|
||||
logger.warn("Prefix not available, ls seems to be unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* errorHandler: function(any) : void,
|
||||
* resolver: function(any) : void,
|
||||
* startTime: number
|
||||
* }} JobEntry
|
||||
*/
|
||||
|
||||
class AsynCompression {
|
||||
constructor() {
|
||||
/** @type {Worker} */
|
||||
this.worker = new CompressionWorker();
|
||||
|
||||
this.currentJobId = 1000;
|
||||
|
||||
/** @type {Object.<number, JobEntry>} */
|
||||
this.currentJobs = {};
|
||||
|
||||
this.worker.addEventListener("message", event => {
|
||||
const { jobId, result } = event.data;
|
||||
const jobData = this.currentJobs[jobId];
|
||||
if (!jobData) {
|
||||
logger.error("Failed to resolve job result, job id", jobId, "is not known");
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = performanceNow() - jobData.startTime;
|
||||
// log(this, "Got response from worker within", duration.toFixed(2), "ms");
|
||||
const resolver = jobData.resolver;
|
||||
delete this.currentJobs[jobId];
|
||||
resolver(result);
|
||||
});
|
||||
|
||||
this.worker.addEventListener("error", err => {
|
||||
logger.error("Got error from webworker:", err, "aborting all jobs");
|
||||
const failureCalls = [];
|
||||
for (const jobId in this.currentJobs) {
|
||||
failureCalls.push(this.currentJobs[jobId].errorHandler);
|
||||
}
|
||||
this.currentJobs = {};
|
||||
for (let i = 0; i < failureCalls.length; ++i) {
|
||||
failureCalls[i](err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses file
|
||||
* @param {string} text
|
||||
*/
|
||||
compressFileAsync(text) {
|
||||
return this.internalQueueJob("compressFile", {
|
||||
text,
|
||||
compressionPrefix,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses regulary
|
||||
* @param {string} text
|
||||
*/
|
||||
compressX64Async(text) {
|
||||
if (text.length < 1024) {
|
||||
// Ok so this is not worth it
|
||||
return Promise.resolve(compressX64(text));
|
||||
}
|
||||
return this.internalQueueJob("compressX64", text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses with checksum
|
||||
* @param {any} obj
|
||||
*/
|
||||
compressWithChecksum(obj) {
|
||||
const stringified = JSON_stringify(obj);
|
||||
return this.internalQueueJob("compressWithChecksum", stringified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses with checksum
|
||||
* @param {any} data The packets data
|
||||
* @param {number} packetId The numeric packet id
|
||||
*/
|
||||
compressPacket(data, packetId) {
|
||||
return this.internalQueueJob("compressPacket", {
|
||||
data,
|
||||
packetId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a new job
|
||||
* @param {string} job
|
||||
* @param {any} data
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
internalQueueJob(job, data) {
|
||||
const jobId = ++this.currentJobId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const errorHandler = err => {
|
||||
logger.error("Failed to compress job", jobId, ":", err);
|
||||
reject(err);
|
||||
};
|
||||
this.currentJobs[jobId] = {
|
||||
errorHandler,
|
||||
resolver: resolve,
|
||||
startTime: performanceNow(),
|
||||
};
|
||||
this.worker.postMessage({ jobId, job, data });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const asyncCompressor = new AsynCompression();
|
||||
38
src/js/core/atlas_definitions.js
Normal file
38
src/js/core/atlas_definitions.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @typedef {{
|
||||
* frame: { x: number, y: number, w: number, h: number },
|
||||
* rotated: false,
|
||||
* spriteSourceSize: { x: number, y: number, w: number, h: number },
|
||||
* sourceSize: { w: number, h: number},
|
||||
* trimmed: true
|
||||
* }} SpriteDefinition
|
||||
*/
|
||||
|
||||
export class AtlasDefinition {
|
||||
constructor(sourceData) {
|
||||
this.sourceFileName = sourceData.meta.image;
|
||||
this.meta = sourceData.meta;
|
||||
|
||||
/** @type {Object.<string, SpriteDefinition>} */
|
||||
this.sourceData = sourceData.frames;
|
||||
}
|
||||
|
||||
getFullSourcePath() {
|
||||
return this.sourceFileName;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export const atlasFiles = require
|
||||
.context("../../../res_built/atlas/", false, /.*\.json/i)
|
||||
.keys()
|
||||
.map(f => f.replace(/^\.\//gi, ""))
|
||||
.map(f => require("../../../res_built/atlas/" + f))
|
||||
.map(data => new AtlasDefinition(data));
|
||||
|
||||
// export const atlasDefinitions = {
|
||||
// qualityPreload: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_preload") >= 0),
|
||||
// qualityLow: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_low") >= 0),
|
||||
// qualityMedium: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_medium") >= 0),
|
||||
// qualityHigh: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_high") >= 0),
|
||||
// };
|
||||
216
src/js/core/background_resources_loader.js
Normal file
216
src/js/core/background_resources_loader.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { Loader } from "./loader";
|
||||
import { createLogger } from "./logging";
|
||||
import { Signal } from "./signal";
|
||||
import { SOUNDS, MUSIC } from "../platform/sound";
|
||||
import { AtlasDefinition, atlasFiles } from "./atlas_definitions";
|
||||
|
||||
const logger = createLogger("background_loader");
|
||||
|
||||
const essentialMainMenuSprites = ["logo.png", ...G_ALL_UI_IMAGES.filter(src => src.startsWith("ui/"))];
|
||||
const essentialMainMenuSounds = [
|
||||
SOUNDS.uiClick,
|
||||
SOUNDS.uiError,
|
||||
SOUNDS.dialogError,
|
||||
SOUNDS.dialogOk,
|
||||
SOUNDS.swishShow,
|
||||
SOUNDS.swishHide,
|
||||
];
|
||||
|
||||
const essentialBareGameAtlases = atlasFiles;
|
||||
const essentialBareGameSprites = G_ALL_UI_IMAGES;
|
||||
const essentialBareGameSounds = [MUSIC.gameBg];
|
||||
|
||||
const additionalGameSprites = [];
|
||||
const additionalGameSounds = [];
|
||||
for (const key in SOUNDS) {
|
||||
additionalGameSounds.push(SOUNDS[key]);
|
||||
}
|
||||
for (const key in MUSIC) {
|
||||
additionalGameSounds.push(MUSIC[key]);
|
||||
}
|
||||
|
||||
export class BackgroundResourcesLoader {
|
||||
/**
|
||||
*
|
||||
* @param {Application} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
this.registerReady = false;
|
||||
this.mainMenuReady = false;
|
||||
this.bareGameReady = false;
|
||||
this.additionalReady = false;
|
||||
|
||||
this.signalMainMenuLoaded = new Signal();
|
||||
this.signalBareGameLoaded = new Signal();
|
||||
this.signalAdditionalLoaded = new Signal();
|
||||
|
||||
this.numAssetsLoaded = 0;
|
||||
this.numAssetsToLoadTotal = 0;
|
||||
|
||||
// Avoid loading stuff twice
|
||||
this.spritesLoaded = [];
|
||||
this.soundsLoaded = [];
|
||||
}
|
||||
|
||||
getNumAssetsLoaded() {
|
||||
return this.numAssetsLoaded;
|
||||
}
|
||||
|
||||
getNumAssetsTotal() {
|
||||
return this.numAssetsToLoadTotal;
|
||||
}
|
||||
|
||||
getPromiseForMainMenu() {
|
||||
if (this.mainMenuReady) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
this.signalMainMenuLoaded.add(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
getPromiseForBareGame() {
|
||||
if (this.bareGameReady) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
this.signalBareGameLoaded.add(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
startLoading() {
|
||||
this.internalStartLoadingEssentialsForMainMenu();
|
||||
}
|
||||
|
||||
internalStartLoadingEssentialsForMainMenu() {
|
||||
logger.log("⏰ Start load: main menu");
|
||||
this.internalLoadSpritesAndSounds(essentialMainMenuSprites, essentialMainMenuSounds)
|
||||
.catch(err => {
|
||||
logger.warn("⏰ Failed to load essentials for main menu:", err);
|
||||
})
|
||||
.then(() => {
|
||||
logger.log("⏰ Finish load: main menu");
|
||||
this.mainMenuReady = true;
|
||||
this.signalMainMenuLoaded.dispatch();
|
||||
this.internalStartLoadingEssentialsForBareGame();
|
||||
});
|
||||
}
|
||||
|
||||
internalStartLoadingEssentialsForBareGame() {
|
||||
logger.log("⏰ Start load: bare game");
|
||||
this.internalLoadSpritesAndSounds(
|
||||
essentialBareGameSprites,
|
||||
essentialBareGameSounds,
|
||||
essentialBareGameAtlases
|
||||
)
|
||||
.catch(err => {
|
||||
logger.warn("⏰ Failed to load essentials for bare game:", err);
|
||||
})
|
||||
.then(() => {
|
||||
logger.log("⏰ Finish load: bare game");
|
||||
Loader.createAtlasLinks();
|
||||
this.bareGameReady = true;
|
||||
this.signalBareGameLoaded.dispatch();
|
||||
this.internalStartLoadingAdditionalGameAssets();
|
||||
});
|
||||
}
|
||||
|
||||
internalStartLoadingAdditionalGameAssets() {
|
||||
const additionalAtlases = [];
|
||||
logger.log("⏰ Start load: additional assets (", additionalAtlases.length, "images)");
|
||||
this.internalLoadSpritesAndSounds(additionalGameSprites, additionalGameSounds, additionalAtlases)
|
||||
.catch(err => {
|
||||
logger.warn("⏰ Failed to load additional assets:", err);
|
||||
})
|
||||
.then(() => {
|
||||
logger.log("⏰ Finish load: additional assets");
|
||||
this.additionalReady = true;
|
||||
this.signalAdditionalLoaded.dispatch();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<string>} sprites
|
||||
* @param {Array<string>} sounds
|
||||
* @param {Array<AtlasDefinition>} atlases
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
internalLoadSpritesAndSounds(sprites, sounds, atlases = []) {
|
||||
this.numAssetsToLoadTotal = sprites.length + sounds.length + atlases.length;
|
||||
this.numAssetsLoaded = 0;
|
||||
|
||||
let promises = [];
|
||||
|
||||
for (let i = 0; i < sounds.length; ++i) {
|
||||
if (this.soundsLoaded.indexOf(sounds[i]) >= 0) {
|
||||
// Already loaded
|
||||
continue;
|
||||
}
|
||||
|
||||
this.soundsLoaded.push(sounds[i]);
|
||||
promises.push(
|
||||
this.app.sound
|
||||
.loadSound(sounds[i])
|
||||
.catch(err => {
|
||||
logger.warn("Failed to load sound:", sounds[i]);
|
||||
})
|
||||
.then(() => {
|
||||
this.numAssetsLoaded++;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < sprites.length; ++i) {
|
||||
if (this.spritesLoaded.indexOf(sprites[i]) >= 0) {
|
||||
// Already loaded
|
||||
continue;
|
||||
}
|
||||
this.spritesLoaded.push(sprites[i]);
|
||||
promises.push(
|
||||
Loader.preloadCSSSprite(sprites[i])
|
||||
.catch(err => {
|
||||
logger.warn("Failed to load css sprite:", sprites[i]);
|
||||
})
|
||||
.then(() => {
|
||||
this.numAssetsLoaded++;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < atlases.length; ++i) {
|
||||
const atlas = atlases[i];
|
||||
promises.push(
|
||||
Loader.preloadAtlas(atlas)
|
||||
.catch(err => {
|
||||
logger.warn("Failed to load atlas:", atlas.sourceFileName);
|
||||
})
|
||||
.then(() => {
|
||||
this.numAssetsLoaded++;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
Promise.all(promises)
|
||||
|
||||
// // Remove some pressure by waiting a bit
|
||||
// .then(() => {
|
||||
// return new Promise(resolve => {
|
||||
// setTimeout(resolve, 200);
|
||||
// });
|
||||
// })
|
||||
.then(() => {
|
||||
this.numAssetsToLoadTotal = 0;
|
||||
this.numAssetsLoaded = 0;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
146
src/js/core/buffer_maintainer.js
Normal file
146
src/js/core/buffer_maintainer.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import { GameRoot } from "../game/root";
|
||||
import {
|
||||
makeOffscreenBuffer,
|
||||
freeCanvas,
|
||||
getBufferVramUsageBytes,
|
||||
getBufferStats,
|
||||
clearBufferBacklog,
|
||||
} from "./buffer_utils";
|
||||
import { createLogger } from "./logging";
|
||||
import { round2Digits, round1Digit } from "./utils";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* canvas: HTMLCanvasElement,
|
||||
* context: CanvasRenderingContext2D,
|
||||
* lastUse: number,
|
||||
* }} CacheEntry
|
||||
*/
|
||||
|
||||
const logger = createLogger("buffers");
|
||||
|
||||
const bufferGcDurationSeconds = 3;
|
||||
|
||||
export class BufferMaintainer {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
/** @type {Map<string, Map<string, CacheEntry>>} */
|
||||
this.cache = new Map();
|
||||
|
||||
this.iterationIndex = 1;
|
||||
this.lastIteration = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes to the next buffer iteration, clearing all buffers which were not used
|
||||
* for a few iterations
|
||||
*/
|
||||
garbargeCollect() {
|
||||
let totalKeys = 0;
|
||||
let deletedKeys = 0;
|
||||
const minIteration = this.iterationIndex;
|
||||
|
||||
this.cache.forEach((subCache, key) => {
|
||||
let unusedSubKeys = [];
|
||||
|
||||
// Filter sub cache
|
||||
subCache.forEach((cacheEntry, subKey) => {
|
||||
if (cacheEntry.lastUse < minIteration) {
|
||||
unusedSubKeys.push(subKey);
|
||||
freeCanvas(cacheEntry.canvas);
|
||||
++deletedKeys;
|
||||
} else {
|
||||
++totalKeys;
|
||||
}
|
||||
});
|
||||
|
||||
// Delete unused sub keys
|
||||
for (let i = 0; i < unusedSubKeys.length; ++i) {
|
||||
subCache.delete(unusedSubKeys[i]);
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure our backlog never gets too big
|
||||
clearBufferBacklog();
|
||||
|
||||
const bufferStats = getBufferStats();
|
||||
const mbUsed = round1Digit(bufferStats.vramUsage / (1024 * 1024));
|
||||
logger.log(
|
||||
"GC: Remove",
|
||||
(deletedKeys + "").padStart(4),
|
||||
", Remain",
|
||||
(totalKeys + "").padStart(4),
|
||||
"(",
|
||||
(bufferStats.bufferCount + "").padStart(4),
|
||||
"total",
|
||||
")",
|
||||
|
||||
"(",
|
||||
(bufferStats.backlog + "").padStart(4),
|
||||
"backlog",
|
||||
")",
|
||||
|
||||
"VRAM:",
|
||||
mbUsed,
|
||||
"MB"
|
||||
);
|
||||
|
||||
++this.iterationIndex;
|
||||
}
|
||||
|
||||
update() {
|
||||
const now = this.root.time.realtimeNow();
|
||||
if (now - this.lastIteration > bufferGcDurationSeconds) {
|
||||
this.lastIteration = now;
|
||||
this.garbargeCollect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {string} subKey
|
||||
* @param {function(HTMLCanvasElement, CanvasRenderingContext2D, number, number, number, object?) : void} redrawMethod
|
||||
* @param {object=} additionalParams
|
||||
* @returns {HTMLCanvasElement}
|
||||
*
|
||||
*/
|
||||
getForKey(key, subKey, w, h, dpi, redrawMethod, additionalParams) {
|
||||
// First, create parent key
|
||||
let parent = this.cache.get(key);
|
||||
if (!parent) {
|
||||
parent = new Map();
|
||||
this.cache.set(key, parent);
|
||||
}
|
||||
|
||||
// Now search for sub key
|
||||
const cacheHit = parent.get(subKey);
|
||||
if (cacheHit) {
|
||||
cacheHit.lastUse = this.iterationIndex;
|
||||
return cacheHit.canvas;
|
||||
}
|
||||
|
||||
// Need to generate new buffer
|
||||
const effectiveWidth = w * dpi;
|
||||
const effectiveHeight = h * dpi;
|
||||
|
||||
const [canvas, context] = makeOffscreenBuffer(effectiveWidth, effectiveHeight, {
|
||||
reusable: true,
|
||||
label: "buffer-" + key + "/" + subKey,
|
||||
smooth: true,
|
||||
});
|
||||
|
||||
redrawMethod(canvas, context, w, h, dpi, additionalParams);
|
||||
|
||||
parent.set(subKey, {
|
||||
canvas,
|
||||
context,
|
||||
lastUse: this.iterationIndex,
|
||||
});
|
||||
return canvas;
|
||||
}
|
||||
}
|
||||
201
src/js/core/buffer_utils.js
Normal file
201
src/js/core/buffer_utils.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import { globalConfig } from "./config";
|
||||
import { Math_max, Math_floor, Math_abs } from "./builtins";
|
||||
import { fastArrayDelete } from "./utils";
|
||||
import { createLogger } from "./logging";
|
||||
|
||||
const logger = createLogger("buffer_utils");
|
||||
|
||||
/**
|
||||
* Enables images smoothing on a context
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
export function enableImageSmoothing(context) {
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.webkitImageSmoothingEnabled = true;
|
||||
|
||||
// @ts-ignore
|
||||
context.imageSmoothingQuality = globalConfig.smoothing.quality;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables image smoothing on a context
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
export function disableImageSmoothing(context) {
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.webkitImageSmoothingEnabled = false;
|
||||
}
|
||||
|
||||
const registeredCanvas = [];
|
||||
const freeCanvasList = [];
|
||||
|
||||
let vramUsage = 0;
|
||||
let bufferCount = 0;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
*/
|
||||
export function getBufferVramUsageBytes(canvas) {
|
||||
return canvas.width * canvas.height * 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stats on the allocated buffers
|
||||
*/
|
||||
export function getBufferStats() {
|
||||
return {
|
||||
vramUsage,
|
||||
bufferCount,
|
||||
backlog: freeCanvasList.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearBufferBacklog() {
|
||||
while (freeCanvasList.length > 50) {
|
||||
freeCanvasList.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new offscreen buffer
|
||||
* @param {Number} w
|
||||
* @param {Number} h
|
||||
* @returns {[HTMLCanvasElement, CanvasRenderingContext2D]}
|
||||
*/
|
||||
export function makeOffscreenBuffer(w, h, { smooth = true, reusable = true, label = "buffer" }) {
|
||||
assert(w > 0 && h > 0, "W or H < 0");
|
||||
if (w % 1 !== 0 || h % 1 !== 0) {
|
||||
// console.warn("Subpixel offscreen buffer size:", w, h);
|
||||
}
|
||||
if (w < 1 || h < 1) {
|
||||
logger.error("Offscreen buffer size < 0:", w, "x", h);
|
||||
w = Math_max(1, w);
|
||||
h = Math_max(1, h);
|
||||
}
|
||||
|
||||
const recommendedSize = 1024 * 1024;
|
||||
if (w * h > recommendedSize) {
|
||||
logger.warn("Creating huge buffer:", w, "x", h, "with label", label);
|
||||
}
|
||||
|
||||
w = Math_floor(w);
|
||||
h = Math_floor(h);
|
||||
|
||||
let canvas = null;
|
||||
let context = null;
|
||||
|
||||
let bestMatchingOne = null;
|
||||
let bestMatchingPixelsDiff = 1e50;
|
||||
|
||||
const currentPixels = w * h;
|
||||
|
||||
// Ok, search in cache first
|
||||
for (let i = 0; i < freeCanvasList.length; ++i) {
|
||||
const { canvas: useableCanvas, context: useableContext } = freeCanvasList[i];
|
||||
if (useableCanvas.width === w && useableCanvas.height === h) {
|
||||
// Ok we found one
|
||||
canvas = useableCanvas;
|
||||
context = useableContext;
|
||||
|
||||
fastArrayDelete(freeCanvasList, i);
|
||||
break;
|
||||
}
|
||||
|
||||
const otherPixels = useableCanvas.width * useableCanvas.height;
|
||||
const diff = Math_abs(otherPixels - currentPixels);
|
||||
if (diff < bestMatchingPixelsDiff) {
|
||||
bestMatchingPixelsDiff = diff;
|
||||
bestMatchingOne = {
|
||||
canvas: useableCanvas,
|
||||
context: useableContext,
|
||||
index: i,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Ok none matching, reuse one though
|
||||
if (!canvas && bestMatchingOne) {
|
||||
canvas = bestMatchingOne.canvas;
|
||||
context = bestMatchingOne.context;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
fastArrayDelete(freeCanvasList, bestMatchingOne.index);
|
||||
}
|
||||
|
||||
// Reset context
|
||||
if (context) {
|
||||
// Restore past state
|
||||
context.restore();
|
||||
context.save();
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
delete canvas.style.width;
|
||||
delete canvas.style.height;
|
||||
}
|
||||
|
||||
// None found , create new one
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
context = canvas.getContext("2d" /*, { alpha } */);
|
||||
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
|
||||
// Initial state
|
||||
context.save();
|
||||
}
|
||||
|
||||
canvas.label = label;
|
||||
|
||||
if (smooth) {
|
||||
enableImageSmoothing(context);
|
||||
} else {
|
||||
disableImageSmoothing(context);
|
||||
}
|
||||
|
||||
if (reusable) {
|
||||
registerCanvas(canvas, context);
|
||||
}
|
||||
|
||||
return [canvas, context];
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees a canvas
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
*/
|
||||
export function registerCanvas(canvas, context) {
|
||||
registeredCanvas.push({ canvas, context });
|
||||
|
||||
bufferCount += 1;
|
||||
vramUsage += getBufferVramUsageBytes(canvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees a canvas
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
*/
|
||||
export function freeCanvas(canvas) {
|
||||
assert(canvas, "Canvas is empty");
|
||||
|
||||
let index = -1;
|
||||
let data = null;
|
||||
for (let i = 0; i < registeredCanvas.length; ++i) {
|
||||
if (registeredCanvas[i].canvas === canvas) {
|
||||
index = i;
|
||||
data = registeredCanvas[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index < 0) {
|
||||
logger.error("Tried to free unregistered canvas of size", canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
fastArrayDelete(registeredCanvas, index);
|
||||
freeCanvasList.push(data);
|
||||
|
||||
bufferCount -= 1;
|
||||
vramUsage -= getBufferVramUsageBytes(canvas);
|
||||
}
|
||||
34
src/js/core/builtins.js
Normal file
34
src/js/core/builtins.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// Store the original version of all builtins to prevent modification
|
||||
|
||||
export const JSON_stringify = JSON.stringify.bind(JSON);
|
||||
export const JSON_parse = JSON.parse.bind(JSON);
|
||||
|
||||
export function Math_radians(degrees) {
|
||||
return (degrees * Math_PI) / 180.0;
|
||||
}
|
||||
|
||||
export function Math_degrees(radians) {
|
||||
return (radians * 180.0) / Math_PI;
|
||||
}
|
||||
|
||||
export const performanceNow = performance.now.bind(performance);
|
||||
|
||||
export const Math_abs = Math.abs.bind(Math);
|
||||
export const Math_ceil = Math.ceil.bind(Math);
|
||||
export const Math_floor = Math.floor.bind(Math);
|
||||
export const Math_round = Math.round.bind(Math);
|
||||
export const Math_sign = Math.sign.bind(Math);
|
||||
export const Math_sqrt = Math.sqrt.bind(Math);
|
||||
export const Math_min = Math.min.bind(Math);
|
||||
export const Math_max = Math.max.bind(Math);
|
||||
export const Math_sin = Math.sin.bind(Math);
|
||||
export const Math_cos = Math.cos.bind(Math);
|
||||
export const Math_tan = Math.tan.bind(Math);
|
||||
export const Math_hypot = Math.hypot.bind(Math);
|
||||
export const Math_atan2 = Math.atan2.bind(Math);
|
||||
export const Math_pow = Math.pow.bind(Math);
|
||||
export const Math_random = Math.random.bind(Math);
|
||||
export const Math_exp = Math.exp.bind(Math);
|
||||
export const Math_log10 = Math.log10.bind(Math);
|
||||
|
||||
export const Math_PI = 3.1415926;
|
||||
10
src/js/core/cachebust.js
Normal file
10
src/js/core/cachebust.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generates a cachebuster string. This only modifies the path in the browser version
|
||||
* @param {string} path
|
||||
*/
|
||||
export function cachebust(path) {
|
||||
if (G_IS_BROWSER && !G_IS_STANDALONE && !G_IS_DEV) {
|
||||
return "/v/" + G_BUILD_COMMIT_HASH + "/" + path;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
431
src/js/core/click_detector.js
Normal file
431
src/js/core/click_detector.js
Normal file
@@ -0,0 +1,431 @@
|
||||
import { performanceNow } from "../core/builtins";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { Signal } from "../core/signal";
|
||||
import { fastArrayDelete, fastArrayDeleteValueIfContained } from "./utils";
|
||||
import { Vector } from "./vector";
|
||||
import { IS_MOBILE } from "./config";
|
||||
|
||||
const logger = createLogger("click_detector");
|
||||
|
||||
export const MAX_MOVE_DISTANCE_PX = IS_MOBILE ? 20 : 40;
|
||||
|
||||
// For debugging
|
||||
const registerClickDetectors = G_IS_DEV && true;
|
||||
if (registerClickDetectors) {
|
||||
/** @type {Array<ClickDetector>} */
|
||||
window.activeClickDetectors = [];
|
||||
}
|
||||
|
||||
// Store active click detectors so we can cancel them
|
||||
/** @type {Array<ClickDetector>} */
|
||||
const ongoingClickDetectors = [];
|
||||
|
||||
// Store when the last touch event was registered, to avoid accepting a touch *and* a click event
|
||||
|
||||
export let clickDetectorGlobals = {
|
||||
lastTouchTime: -1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Click detector creation payload typehints
|
||||
* @typedef {{
|
||||
* consumeEvents?: boolean,
|
||||
* preventDefault?: boolean,
|
||||
* applyCssClass?: string,
|
||||
* captureTouchmove?: boolean,
|
||||
* targetOnly?: boolean,
|
||||
* maxDistance?: number,
|
||||
* clickSound?: string,
|
||||
* }} ClickDetectorConstructorArgs
|
||||
*/
|
||||
|
||||
// Detects clicks
|
||||
export class ClickDetector {
|
||||
/**
|
||||
*
|
||||
* @param {Element} element
|
||||
* @param {object} param1
|
||||
* @param {boolean=} param1.consumeEvents Whether to call stopPropagation
|
||||
* (Useful for nested elements where the parent has a click handler as wel)
|
||||
* @param {boolean=} param1.preventDefault Whether to call preventDefault (Usually makes the handler faster)
|
||||
* @param {string=} param1.applyCssClass The css class to add while the element is pressed
|
||||
* @param {boolean=} param1.captureTouchmove Whether to capture touchmove events as well
|
||||
* @param {boolean=} param1.targetOnly Whether to also accept clicks on child elements (e.target !== element)
|
||||
* @param {number=} param1.maxDistance The maximum distance in pixels to accept clicks
|
||||
* @param {string=} param1.clickSound Sound key to play on touchdown
|
||||
*/
|
||||
constructor(
|
||||
element,
|
||||
{
|
||||
consumeEvents = false,
|
||||
preventDefault = true,
|
||||
applyCssClass = "pressed",
|
||||
captureTouchmove = false,
|
||||
targetOnly = false,
|
||||
maxDistance = MAX_MOVE_DISTANCE_PX,
|
||||
clickSound = null,
|
||||
}
|
||||
) {
|
||||
assert(element, "No element given!");
|
||||
this.clickDownPosition = null;
|
||||
|
||||
this.consumeEvents = consumeEvents;
|
||||
this.preventDefault = preventDefault;
|
||||
this.applyCssClass = applyCssClass;
|
||||
this.captureTouchmove = captureTouchmove;
|
||||
this.targetOnly = targetOnly;
|
||||
this.clickSound = clickSound;
|
||||
this.maxDistance = maxDistance;
|
||||
|
||||
// Signals
|
||||
this.click = new Signal();
|
||||
this.rightClick = new Signal();
|
||||
this.touchstart = new Signal();
|
||||
this.touchmove = new Signal();
|
||||
this.touchend = new Signal();
|
||||
this.touchcancel = new Signal();
|
||||
|
||||
// Simple signals which just receive the touch position
|
||||
this.touchstartSimple = new Signal();
|
||||
this.touchmoveSimple = new Signal();
|
||||
this.touchendSimple = new Signal();
|
||||
|
||||
// Store time of touch start
|
||||
this.clickStartTime = null;
|
||||
|
||||
// A click can be cancelled if another detector registers a click
|
||||
this.cancelled = false;
|
||||
|
||||
this.internalBindTo(/** @type {HTMLElement} */ (element));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all event listeners of this detector
|
||||
*/
|
||||
cleanup() {
|
||||
if (this.element) {
|
||||
if (registerClickDetectors) {
|
||||
const index = window.activeClickDetectors.indexOf(this);
|
||||
if (index < 0) {
|
||||
logger.error("Click detector cleanup but is not active");
|
||||
} else {
|
||||
window.activeClickDetectors.splice(index, 1);
|
||||
}
|
||||
}
|
||||
const options = this.internalGetEventListenerOptions();
|
||||
this.element.removeEventListener("touchstart", this.handlerTouchStart, options);
|
||||
this.element.removeEventListener("touchend", this.handlerTouchEnd, options);
|
||||
this.element.removeEventListener("touchcancel", this.handlerTouchCancel, options);
|
||||
|
||||
this.element.removeEventListener("mouseup", this.handlerTouchStart, options);
|
||||
this.element.removeEventListener("mousedown", this.handlerTouchEnd, options);
|
||||
this.element.removeEventListener("mouseout", this.handlerTouchCancel, options);
|
||||
|
||||
if (this.captureTouchmove) {
|
||||
this.element.removeEventListener("touchmove", this.handlerTouchMove, options);
|
||||
this.element.removeEventListener("mousemove", this.handlerTouchMove, options);
|
||||
}
|
||||
|
||||
this.click.removeAll();
|
||||
this.touchstart.removeAll();
|
||||
this.touchmove.removeAll();
|
||||
this.touchend.removeAll();
|
||||
this.touchcancel.removeAll();
|
||||
|
||||
// TODO: Remove pointer captures
|
||||
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
|
||||
// INTERNAL METHODS
|
||||
|
||||
/**
|
||||
* Internal method to get the options to pass to an event listener
|
||||
*/
|
||||
internalGetEventListenerOptions() {
|
||||
return {
|
||||
capture: this.consumeEvents,
|
||||
passive: !this.preventDefault,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the click detector to an element
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
internalBindTo(element) {
|
||||
const options = this.internalGetEventListenerOptions();
|
||||
|
||||
this.handlerTouchStart = this.internalOnPointerDown.bind(this);
|
||||
this.handlerTouchEnd = this.internalOnPointerEnd.bind(this);
|
||||
this.handlerTouchMove = this.internalOnPointerMove.bind(this);
|
||||
this.handlerTouchCancel = this.internalOnTouchCancel.bind(this);
|
||||
|
||||
element.addEventListener("touchstart", this.handlerTouchStart, options);
|
||||
element.addEventListener("touchend", this.handlerTouchEnd, options);
|
||||
element.addEventListener("touchcancel", this.handlerTouchCancel, options);
|
||||
|
||||
element.addEventListener("mousedown", this.handlerTouchStart, options);
|
||||
element.addEventListener("mouseup", this.handlerTouchEnd, options);
|
||||
element.addEventListener("mouseout", this.handlerTouchCancel, options);
|
||||
|
||||
if (this.captureTouchmove) {
|
||||
element.addEventListener("touchmove", this.handlerTouchMove, options);
|
||||
element.addEventListener("mousemove", this.handlerTouchMove, options);
|
||||
}
|
||||
|
||||
if (registerClickDetectors) {
|
||||
window.activeClickDetectors.push(this);
|
||||
}
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the bound element is currently in the DOM.
|
||||
*/
|
||||
internalIsDomElementAttached() {
|
||||
return this.element && document.documentElement.contains(this.element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given event is relevant for this detector
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
*/
|
||||
internalEventPreHandler(event, expectedRemainingTouches = 1) {
|
||||
if (!this.element) {
|
||||
// Already cleaned up
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.targetOnly && event.target !== this.element) {
|
||||
// Clicked a child element
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop any propagation and defaults if configured
|
||||
if (this.consumeEvents && event.cancelable) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (this.preventDefault && event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (window.TouchEvent && event instanceof TouchEvent) {
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
|
||||
// console.log("Got touches", event.targetTouches.length, "vs", expectedRemainingTouches);
|
||||
if (event.targetTouches.length !== expectedRemainingTouches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (event instanceof MouseEvent) {
|
||||
if (performanceNow() - clickDetectorGlobals.lastTouchTime < 1000.0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the mous position from an event
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
* @returns {Vector} The client space position
|
||||
*/
|
||||
static extractPointerPosition(event) {
|
||||
if (window.TouchEvent && event instanceof TouchEvent) {
|
||||
if (event.changedTouches.length !== 1) {
|
||||
logger.warn(
|
||||
"Got unexpected target touches:",
|
||||
event.targetTouches.length,
|
||||
"->",
|
||||
event.targetTouches
|
||||
);
|
||||
return new Vector(0, 0);
|
||||
}
|
||||
|
||||
const touch = event.changedTouches[0];
|
||||
return new Vector(touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
if (event instanceof MouseEvent) {
|
||||
return new Vector(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
assertAlways(false, "Got unknown event: " + event);
|
||||
|
||||
return new Vector(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cacnels all ongoing events on this detector
|
||||
*/
|
||||
cancelOngoingEvents() {
|
||||
if (this.applyCssClass && this.element) {
|
||||
this.element.classList.remove(this.applyCssClass);
|
||||
}
|
||||
this.clickDownPosition = null;
|
||||
this.clickStartTime = null;
|
||||
this.cancelled = true;
|
||||
fastArrayDeleteValueIfContained(ongoingClickDetectors, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pointer down handler
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
*/
|
||||
internalOnPointerDown(event) {
|
||||
if (!this.internalEventPreHandler(event, 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const position = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event);
|
||||
|
||||
if (event instanceof MouseEvent) {
|
||||
const isRightClick = event.which == 3;
|
||||
if (isRightClick) {
|
||||
// Ignore right clicks
|
||||
this.rightClick.dispatch(position, event);
|
||||
this.cancelled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.clickDownPosition) {
|
||||
logger.warn("Ignoring double click");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.cancelled = false;
|
||||
this.touchstart.dispatch(event);
|
||||
|
||||
// Store where the touch started
|
||||
this.clickDownPosition = position;
|
||||
this.clickStartTime = performanceNow();
|
||||
this.touchstartSimple.dispatch(this.clickDownPosition.x, this.clickDownPosition.y);
|
||||
|
||||
// If we are not currently within a click, register it
|
||||
if (ongoingClickDetectors.indexOf(this) < 0) {
|
||||
ongoingClickDetectors.push(this);
|
||||
} else {
|
||||
logger.warn("Click detector got pointer down of active pointer twice");
|
||||
}
|
||||
|
||||
// If we should apply any classes, do this now
|
||||
if (this.applyCssClass) {
|
||||
this.element.classList.add(this.applyCssClass);
|
||||
}
|
||||
|
||||
// If we should play any sound, do this
|
||||
if (this.clickSound) {
|
||||
throw new Error("TODO: Play sounds on click");
|
||||
// GLOBAL_APP.sound.playUiSound(this.clickSound);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pointer move handler
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
*/
|
||||
internalOnPointerMove(event) {
|
||||
if (!this.internalEventPreHandler(event, 1)) {
|
||||
return false;
|
||||
}
|
||||
this.touchmove.dispatch(event);
|
||||
const pos = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event);
|
||||
this.touchmoveSimple.dispatch(pos.x, pos.y);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pointer end handler
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
*/
|
||||
internalOnPointerEnd(event) {
|
||||
if (!this.internalEventPreHandler(event, 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.cancelled) {
|
||||
// warn(this, "Not dispatching touchend on cancelled listener");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event instanceof MouseEvent) {
|
||||
const isRightClick = event.which == 3;
|
||||
if (isRightClick) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const index = ongoingClickDetectors.indexOf(this);
|
||||
if (index < 0) {
|
||||
logger.warn("Got pointer end but click detector is not in pressed state");
|
||||
} else {
|
||||
fastArrayDelete(ongoingClickDetectors, index);
|
||||
}
|
||||
|
||||
let dispatchClick = false;
|
||||
let dispatchClickPos = null;
|
||||
|
||||
// Check for correct down position, otherwise must have pinched or so
|
||||
if (this.clickDownPosition) {
|
||||
const pos = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event);
|
||||
const distance = pos.distance(this.clickDownPosition);
|
||||
if (distance <= this.maxDistance) {
|
||||
dispatchClick = true;
|
||||
dispatchClickPos = pos;
|
||||
} else {
|
||||
// console.warn("[ClickDetector] Touch does not count as click: ms=", timeSinceStart, "-> tolerance:", tolerance, "(was", distance, ")");
|
||||
}
|
||||
}
|
||||
|
||||
this.clickDownPosition = null;
|
||||
this.clickStartTime = null;
|
||||
|
||||
if (this.applyCssClass) {
|
||||
this.element.classList.remove(this.applyCssClass);
|
||||
}
|
||||
|
||||
// Dispatch in the end to avoid the element getting invalidated
|
||||
// Also make sure that the element is still in the dom
|
||||
if (this.internalIsDomElementAttached()) {
|
||||
this.touchend.dispatch(event);
|
||||
this.touchendSimple.dispatch();
|
||||
|
||||
if (dispatchClick) {
|
||||
const detectors = ongoingClickDetectors.slice();
|
||||
for (let i = 0; i < detectors.length; ++i) {
|
||||
detectors[i].cancelOngoingEvents();
|
||||
}
|
||||
this.click.dispatch(dispatchClickPos, event);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch cancel handler
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
*/
|
||||
internalOnTouchCancel(event) {
|
||||
if (!this.internalEventPreHandler(event, 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.cancelled) {
|
||||
// warn(this, "Not dispatching touchcancel on cancelled listener");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.cancelOngoingEvents();
|
||||
this.touchcancel.dispatch(event);
|
||||
this.touchendSimple.dispatch(event);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
104
src/js/core/config.js
Normal file
104
src/js/core/config.js
Normal file
@@ -0,0 +1,104 @@
|
||||
export const IS_DEBUG =
|
||||
G_IS_DEV &&
|
||||
typeof window !== "undefined" &&
|
||||
window.location.port === "3005" &&
|
||||
(window.location.host.indexOf("localhost:") >= 0 || window.location.host.indexOf("192.168.0.") >= 0) &&
|
||||
window.location.search.indexOf("nodebug") < 0;
|
||||
|
||||
const smoothCanvas = true;
|
||||
|
||||
export const globalConfig = {
|
||||
// Size of a single tile in Pixels.
|
||||
// NOTICE: Update webpack.production.config too!
|
||||
tileSize: 32,
|
||||
halfTileSize: 16,
|
||||
|
||||
// Which dpi the assets have
|
||||
assetsDpi: 192 / 32,
|
||||
assetsSharpness: 1.2,
|
||||
shapesSharpness: 1.4,
|
||||
|
||||
// [Calculated] physics step size
|
||||
physicsDeltaMs: 0,
|
||||
physicsDeltaSeconds: 0,
|
||||
|
||||
// Update physics at N fps, independent of rendering
|
||||
physicsUpdateRate: 60,
|
||||
|
||||
// Map
|
||||
mapChunkSize: 32,
|
||||
mapChunkPrerenderMinZoom: 0.7,
|
||||
mapChunkOverviewMinZoom: 0.7,
|
||||
|
||||
// Belt speeds
|
||||
// NOTICE: Update webpack.production.config too!
|
||||
beltSpeedItemsPerSecond: 1,
|
||||
itemSpacingOnBelts: 0.63,
|
||||
minerSpeedItemsPerSecond: 0, // COMPUTED
|
||||
|
||||
undergroundBeltMaxTiles: 5,
|
||||
|
||||
buildingSpeeds: {
|
||||
cutter: 1 / 6,
|
||||
rotater: 1 / 2,
|
||||
painter: 1 / 3,
|
||||
mixer: 1 / 2,
|
||||
stacker: 1 / 5,
|
||||
},
|
||||
|
||||
// Zooming
|
||||
initialZoom: 1.9,
|
||||
minZoomLevel: 0.1,
|
||||
maxZoomLevel: 3,
|
||||
|
||||
// Global game speed
|
||||
gameSpeed: 1,
|
||||
|
||||
warmupTimeSecondsFast: 0.1,
|
||||
warmupTimeSecondsRegular: 1,
|
||||
|
||||
smoothing: {
|
||||
smoothMainCanvas: smoothCanvas && true,
|
||||
quality: "low", // Low is CRUCIAL for mobile performance!
|
||||
},
|
||||
|
||||
rendering: {},
|
||||
|
||||
debug: {
|
||||
/* dev:start */
|
||||
fastGameEnter: true,
|
||||
noArtificialDelays: true,
|
||||
disableSavegameWrite: false,
|
||||
showEntityBounds: false,
|
||||
showAcceptorEjectors: false,
|
||||
usePlainShapeIds: true,
|
||||
disableMusic: true,
|
||||
doNotRenderStatics: false,
|
||||
disableZoomLimits: false,
|
||||
showChunkBorders: false,
|
||||
rewardsInstant: false,
|
||||
allBuildingsUnlocked: true,
|
||||
upgradesNoCost: true,
|
||||
disableUnlockDialog: true,
|
||||
/* dev:end */
|
||||
},
|
||||
|
||||
// Secret vars
|
||||
info: {
|
||||
// Binary file salt
|
||||
file: "Ec'])@^+*9zMevK3uMV4432x9%iK'=",
|
||||
|
||||
// Savegame salt
|
||||
sgSalt: "}95Q3%8/.837Lqym_BJx%q7)pAHJbF",
|
||||
},
|
||||
};
|
||||
|
||||
export const IS_MOBILE = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
|
||||
// Automatic calculations
|
||||
|
||||
globalConfig.physicsDeltaMs = 1000.0 / globalConfig.physicsUpdateRate;
|
||||
globalConfig.physicsDeltaSeconds = 1.0 / globalConfig.physicsUpdateRate;
|
||||
|
||||
globalConfig.minerSpeedItemsPerSecond =
|
||||
globalConfig.beltSpeedItemsPerSecond / globalConfig.itemSpacingOnBelts / 6;
|
||||
117
src/js/core/dpi_manager.js
Normal file
117
src/js/core/dpi_manager.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Math_ceil, Math_floor, Math_round } from "./builtins";
|
||||
import { round1Digit, round2Digits } from "./utils";
|
||||
|
||||
/**
|
||||
* Returns the current dpi
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getDeviceDPI() {
|
||||
return window.devicePixelRatio || 1;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} dpi
|
||||
* @returns {number} Smoothed dpi
|
||||
*/
|
||||
export function smoothenDpi(dpi) {
|
||||
if (dpi < 0.05) {
|
||||
return 0.05;
|
||||
} else if (dpi < 0.1) {
|
||||
return round2Digits(dpi);
|
||||
} else if (dpi < 1) {
|
||||
return round1Digit(dpi);
|
||||
} else {
|
||||
return round1Digit(Math_round(dpi / 0.5) * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial dpi
|
||||
// setDPIMultiplicator(1);
|
||||
|
||||
/**
|
||||
* Prepares a context for hihg dpi rendering
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
export function prepareHighDPIContext(context, smooth = true) {
|
||||
const dpi = getDeviceDPI();
|
||||
context.scale(dpi, dpi);
|
||||
|
||||
if (smooth) {
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.webkitImageSmoothingEnabled = true;
|
||||
|
||||
// @ts-ignore
|
||||
context.imageSmoothingQuality = globalConfig.smoothing.quality;
|
||||
} else {
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.webkitImageSmoothingEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes a high dpi canvas
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
export function resizeHighDPICanvas(canvas, w, h, smooth = true) {
|
||||
const dpi = getDeviceDPI();
|
||||
|
||||
const wNumber = Math_floor(w);
|
||||
const hNumber = Math_floor(h);
|
||||
|
||||
const targetW = Math_floor(wNumber * dpi);
|
||||
const targetH = Math_floor(hNumber * dpi);
|
||||
|
||||
if (targetW !== canvas.width || targetH !== canvas.height) {
|
||||
// console.log("Resize Canvas from", canvas.width, canvas.height, "to", targetW, targetH)
|
||||
canvas.width = targetW;
|
||||
canvas.height = targetH;
|
||||
canvas.style.width = wNumber + "px";
|
||||
canvas.style.height = hNumber + "px";
|
||||
prepareHighDPIContext(canvas.getContext("2d"), smooth);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes a canvas
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
export function resizeCanvas(canvas, w, h, setStyle = true) {
|
||||
const actualW = Math_ceil(w);
|
||||
const actualH = Math_ceil(h);
|
||||
if (actualW !== canvas.width || actualH !== canvas.height) {
|
||||
canvas.width = actualW;
|
||||
canvas.height = actualH;
|
||||
if (setStyle) {
|
||||
canvas.style.width = actualW + "px";
|
||||
canvas.style.height = actualH + "px";
|
||||
}
|
||||
// console.log("Resizing canvas to", actualW, "x", actualH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes a canvas and makes sure its cleared
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
export function resizeCanvasAndClear(canvas, context, w, h) {
|
||||
const actualW = Math_ceil(w);
|
||||
const actualH = Math_ceil(h);
|
||||
if (actualW !== canvas.width || actualH !== canvas.height) {
|
||||
canvas.width = actualW;
|
||||
canvas.height = actualH;
|
||||
canvas.style.width = actualW + "px";
|
||||
canvas.style.height = actualH + "px";
|
||||
// console.log("Resizing canvas to", actualW, "x", actualH);
|
||||
} else {
|
||||
context.clearRect(0, 0, actualW, actualH);
|
||||
}
|
||||
}
|
||||
25
src/js/core/draw_parameters.js
Normal file
25
src/js/core/draw_parameters.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Rectangle } from "./rectangle";
|
||||
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "../game/root";
|
||||
/* typehints:end */
|
||||
|
||||
export class DrawParameters {
|
||||
constructor({ context, visibleRect, desiredAtlasScale, zoomLevel, root }) {
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
this.context = context;
|
||||
|
||||
/** @type {Rectangle} */
|
||||
this.visibleRect = visibleRect;
|
||||
|
||||
/** @type {number} */
|
||||
this.desiredAtlasScale = desiredAtlasScale;
|
||||
|
||||
/** @type {number} */
|
||||
this.zoomLevel = zoomLevel;
|
||||
|
||||
// FIXME: Not really nice
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
}
|
||||
}
|
||||
321
src/js/core/draw_utils.js
Normal file
321
src/js/core/draw_utils.js
Normal file
@@ -0,0 +1,321 @@
|
||||
/* typehints:start */
|
||||
import { AtlasSprite } from "./sprites";
|
||||
import { DrawParameters } from "./draw_parameters";
|
||||
/* typehints:end */
|
||||
|
||||
import { Math_PI, Math_round, Math_atan2, Math_hypot, Math_floor } from "./builtins";
|
||||
import { Vector } from "./vector";
|
||||
import { Rectangle } from "./rectangle";
|
||||
import { createLogger } from "./logging";
|
||||
|
||||
const logger = createLogger("draw_utils");
|
||||
|
||||
export function initDrawUtils() {
|
||||
CanvasRenderingContext2D.prototype.beginRoundedRect = function (x, y, w, h, r) {
|
||||
if (r < 0.05) {
|
||||
this.beginPath();
|
||||
this.rect(x, y, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
if (w < 2 * r) {
|
||||
r = w / 2;
|
||||
}
|
||||
if (h < 2 * r) {
|
||||
r = h / 2;
|
||||
}
|
||||
this.beginPath();
|
||||
this.moveTo(x + r, y);
|
||||
this.arcTo(x + w, y, x + w, y + h, r);
|
||||
this.arcTo(x + w, y + h, x, y + h, r);
|
||||
this.arcTo(x, y + h, x, y, r);
|
||||
this.arcTo(x, y, x + w, y, r);
|
||||
// this.closePath();
|
||||
};
|
||||
|
||||
CanvasRenderingContext2D.prototype.beginCircle = function (x, y, r) {
|
||||
if (r < 0.05) {
|
||||
this.beginPath();
|
||||
this.rect(x, y, 1, 1);
|
||||
return;
|
||||
}
|
||||
this.beginPath();
|
||||
this.arc(x, y, r, 0, 2.0 * Math_PI);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {DrawParameters} param0.parameters
|
||||
* @param {AtlasSprite} param0.sprite
|
||||
* @param {number} param0.x
|
||||
* @param {number} param0.y
|
||||
* @param {number} param0.angle
|
||||
* @param {number} param0.size
|
||||
* @param {number=} param0.offsetX
|
||||
* @param {number=} param0.offsetY
|
||||
*/
|
||||
export function drawRotatedSprite({ parameters, sprite, x, y, angle, size, offsetX = 0, offsetY = 0 }) {
|
||||
parameters.context.translate(x, y);
|
||||
parameters.context.rotate(angle);
|
||||
sprite.drawCachedCentered(parameters, offsetX, offsetY, size, false);
|
||||
parameters.context.rotate(-angle);
|
||||
parameters.context.translate(-x, -y);
|
||||
}
|
||||
|
||||
export function drawLineFast(context, { x1, x2, y1, y2, color = null, lineSize = 1 }) {
|
||||
const dX = x2 - x1;
|
||||
const dY = y2 - y1;
|
||||
|
||||
const angle = Math_atan2(dY, dX) + 0.0 * Math_PI;
|
||||
const len = Math_hypot(dX, dY);
|
||||
|
||||
context.translate(x1, y1);
|
||||
context.rotate(angle);
|
||||
|
||||
if (color) {
|
||||
context.fillStyle = color;
|
||||
}
|
||||
|
||||
context.fillRect(0, -lineSize / 2, len, lineSize);
|
||||
|
||||
context.rotate(-angle);
|
||||
context.translate(-x1, -y1);
|
||||
}
|
||||
|
||||
const INSIDE = 0;
|
||||
const LEFT = 1;
|
||||
const RIGHT = 2;
|
||||
const BOTTOM = 4;
|
||||
const TOP = 8;
|
||||
|
||||
// https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm
|
||||
|
||||
function computeOutCode(x, y, xmin, xmax, ymin, ymax) {
|
||||
let code = INSIDE;
|
||||
|
||||
if (x < xmin)
|
||||
// to the left of clip window
|
||||
code |= LEFT;
|
||||
else if (x > xmax)
|
||||
// to the right of clip window
|
||||
code |= RIGHT;
|
||||
if (y < ymin)
|
||||
// below the clip window
|
||||
code |= BOTTOM;
|
||||
else if (y > ymax)
|
||||
// above the clip window
|
||||
code |= TOP;
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
// Cohen–Sutherland clipping algorithm clips a line from
|
||||
// P0 = (x0, y0) to P1 = (x1, y1) against a rectangle with
|
||||
// diagonal from (xmin, ymin) to (xmax, ymax).
|
||||
/**
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
export function drawLineFastClipped(context, rect, { x0, y0, x1, y1, color = null, lineSize = 1 }) {
|
||||
const xmin = rect.x;
|
||||
const ymin = rect.y;
|
||||
const xmax = rect.right();
|
||||
const ymax = rect.bottom();
|
||||
|
||||
// compute outcodes for P0, P1, and whatever point lies outside the clip rectangle
|
||||
let outcode0 = computeOutCode(x0, y0, xmin, xmax, ymin, ymax);
|
||||
let outcode1 = computeOutCode(x1, y1, xmin, xmax, ymin, ymax);
|
||||
let accept = false;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (!(outcode0 | outcode1)) {
|
||||
// bitwise OR is 0: both points inside window; trivially accept and exit loop
|
||||
accept = true;
|
||||
break;
|
||||
} else if (outcode0 & outcode1) {
|
||||
// bitwise AND is not 0: both points share an outside zone (LEFT, RIGHT, TOP,
|
||||
// or BOTTOM), so both must be outside window; exit loop (accept is false)
|
||||
break;
|
||||
} else {
|
||||
// failed both tests, so calculate the line segment to clip
|
||||
// from an outside point to an intersection with clip edge
|
||||
let x, y;
|
||||
|
||||
// At least one endpoint is outside the clip rectangle; pick it.
|
||||
let outcodeOut = outcode0 ? outcode0 : outcode1;
|
||||
|
||||
// Now find the intersection point;
|
||||
// use formulas:
|
||||
// slope = (y1 - y0) / (x1 - x0)
|
||||
// x = x0 + (1 / slope) * (ym - y0), where ym is ymin or ymax
|
||||
// y = y0 + slope * (xm - x0), where xm is xmin or xmax
|
||||
// No need to worry about divide-by-zero because, in each case, the
|
||||
// outcode bit being tested guarantees the denominator is non-zero
|
||||
if (outcodeOut & TOP) {
|
||||
// point is above the clip window
|
||||
x = x0 + ((x1 - x0) * (ymax - y0)) / (y1 - y0);
|
||||
y = ymax;
|
||||
} else if (outcodeOut & BOTTOM) {
|
||||
// point is below the clip window
|
||||
x = x0 + ((x1 - x0) * (ymin - y0)) / (y1 - y0);
|
||||
y = ymin;
|
||||
} else if (outcodeOut & RIGHT) {
|
||||
// point is to the right of clip window
|
||||
y = y0 + ((y1 - y0) * (xmax - x0)) / (x1 - x0);
|
||||
x = xmax;
|
||||
} else if (outcodeOut & LEFT) {
|
||||
// point is to the left of clip window
|
||||
y = y0 + ((y1 - y0) * (xmin - x0)) / (x1 - x0);
|
||||
x = xmin;
|
||||
}
|
||||
|
||||
// Now we move outside point to intersection point to clip
|
||||
// and get ready for next pass.
|
||||
if (outcodeOut == outcode0) {
|
||||
x0 = x;
|
||||
y0 = y;
|
||||
outcode0 = computeOutCode(x0, y0, xmin, xmax, ymin, ymax);
|
||||
} else {
|
||||
x1 = x;
|
||||
y1 = y;
|
||||
outcode1 = computeOutCode(x1, y1, xmin, xmax, ymin, ymax);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (accept) {
|
||||
// Following functions are left for implementation by user based on
|
||||
// their platform (OpenGL/graphics.h etc.)
|
||||
// DrawRectangle(xmin, ymin, xmax, ymax);
|
||||
// LineSegment(x0, y0, x1, y1);
|
||||
drawLineFast(context, {
|
||||
x1: x0,
|
||||
y1: y0,
|
||||
x2: x1,
|
||||
y2: y1,
|
||||
color,
|
||||
lineSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an HSL color value to RGB. Conversion formula
|
||||
* adapted from http://en.wikipedia.org/wiki/HSL_color_space.
|
||||
* Assumes h, s, and l are contained in the set [0, 1] and
|
||||
* returns r, g, and b in the set [0, 255].
|
||||
*
|
||||
* @param {number} h The hue
|
||||
* @param {number} s The saturation
|
||||
* @param {number} l The lightness
|
||||
* @return {Array} The RGB representation
|
||||
*/
|
||||
export function hslToRgb(h, s, l) {
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l; // achromatic
|
||||
} else {
|
||||
// tslint:disable-next-line:no-shadowed-variable
|
||||
const hue2rgb = function (p, q, t) {
|
||||
if (t < 0) {
|
||||
t += 1;
|
||||
}
|
||||
if (t > 1) {
|
||||
t -= 1;
|
||||
}
|
||||
if (t < 1 / 6) {
|
||||
return p + (q - p) * 6 * t;
|
||||
}
|
||||
if (t < 1 / 2) {
|
||||
return q;
|
||||
}
|
||||
if (t < 2 / 3) {
|
||||
return p + (q - p) * (2 / 3 - t) * 6;
|
||||
}
|
||||
return p;
|
||||
};
|
||||
|
||||
let q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
let p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
return [Math_round(r * 255), Math_round(g * 255), Math_round(b * 255)];
|
||||
}
|
||||
|
||||
export function wrapText(context, text, x, y, maxWidth, lineHeight, stroke = false) {
|
||||
var words = text.split(" ");
|
||||
var line = "";
|
||||
|
||||
for (var n = 0; n < words.length; n++) {
|
||||
var testLine = line + words[n] + " ";
|
||||
var metrics = context.measureText(testLine);
|
||||
var testWidth = metrics.width;
|
||||
if (testWidth > maxWidth && n > 0) {
|
||||
if (stroke) {
|
||||
context.strokeText(line, x, y);
|
||||
} else {
|
||||
context.fillText(line, x, y);
|
||||
}
|
||||
line = words[n] + " ";
|
||||
y += lineHeight;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (stroke) {
|
||||
context.strokeText(line, x, y);
|
||||
} else {
|
||||
context.fillText(line, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rotated trapez, used for spotlight culling
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {number} leftHeight
|
||||
* @param {number} angle
|
||||
*/
|
||||
export function rotateTrapezRightFaced(x, y, w, h, leftHeight, angle) {
|
||||
const halfY = y + h / 2;
|
||||
const points = [
|
||||
new Vector(x, halfY - leftHeight / 2),
|
||||
new Vector(x + w, y),
|
||||
new Vector(x, halfY + leftHeight / 2),
|
||||
new Vector(x + w, y + h),
|
||||
];
|
||||
|
||||
return Rectangle.getAroundPointsRotated(points, angle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts values from 0 .. 255 to values like 07, 7f, 5d etc
|
||||
* @param {number} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mapClampedColorValueToHex(value) {
|
||||
const hex = "0123456789abcdef";
|
||||
return hex[Math_floor(value / 16)] + hex[value % 16];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts rgb to a hex string
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @returns {string}
|
||||
*/
|
||||
export function rgbToHex(r, g, b) {
|
||||
return mapClampedColorValueToHex(r) + mapClampedColorValueToHex(g) + mapClampedColorValueToHex(b);
|
||||
}
|
||||
126
src/js/core/error_handler.js
Normal file
126
src/js/core/error_handler.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { logSection } from "./logging";
|
||||
import { stringifyObjectContainingErrors } from "./logging";
|
||||
import { removeAllChildren } from "./utils";
|
||||
|
||||
export let APPLICATION_ERROR_OCCURED = false;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Event|string} message
|
||||
* @param {string} source
|
||||
* @param {number} lineno
|
||||
* @param {number} colno
|
||||
* @param {Error} source
|
||||
*/
|
||||
function catchErrors(message, source, lineno, colno, error) {
|
||||
let fullPayload = JSON.parse(
|
||||
stringifyObjectContainingErrors({
|
||||
message,
|
||||
source,
|
||||
lineno,
|
||||
colno,
|
||||
error,
|
||||
})
|
||||
);
|
||||
|
||||
if (("" + message).indexOf("Script error.") >= 0) {
|
||||
console.warn("Thirdparty script error:", message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (("" + message).indexOf("NS_ERROR_FAILURE") >= 0) {
|
||||
console.warn("Firefox NS_ERROR_FAILURE error:", message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (("" + message).indexOf("Cannot read property 'postMessage' of null") >= 0) {
|
||||
console.warn("Safari can not read post message error:", message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!G_IS_DEV && G_IS_BROWSER && ("" + source).indexOf("shapez.io") < 0) {
|
||||
console.warn("Thirdparty error:", message);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n\n\n⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️\n\n\n");
|
||||
console.log(" APPLICATION CRASHED ");
|
||||
console.log("\n\n⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️\n\n\n");
|
||||
|
||||
logSection("APPLICATION CRASH", "#e53935");
|
||||
console.log("Error:", message, "->", error);
|
||||
console.log("Payload:", fullPayload);
|
||||
|
||||
if (window.Sentry && !window.anyModLoaded) {
|
||||
window.Sentry.withScope(scope => {
|
||||
window.Sentry.setTag("message", message);
|
||||
window.Sentry.setTag("source", source);
|
||||
|
||||
window.Sentry.setExtra("message", message);
|
||||
window.Sentry.setExtra("source", source);
|
||||
window.Sentry.setExtra("lineno", lineno);
|
||||
window.Sentry.setExtra("colno", colno);
|
||||
window.Sentry.setExtra("error", error);
|
||||
window.Sentry.setExtra("fullPayload", fullPayload);
|
||||
|
||||
try {
|
||||
const userName = window.localStorage.getItem("tracking_context") || null;
|
||||
window.Sentry.setTag("username", userName);
|
||||
} catch (ex) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
window.Sentry.captureException(error || source);
|
||||
});
|
||||
}
|
||||
|
||||
if (APPLICATION_ERROR_OCCURED) {
|
||||
console.warn("ERROR: Only showing and submitting first error");
|
||||
return;
|
||||
}
|
||||
|
||||
APPLICATION_ERROR_OCCURED = true;
|
||||
const element = document.createElement("div");
|
||||
element.id = "applicationError";
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.innerText = "Whoops!";
|
||||
element.appendChild(title);
|
||||
|
||||
const desc = document.createElement("div");
|
||||
desc.classList.add("desc");
|
||||
desc.innerHTML = `
|
||||
It seems the application crashed - I am sorry for that!<br /><br />
|
||||
An anonymized crash report has been sent, and I will have a look as soon as possible.<br /><br />
|
||||
If you have additional information how I can reproduce this error, please E-Mail me:
|
||||
<a href="mailto:bugs@shapez.io?title=Application+Crash">bugs@shapez.io</a>`;
|
||||
element.appendChild(desc);
|
||||
|
||||
const details = document.createElement("pre");
|
||||
details.classList.add("details");
|
||||
details.innerText = (error && error.stack) || message;
|
||||
element.appendChild(details);
|
||||
|
||||
const inject = function () {
|
||||
if (!G_IS_DEV) {
|
||||
removeAllChildren(document.body);
|
||||
}
|
||||
if (document.body.parentElement) {
|
||||
document.body.parentElement.appendChild(element);
|
||||
} else {
|
||||
document.body.appendChild(element);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.body) {
|
||||
inject();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
inject();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
window.onerror = catchErrors;
|
||||
40
src/js/core/explained_result.js
Normal file
40
src/js/core/explained_result.js
Normal file
@@ -0,0 +1,40 @@
|
||||
export class ExplainedResult {
|
||||
constructor(result = true, reason = null, additionalProps = {}) {
|
||||
/** @type {boolean} */
|
||||
this.result = result;
|
||||
|
||||
/** @type {string} */
|
||||
this.reason = reason;
|
||||
|
||||
// Copy additional props
|
||||
for (const key in additionalProps) {
|
||||
this[key] = additionalProps[key];
|
||||
}
|
||||
}
|
||||
|
||||
isGood() {
|
||||
return !!this.result;
|
||||
}
|
||||
|
||||
isBad() {
|
||||
return !this.result;
|
||||
}
|
||||
|
||||
static good() {
|
||||
return new ExplainedResult(true);
|
||||
}
|
||||
|
||||
static bad(reason, additionalProps) {
|
||||
return new ExplainedResult(false, reason, additionalProps);
|
||||
}
|
||||
|
||||
static requireAll(...args) {
|
||||
for (let i = 0; i < args.length; ++i) {
|
||||
const subResult = args[i].call();
|
||||
if (!subResult.isGood()) {
|
||||
return subResult;
|
||||
}
|
||||
}
|
||||
return this.good();
|
||||
}
|
||||
}
|
||||
81
src/js/core/factory.js
Normal file
81
src/js/core/factory.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createLogger } from "./logging";
|
||||
|
||||
const logger = createLogger("factory");
|
||||
|
||||
// simple factory pattern
|
||||
export class Factory {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
|
||||
// Store array as well as dictionary, to speed up lookups
|
||||
this.entries = [];
|
||||
this.entryIds = [];
|
||||
this.idToEntry = {};
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
register(entry) {
|
||||
// Extract id
|
||||
const id = entry.getId();
|
||||
assert(id, "Factory: Invalid id for class: " + entry);
|
||||
|
||||
// Check duplicates
|
||||
assert(!this.idToEntry[id], "Duplicate factory entry for " + id);
|
||||
|
||||
// Insert
|
||||
this.entries.push(entry);
|
||||
this.entryIds.push(id);
|
||||
this.idToEntry[id] = entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given id is registered
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasId(id) {
|
||||
return !!this.idToEntry[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an instance by a given id
|
||||
* @param {string} id
|
||||
* @returns {object}
|
||||
*/
|
||||
findById(id) {
|
||||
const entry = this.idToEntry[id];
|
||||
if (!entry) {
|
||||
logger.error("Object with id", id, "is not registered on factory", this.id, "!");
|
||||
assert(false, "Factory: Object with id '" + id + "' is not registered!");
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all entries
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getEntries() {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all registered ids
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
getAllIds() {
|
||||
return this.entryIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns amount of stored entries
|
||||
* @returns {number}
|
||||
*/
|
||||
getNumEntries() {
|
||||
return this.entries.length;
|
||||
}
|
||||
}
|
||||
365
src/js/core/game_state.js
Normal file
365
src/js/core/game_state.js
Normal file
@@ -0,0 +1,365 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
import { StateManager } from "./state_manager";
|
||||
/* typehints:end */
|
||||
|
||||
import { globalConfig } from "./config";
|
||||
import { ClickDetector } from "./click_detector";
|
||||
import { logSection, createLogger } from "./logging";
|
||||
import { InputReceiver } from "./input_receiver";
|
||||
import { waitNextFrame } from "./utils";
|
||||
import { RequestChannel } from "./request_channel";
|
||||
import { MUSIC } from "../platform/sound";
|
||||
|
||||
const logger = createLogger("game_state");
|
||||
|
||||
/**
|
||||
* Basic state of the game state machine. This is the base of the whole game
|
||||
*/
|
||||
export class GameState {
|
||||
/**
|
||||
* Constructs a new state with the given id
|
||||
* @param {string} key The id of the state. We use ids to refer to states because otherwise we get
|
||||
* circular references
|
||||
*/
|
||||
constructor(key) {
|
||||
this.key = key;
|
||||
|
||||
/** @type {StateManager} */
|
||||
this.stateManager = null;
|
||||
|
||||
/** @type {Application} */
|
||||
this.app = null;
|
||||
|
||||
// Store if we are currently fading out
|
||||
this.fadingOut = false;
|
||||
|
||||
/** @type {Array<ClickDetector>} */
|
||||
this.clickDetectors = [];
|
||||
|
||||
// Every state captures keyboard events by default
|
||||
this.inputReciever = new InputReceiver("state-" + key);
|
||||
this.inputReciever.backButton.add(this.onBackButton, this);
|
||||
|
||||
// A channel we can use to perform async ops
|
||||
this.asyncChannel = new RequestChannel();
|
||||
}
|
||||
|
||||
//// GETTERS / HELPER METHODS ////
|
||||
|
||||
/**
|
||||
* Returns the states key
|
||||
* @returns {string}
|
||||
*/
|
||||
getKey() {
|
||||
return this.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the html element of the state
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
getDivElement() {
|
||||
return document.getElementById("state_" + this.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers to a new state
|
||||
* @param {string} stateKey The id of the new state
|
||||
*/
|
||||
moveToState(stateKey, payload = {}, skipFadeOut = false) {
|
||||
if (this.fadingOut) {
|
||||
logger.warn("Skipping move to '" + stateKey + "' since already fading out");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up event listeners
|
||||
this.internalCleanUpClickDetectors();
|
||||
|
||||
// Fading
|
||||
const fadeTime = this.internalGetFadeInOutTime();
|
||||
const doFade = !skipFadeOut && this.getHasFadeOut() && fadeTime !== 0;
|
||||
logger.log("Moving to", stateKey, "(fading=", doFade, ")");
|
||||
if (doFade) {
|
||||
this.htmlElement.classList.remove("arrived");
|
||||
this.fadingOut = true;
|
||||
setTimeout(() => {
|
||||
this.stateManager.moveToState(stateKey, payload);
|
||||
}, fadeTime);
|
||||
} else {
|
||||
this.stateManager.moveToState(stateKey, payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} nextStateId
|
||||
* @param {object=} nextStatePayload
|
||||
*/
|
||||
watchAdAndMoveToState(nextStateId, nextStatePayload = {}) {
|
||||
if (this.app.adProvider.getCanShowVideoAd() && this.app.isRenderable()) {
|
||||
this.moveToState(
|
||||
"WatchAdState",
|
||||
{
|
||||
nextStateId,
|
||||
nextStatePayload,
|
||||
},
|
||||
true
|
||||
);
|
||||
} else {
|
||||
this.moveToState(nextStateId, nextStatePayload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks clicks on a given element and calls the given callback *on this state*.
|
||||
* If you want to call another function wrap it inside a lambda.
|
||||
* @param {Element} element The element to track clicks on
|
||||
* @param {function():void} handler The handler to call
|
||||
* @param {import("./click_detector").ClickDetectorConstructorArgs=} args Click detector arguments
|
||||
*/
|
||||
trackClicks(element, handler, args = {}) {
|
||||
const detector = new ClickDetector(element, args);
|
||||
detector.click.add(handler, this);
|
||||
if (G_IS_DEV) {
|
||||
// Append a source so we can check where the click detector is from
|
||||
// @ts-ignore
|
||||
detector._src = "state-" + this.key;
|
||||
}
|
||||
this.clickDetectors.push(detector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all promises on the api as well as our async channel
|
||||
*/
|
||||
cancelAllAsyncOperations() {
|
||||
this.asyncChannel.cancelAll();
|
||||
// TODO
|
||||
// this.app.api.cancelRequests();
|
||||
}
|
||||
|
||||
//// CALLBACKS ////
|
||||
|
||||
/**
|
||||
* Callback when entering the state, to be overriddemn
|
||||
* @param {any} payload Arbitrary data passed from the state which we are transferring from
|
||||
*/
|
||||
onEnter(payload) {}
|
||||
|
||||
/**
|
||||
* Callback when leaving the state
|
||||
*/
|
||||
onLeave() {}
|
||||
|
||||
/**
|
||||
* Callback before leaving the game state or when the page is unloaded
|
||||
*/
|
||||
onBeforeExit() {}
|
||||
|
||||
/**
|
||||
* Callback when the app got paused (on android, this means in background)
|
||||
*/
|
||||
onAppPause() {}
|
||||
|
||||
/**
|
||||
* Callback when the app got resumed (on android, this means in foreground again)
|
||||
*/
|
||||
onAppResume() {}
|
||||
|
||||
/**
|
||||
* Render callback
|
||||
* @param {number} dt Delta time in ms since last render
|
||||
*/
|
||||
onRender(dt) {}
|
||||
|
||||
/**
|
||||
* Background tick callback, called while the game is inactiev
|
||||
* @param {number} dt Delta time in ms since last tick
|
||||
*/
|
||||
onBackgroundTick(dt) {}
|
||||
|
||||
/**
|
||||
* Called when the screen resized
|
||||
* @param {number} w window/screen width
|
||||
* @param {number} h window/screen height
|
||||
*/
|
||||
onResized(w, h) {}
|
||||
|
||||
/**
|
||||
* Internal backbutton handler, called when the hardware back button is pressed or
|
||||
* the escape key is pressed
|
||||
*/
|
||||
onBackButton() {}
|
||||
|
||||
//// INTERFACE ////
|
||||
|
||||
/**
|
||||
* Should return how many mulliseconds to fade in / out the state. Not recommended to override!
|
||||
* @returns {number} Time in milliseconds to fade out
|
||||
*/
|
||||
getInOutFadeTime() {
|
||||
if (globalConfig.debug.noArtificialDelays) {
|
||||
return 0;
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return whether to fade in the game state. This will then apply the right css classes
|
||||
* for the fadein.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getHasFadeIn() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return whether to fade out the game state. This will then apply the right css classes
|
||||
* for the fadeout and wait the delay before moving states
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getHasFadeOut() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this state should get paused if it does not have focus
|
||||
* @returns {boolean} true to pause the updating of the game
|
||||
*/
|
||||
getPauseOnFocusLost() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the html code of the state.
|
||||
* @returns {string}
|
||||
*/
|
||||
getInnerHTML() {
|
||||
abstract;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the state has an unload confirmation, this is the
|
||||
* "Are you sure you want to leave the page" message.
|
||||
*/
|
||||
getHasUnloadConfirmation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the theme music for this state
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getThemeMusic() {
|
||||
return MUSIC.mainMenu;
|
||||
}
|
||||
|
||||
////////////////////
|
||||
|
||||
//// INTERNAL ////
|
||||
|
||||
/**
|
||||
* Internal callback from the manager. Do not override!
|
||||
* @param {StateManager} stateManager
|
||||
*/
|
||||
internalRegisterCallback(stateManager, app) {
|
||||
assert(stateManager, "No state manager");
|
||||
assert(app, "No app");
|
||||
this.stateManager = stateManager;
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal callback when entering the state. Do not override!
|
||||
* @param {any} payload Arbitrary data passed from the state which we are transferring from
|
||||
* @param {boolean} callCallback Whether to call the onEnter callback
|
||||
*/
|
||||
internalEnterCallback(payload, callCallback = true) {
|
||||
logSection(this.key, "#26a69a");
|
||||
this.app.inputMgr.pushReciever(this.inputReciever);
|
||||
|
||||
this.htmlElement = this.getDivElement();
|
||||
this.htmlElement.classList.add("active");
|
||||
|
||||
// Apply classes in the next frame so the css transition keeps up
|
||||
waitNextFrame().then(() => {
|
||||
if (this.htmlElement) {
|
||||
this.htmlElement.classList.remove("fadingOut");
|
||||
this.htmlElement.classList.remove("fadingIn");
|
||||
}
|
||||
});
|
||||
|
||||
// Call handler
|
||||
if (callCallback) {
|
||||
this.onEnter(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal callback when the state is left. Do not override!
|
||||
*/
|
||||
internalLeaveCallback() {
|
||||
this.onLeave();
|
||||
|
||||
this.htmlElement.classList.remove("active");
|
||||
this.app.inputMgr.popReciever(this.inputReciever);
|
||||
this.internalCleanUpClickDetectors();
|
||||
this.asyncChannel.cancelAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal callback *before* the state is left. Also is called on page unload
|
||||
*/
|
||||
internalOnBeforeExitCallback() {
|
||||
this.onBeforeExit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal app pause callback
|
||||
*/
|
||||
internalOnAppPauseCallback() {
|
||||
this.onAppPause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal app resume callback
|
||||
*/
|
||||
internalOnAppResumeCallback() {
|
||||
this.onAppResume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all click detectors
|
||||
*/
|
||||
internalCleanUpClickDetectors() {
|
||||
if (this.clickDetectors) {
|
||||
for (let i = 0; i < this.clickDetectors.length; ++i) {
|
||||
this.clickDetectors[i].cleanup();
|
||||
}
|
||||
this.clickDetectors = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to get the HTML of the game state.
|
||||
* @returns {string}
|
||||
*/
|
||||
internalGetFullHtml() {
|
||||
return this.getInnerHTML();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to compute the time to fade in / out
|
||||
* @returns {number} time to fade in / out in ms
|
||||
*/
|
||||
internalGetFadeInOutTime() {
|
||||
if (G_IS_DEV && globalConfig.debug.fastGameEnter) {
|
||||
return 1;
|
||||
}
|
||||
if (G_IS_DEV && globalConfig.debug.noArtificialDelays) {
|
||||
return 1;
|
||||
}
|
||||
return this.getInOutFadeTime();
|
||||
}
|
||||
}
|
||||
35
src/js/core/global_registries.js
Normal file
35
src/js/core/global_registries.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { SingletonFactory } from "./singleton_factory";
|
||||
import { Factory } from "./factory";
|
||||
|
||||
/* typehints:start */
|
||||
import { BaseGameSpeed } from "../game/time/base_game_speed";
|
||||
import { Component } from "../game/component";
|
||||
import { BaseItem } from "../game/base_item";
|
||||
import { MetaBuilding } from "../game/meta_building";
|
||||
/* typehints:end */
|
||||
|
||||
// These factories are here to remove circular dependencies
|
||||
|
||||
/** @type {SingletonFactoryTemplate<MetaBuilding>} */
|
||||
export let gMetaBuildingRegistry = new SingletonFactory();
|
||||
|
||||
/** @type {Object.<string, Array<typeof MetaBuilding>>} */
|
||||
export let gBuildingsByCategory = null;
|
||||
|
||||
/** @type {FactoryTemplate<Component>} */
|
||||
export let gComponentRegistry = new Factory("component");
|
||||
|
||||
/** @type {FactoryTemplate<BaseGameSpeed>} */
|
||||
export let gGameSpeedRegistry = new Factory("gamespeed");
|
||||
|
||||
/** @type {FactoryTemplate<BaseItem>} */
|
||||
export let gItemRegistry = new Factory("item");
|
||||
|
||||
// Helpers
|
||||
|
||||
/**
|
||||
* @param {Object.<string, Array<typeof MetaBuilding>>} buildings
|
||||
*/
|
||||
export function initBuildingsByCategory(buildings) {
|
||||
gBuildingsByCategory = buildings;
|
||||
}
|
||||
17
src/js/core/globals.js
Normal file
17
src/js/core/globals.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
/**
|
||||
* Used for the bug reporter, and the click detector which both have no handles to this.
|
||||
* It would be nicer to have no globals, but this is the only one. I promise!
|
||||
* @type {Application} */
|
||||
export let GLOBAL_APP = null;
|
||||
|
||||
/**
|
||||
* @param {Application} app
|
||||
*/
|
||||
export function setGlobalApp(app) {
|
||||
assert(!GLOBAL_APP, "Create application twice!");
|
||||
GLOBAL_APP = app;
|
||||
}
|
||||
235
src/js/core/input_distributor.js
Normal file
235
src/js/core/input_distributor.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
import { InputReceiver } from "./input_receiver";
|
||||
/* typehints:end */
|
||||
|
||||
import { Signal, STOP_PROPAGATION } from "./signal";
|
||||
import { createLogger } from "./logging";
|
||||
import { arrayDeleteValue, fastArrayDeleteValue } from "./utils";
|
||||
|
||||
const logger = createLogger("input_distributor");
|
||||
|
||||
export class InputDistributor {
|
||||
/**
|
||||
*
|
||||
* @param {Application} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/** @type {Array<InputReceiver>} */
|
||||
this.recieverStack = [];
|
||||
|
||||
/** @type {Array<function(any) : boolean>} */
|
||||
this.filters = [];
|
||||
|
||||
this.shiftIsDown = false;
|
||||
this.altIsDown = false;
|
||||
|
||||
this.bindToEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a new filter which can filter and reject events
|
||||
* @param {function(any): boolean} filter
|
||||
*/
|
||||
installFilter(filter) {
|
||||
this.filters.push(filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an attached filter
|
||||
* @param {function(any) : boolean} filter
|
||||
*/
|
||||
dismountFilter(filter) {
|
||||
fastArrayDeleteValue(this.filters, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
pushReciever(reciever) {
|
||||
if (this.isRecieverAttached(reciever)) {
|
||||
assert(false, "Can not add reciever " + reciever.context + " twice");
|
||||
logger.error("Can not add reciever", reciever.context, "twice");
|
||||
return;
|
||||
}
|
||||
this.recieverStack.push(reciever);
|
||||
|
||||
if (this.recieverStack.length > 10) {
|
||||
logger.error(
|
||||
"Reciever stack is huge, probably some dead receivers arround:",
|
||||
this.recieverStack.map(x => x.context)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
popReciever(reciever) {
|
||||
if (this.recieverStack.indexOf(reciever) < 0) {
|
||||
assert(false, "Can not pop reciever " + reciever.context + " since its not contained");
|
||||
logger.error("Can not pop reciever", reciever.context, "since its not contained");
|
||||
return;
|
||||
}
|
||||
if (this.recieverStack[this.recieverStack.length - 1] !== reciever) {
|
||||
logger.warn(
|
||||
"Popping reciever",
|
||||
reciever.context,
|
||||
"which is not on top of the stack. Stack is: ",
|
||||
this.recieverStack.map(x => x.context)
|
||||
);
|
||||
}
|
||||
arrayDeleteValue(this.recieverStack, reciever);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
isRecieverAttached(reciever) {
|
||||
return this.recieverStack.indexOf(reciever) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
isRecieverOnTop(reciever) {
|
||||
return (
|
||||
this.isRecieverAttached(reciever) &&
|
||||
this.recieverStack[this.recieverStack.length - 1] === reciever
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
makeSureAttachedAndOnTop(reciever) {
|
||||
this.makeSureDetached(reciever);
|
||||
this.pushReciever(reciever);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
makeSureDetached(reciever) {
|
||||
if (this.isRecieverAttached(reciever)) {
|
||||
arrayDeleteValue(this.recieverStack, reciever);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
destroyReceiver(reciever) {
|
||||
this.makeSureDetached(reciever);
|
||||
reciever.cleanup();
|
||||
}
|
||||
|
||||
// Internal
|
||||
|
||||
getTopReciever() {
|
||||
if (this.recieverStack.length > 0) {
|
||||
return this.recieverStack[this.recieverStack.length - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bindToEvents() {
|
||||
window.addEventListener("popstate", this.handleBackButton.bind(this), false);
|
||||
document.addEventListener("backbutton", this.handleBackButton.bind(this), false);
|
||||
window.addEventListener("keydown", this.handleKeydown.bind(this));
|
||||
window.addEventListener("keyup", this.handleKeyup.bind(this));
|
||||
window.addEventListener("blur", this.handleBlur.bind(this));
|
||||
}
|
||||
|
||||
forwardToReceiver(eventId, payload = null) {
|
||||
// Check filters
|
||||
for (let i = 0; i < this.filters.length; ++i) {
|
||||
if (!this.filters[i](eventId)) {
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
}
|
||||
|
||||
const reciever = this.getTopReciever();
|
||||
if (!reciever) {
|
||||
logger.warn("Dismissing event because not reciever was found:", eventId);
|
||||
return;
|
||||
}
|
||||
const signal = reciever[eventId];
|
||||
assert(signal instanceof Signal, "Not a valid event id");
|
||||
return signal.dispatch(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
handleBackButton(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.forwardToReceiver("backButton");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles when the page got blurred
|
||||
*/
|
||||
handleBlur() {
|
||||
this.shiftIsDown = false;
|
||||
this.forwardToReceiver("pageBlur", {});
|
||||
this.forwardToReceiver("shiftUp", {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
handleKeydown(event) {
|
||||
if (event.keyCode === 16) {
|
||||
this.shiftIsDown = true;
|
||||
}
|
||||
|
||||
if (
|
||||
// TAB
|
||||
event.keyCode === 9 ||
|
||||
// F1 - F10
|
||||
(event.keyCode >= 112 && event.keyCode < 122 && !G_IS_DEV)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (
|
||||
this.forwardToReceiver("keydown", {
|
||||
keyCode: event.keyCode,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
event,
|
||||
}) === STOP_PROPAGATION
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = event.keyCode;
|
||||
if (code === 27) {
|
||||
// Escape key
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return this.forwardToReceiver("backButton");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
handleKeyup(event) {
|
||||
if (event.keyCode === 16) {
|
||||
this.shiftIsDown = false;
|
||||
this.forwardToReceiver("shiftUp", {});
|
||||
}
|
||||
|
||||
this.forwardToReceiver("keyup", {
|
||||
keyCode: event.keyCode,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
25
src/js/core/input_receiver.js
Normal file
25
src/js/core/input_receiver.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Signal } from "./signal";
|
||||
|
||||
export class InputReceiver {
|
||||
constructor(context = "unknown") {
|
||||
this.context = context;
|
||||
|
||||
this.backButton = new Signal();
|
||||
|
||||
this.keydown = new Signal();
|
||||
this.keyup = new Signal();
|
||||
this.pageBlur = new Signal();
|
||||
this.shiftUp = new Signal();
|
||||
|
||||
// Dispatched on destroy
|
||||
this.destroyed = new Signal();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.backButton.removeAll();
|
||||
this.keydown.removeAll();
|
||||
this.keyup.removeAll();
|
||||
|
||||
this.destroyed.dispatch();
|
||||
}
|
||||
}
|
||||
243
src/js/core/loader.js
Normal file
243
src/js/core/loader.js
Normal file
@@ -0,0 +1,243 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { AtlasDefinition } from "./atlas_definitions";
|
||||
import { makeOffscreenBuffer } from "./buffer_utils";
|
||||
import { AtlasSprite, BaseSprite, RegularSprite, SpriteAtlasLink } from "./sprites";
|
||||
import { cachebust } from "./cachebust";
|
||||
import { createLogger } from "./logging";
|
||||
|
||||
const logger = createLogger("loader");
|
||||
|
||||
const missingSpriteIds = {};
|
||||
|
||||
class LoaderImpl {
|
||||
constructor() {
|
||||
/** @type {Application} */
|
||||
this.app = null;
|
||||
|
||||
/** @type {Map<string, BaseSprite>} */
|
||||
this.sprites = new Map();
|
||||
|
||||
this.rawImages = [];
|
||||
}
|
||||
|
||||
linkAppAfterBoot(app) {
|
||||
this.app = app;
|
||||
this.makeSpriteNotFoundCanvas();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a given sprite from the cache
|
||||
* @param {string} key
|
||||
* @returns {BaseSprite}
|
||||
*/
|
||||
getSpriteInternal(key) {
|
||||
const sprite = this.sprites.get(key);
|
||||
if (!sprite) {
|
||||
if (!missingSpriteIds[key]) {
|
||||
// Only show error once
|
||||
missingSpriteIds[key] = true;
|
||||
logger.error("Sprite '" + key + "' not found!");
|
||||
}
|
||||
return this.spriteNotFoundSprite;
|
||||
}
|
||||
return sprite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an atlas sprite from the cache
|
||||
* @param {string} key
|
||||
* @returns {AtlasSprite}
|
||||
*/
|
||||
getSprite(key) {
|
||||
const sprite = this.getSpriteInternal(key);
|
||||
assert(sprite instanceof AtlasSprite || sprite === this.spriteNotFoundSprite, "Not an atlas sprite");
|
||||
return /** @type {AtlasSprite} */ (sprite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retursn a regular sprite from the cache
|
||||
* @param {string} key
|
||||
* @returns {RegularSprite}
|
||||
*/
|
||||
getRegularSprite(key) {
|
||||
const sprite = this.getSpriteInternal(key);
|
||||
assert(
|
||||
sprite instanceof RegularSprite || sprite === this.spriteNotFoundSprite,
|
||||
"Not a regular sprite"
|
||||
);
|
||||
return /** @type {RegularSprite} */ (sprite);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} key
|
||||
* @returns {Promise<HTMLImageElement|null>}
|
||||
*/
|
||||
internalPreloadImage(key) {
|
||||
const url = cachebust("res/" + key);
|
||||
const image = new Image();
|
||||
|
||||
let triesSoFar = 0;
|
||||
|
||||
return Promise.race([
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(reject, G_IS_DEV ? 3000 : 60000);
|
||||
}),
|
||||
|
||||
new Promise(resolve => {
|
||||
image.onload = () => {
|
||||
image.onerror = null;
|
||||
image.onload = null;
|
||||
|
||||
if (typeof image.decode === "function") {
|
||||
// SAFARI: Image.decode() fails on safari with svgs -> we dont want to fail
|
||||
// on that
|
||||
// FIREFOX: Decode never returns if the image is in cache, so call it in background
|
||||
image.decode().then(
|
||||
() => null,
|
||||
() => null
|
||||
);
|
||||
}
|
||||
resolve(image);
|
||||
};
|
||||
|
||||
image.onerror = reason => {
|
||||
logger.warn("Failed to load '" + url + "':", reason);
|
||||
if (++triesSoFar < 4) {
|
||||
logger.log("Retrying to load image from", url);
|
||||
image.src = url + "?try=" + triesSoFar;
|
||||
} else {
|
||||
logger.warn("Failed to load", url, "after", triesSoFar, "tries with reason", reason);
|
||||
image.onerror = null;
|
||||
image.onload = null;
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
image.src = url;
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads a sprite
|
||||
* @param {string} key
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
preloadCSSSprite(key) {
|
||||
return this.internalPreloadImage(key).then(image => {
|
||||
if (key.indexOf("game_misc") >= 0) {
|
||||
// Allow access to regular sprites
|
||||
this.sprites.set(key, new RegularSprite(image, image.width, image.height));
|
||||
}
|
||||
this.rawImages.push(image);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads an atlas
|
||||
* @param {AtlasDefinition} atlas
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
preloadAtlas(atlas) {
|
||||
return this.internalPreloadImage(atlas.getFullSourcePath()).then(image => {
|
||||
// @ts-ignore
|
||||
image.label = atlas.sourceFileName;
|
||||
return this.internalParseAtlas(atlas, image);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {AtlasDefinition} atlas
|
||||
* @param {HTMLImageElement} loadedImage
|
||||
*/
|
||||
internalParseAtlas(atlas, loadedImage) {
|
||||
this.rawImages.push(loadedImage);
|
||||
|
||||
for (const spriteKey in atlas.sourceData) {
|
||||
const spriteData = atlas.sourceData[spriteKey];
|
||||
|
||||
let sprite = /** @type {AtlasSprite} */ (this.sprites.get(spriteKey));
|
||||
|
||||
if (!sprite) {
|
||||
sprite = new AtlasSprite({
|
||||
spriteName: spriteKey,
|
||||
});
|
||||
this.sprites.set(spriteKey, sprite);
|
||||
}
|
||||
|
||||
const link = new SpriteAtlasLink({
|
||||
packedX: spriteData.frame.x,
|
||||
packedY: spriteData.frame.y,
|
||||
packedW: spriteData.frame.w,
|
||||
packedH: spriteData.frame.h,
|
||||
packOffsetX: spriteData.spriteSourceSize.x,
|
||||
packOffsetY: spriteData.spriteSourceSize.y,
|
||||
atlas: loadedImage,
|
||||
w: spriteData.sourceSize.w,
|
||||
h: spriteData.sourceSize.h,
|
||||
});
|
||||
sprite.linksByResolution[atlas.meta.scale] = link;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the links for the sprites after the atlas has been loaded. Used so we
|
||||
* don't have to store duplicate sprites.
|
||||
*/
|
||||
createAtlasLinks() {
|
||||
// NOT USED
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the canvas which shows the question mark, shown when a sprite was not found
|
||||
*/
|
||||
makeSpriteNotFoundCanvas() {
|
||||
const dims = 128;
|
||||
|
||||
const [canvas, context] = makeOffscreenBuffer(dims, dims, {
|
||||
smooth: false,
|
||||
label: "not-found-sprite",
|
||||
});
|
||||
context.fillStyle = "#f77";
|
||||
context.fillRect(0, 0, dims, dims);
|
||||
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillStyle = "#eee";
|
||||
context.font = "25px Arial";
|
||||
context.fillText("???", dims / 2, dims / 2);
|
||||
|
||||
// TODO: Not sure why this is set here
|
||||
// @ts-ignore
|
||||
canvas.src = "not-found";
|
||||
|
||||
const resolutions = ["0.1", "0.25", "0.5", "0.75", "1"];
|
||||
const sprite = new AtlasSprite({
|
||||
spriteName: "not-found",
|
||||
});
|
||||
|
||||
for (let i = 0; i < resolutions.length; ++i) {
|
||||
const res = resolutions[i];
|
||||
const link = new SpriteAtlasLink({
|
||||
packedX: 0,
|
||||
packedY: 0,
|
||||
w: dims,
|
||||
h: dims,
|
||||
packOffsetX: 0,
|
||||
packOffsetY: 0,
|
||||
packedW: dims,
|
||||
packedH: dims,
|
||||
atlas: canvas,
|
||||
});
|
||||
sprite.linksByResolution[res] = link;
|
||||
}
|
||||
this.spriteNotFoundSprite = sprite;
|
||||
}
|
||||
}
|
||||
|
||||
export const Loader = new LoaderImpl();
|
||||
249
src/js/core/logging.js
Normal file
249
src/js/core/logging.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Math_floor, performanceNow } from "./builtins";
|
||||
|
||||
const circularJson = require("circular-json");
|
||||
|
||||
/*
|
||||
Logging functions
|
||||
- To be extended
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base logger class
|
||||
*/
|
||||
class Logger {
|
||||
constructor(context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
debug(...args) {
|
||||
globalDebug(this.context, ...args);
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
globalLog(this.context, ...args);
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
globalWarn(this.context, ...args);
|
||||
}
|
||||
|
||||
error(...args) {
|
||||
globalError(this.context, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(context) {
|
||||
return new Logger(context);
|
||||
}
|
||||
|
||||
function prepareObjectForLogging(obj, maxDepth = 1) {
|
||||
if (!window.Sentry) {
|
||||
// Not required without sentry
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (typeof obj !== "object" && !Array.isArray(obj)) {
|
||||
return obj;
|
||||
}
|
||||
const result = {};
|
||||
for (const key in obj) {
|
||||
const val = obj[key];
|
||||
|
||||
if (typeof val === "object") {
|
||||
if (maxDepth > 0) {
|
||||
result[key] = prepareObjectForLogging(val, maxDepth - 1);
|
||||
} else {
|
||||
result[key] = "[object]";
|
||||
}
|
||||
} else {
|
||||
result[key] = val;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an error
|
||||
* @param {Error|ErrorEvent} err
|
||||
*/
|
||||
export function serializeError(err) {
|
||||
if (!err) {
|
||||
return null;
|
||||
}
|
||||
const result = {
|
||||
type: err.constructor.name,
|
||||
};
|
||||
|
||||
if (err instanceof Error) {
|
||||
result.message = err.message;
|
||||
result.name = err.name;
|
||||
result.stack = err.stack;
|
||||
result.type = "{type.Error}";
|
||||
} else if (err instanceof ErrorEvent) {
|
||||
result.filename = err.filename;
|
||||
result.message = err.message;
|
||||
result.lineno = err.lineno;
|
||||
result.colno = err.colno;
|
||||
result.type = "{type.ErrorEvent}";
|
||||
|
||||
if (err.error) {
|
||||
result.error = serializeError(err.error);
|
||||
} else {
|
||||
result.error = "{not-provided}";
|
||||
}
|
||||
} else {
|
||||
result.type = "{unkown-type:" + typeof err + "}";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an event
|
||||
* @param {Event} event
|
||||
*/
|
||||
function serializeEvent(event) {
|
||||
let result = {
|
||||
type: "{type.Event:" + typeof event + "}",
|
||||
};
|
||||
result.eventType = event.type;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a json payload
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
*/
|
||||
function preparePayload(key, value) {
|
||||
if (value instanceof Error || value instanceof ErrorEvent) {
|
||||
return serializeError(value);
|
||||
}
|
||||
if (value instanceof Event) {
|
||||
return serializeEvent(value);
|
||||
}
|
||||
if (typeof value === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringifies an object containing circular references and errors
|
||||
* @param {any} payload
|
||||
*/
|
||||
export function stringifyObjectContainingErrors(payload) {
|
||||
return circularJson.stringify(payload, preparePayload);
|
||||
}
|
||||
|
||||
export function globalDebug(context, ...args) {
|
||||
if (G_IS_DEV) {
|
||||
logInternal(context, console.log, prepareArgsForLogging(args));
|
||||
}
|
||||
}
|
||||
|
||||
export function globalLog(context, ...args) {
|
||||
// eslint-disable-next-line no-console
|
||||
logInternal(context, console.log, prepareArgsForLogging(args));
|
||||
}
|
||||
|
||||
export function globalWarn(context, ...args) {
|
||||
// eslint-disable-next-line no-console
|
||||
logInternal(context, console.warn, prepareArgsForLogging(args));
|
||||
}
|
||||
|
||||
export function globalError(context, ...args) {
|
||||
args = prepareArgsForLogging(args);
|
||||
// eslint-disable-next-line no-console
|
||||
logInternal(context, console.error, args);
|
||||
|
||||
if (window.Sentry) {
|
||||
window.Sentry.withScope(scope => {
|
||||
scope.setExtra("args", args);
|
||||
window.Sentry.captureMessage(internalBuildStringFromArgs(args), "error");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function prepareArgsForLogging(args) {
|
||||
let result = [];
|
||||
for (let i = 0; i < args.length; ++i) {
|
||||
result.push(prepareObjectForLogging(args[i]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<any>} args
|
||||
*/
|
||||
function internalBuildStringFromArgs(args) {
|
||||
let result = [];
|
||||
|
||||
for (let i = 0; i < args.length; ++i) {
|
||||
let arg = args[i];
|
||||
if (
|
||||
typeof arg === "string" ||
|
||||
typeof arg === "number" ||
|
||||
typeof arg === "boolean" ||
|
||||
arg === null ||
|
||||
arg === undefined
|
||||
) {
|
||||
result.push("" + arg);
|
||||
} else if (arg instanceof Error) {
|
||||
result.push(arg.message);
|
||||
} else {
|
||||
result.push("[object]");
|
||||
}
|
||||
}
|
||||
return result.join(" ");
|
||||
}
|
||||
|
||||
export function logSection(name, color) {
|
||||
while (name.length <= 14) {
|
||||
name = " " + name + " ";
|
||||
}
|
||||
name = name.padEnd(19, " ");
|
||||
|
||||
const lineCss =
|
||||
"letter-spacing: -3px; color: " + color + "; font-size: 6px; background: #eee; color: #eee;";
|
||||
const line = "%c----------------------------";
|
||||
console.log("\n" + line + " %c" + name + " " + line + "\n", lineCss, "color: " + color, lineCss);
|
||||
}
|
||||
|
||||
function extractHandleContext(handle) {
|
||||
let context = handle || "unknown";
|
||||
if (handle && handle.constructor && handle.constructor.name) {
|
||||
context = handle.constructor.name;
|
||||
if (context === "String") {
|
||||
context = handle;
|
||||
}
|
||||
}
|
||||
|
||||
if (handle && handle.name) {
|
||||
context = handle.name;
|
||||
}
|
||||
return context + "";
|
||||
}
|
||||
|
||||
function logInternal(handle, consoleMethod, args) {
|
||||
const context = extractHandleContext(handle).padEnd(20, " ");
|
||||
const labelColor = handle && handle.LOG_LABEL_COLOR ? handle.LOG_LABEL_COLOR : "#aaa";
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.logTimestamps) {
|
||||
const timestamp = "⏱ %c" + (Math_floor(performanceNow()) + "").padEnd(6, " ") + "";
|
||||
consoleMethod.call(
|
||||
console,
|
||||
timestamp + " %c" + context,
|
||||
"color: #7f7;",
|
||||
"color: " + labelColor + ";",
|
||||
...args
|
||||
);
|
||||
} else {
|
||||
if (G_IS_DEV && !globalConfig.debug.disableLoggingLogSources) {
|
||||
consoleMethod.call(console, "%c" + context, "color: " + labelColor, ...args);
|
||||
} else {
|
||||
consoleMethod.call(console, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
493
src/js/core/lzstring.js
Normal file
493
src/js/core/lzstring.js
Normal file
@@ -0,0 +1,493 @@
|
||||
// Copyright (c) 2013 Pieroxy <pieroxy@pieroxy.net>
|
||||
// This work is free. You can redistribute it and/or modify it
|
||||
// under the terms of the WTFPL, Version 2
|
||||
// For more information see LICENSE.txt or http://www.wtfpl.net/
|
||||
//
|
||||
// For more information, the home page:
|
||||
// http://pieroxy.net/blog/pages/lz-string/testing.html
|
||||
//
|
||||
// LZ-based compression algorithm, version 1.4.4
|
||||
|
||||
const fromCharCode = String.fromCharCode;
|
||||
const hasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
|
||||
const keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
|
||||
const baseReverseDic = {};
|
||||
|
||||
function getBaseValue(alphabet, character) {
|
||||
if (!baseReverseDic[alphabet]) {
|
||||
baseReverseDic[alphabet] = {};
|
||||
for (let i = 0; i < alphabet.length; i++) {
|
||||
baseReverseDic[alphabet][alphabet.charAt(i)] = i;
|
||||
}
|
||||
}
|
||||
return baseReverseDic[alphabet][character];
|
||||
}
|
||||
|
||||
//compress into uint8array (UCS-2 big endian format)
|
||||
export function compressU8(uncompressed) {
|
||||
let compressed = compress(uncompressed);
|
||||
let buf = new Uint8Array(compressed.length * 2); // 2 bytes per character
|
||||
|
||||
for (let i = 0, TotalLen = compressed.length; i < TotalLen; i++) {
|
||||
let current_value = compressed.charCodeAt(i);
|
||||
buf[i * 2] = current_value >>> 8;
|
||||
buf[i * 2 + 1] = current_value % 256;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Compreses with header
|
||||
/**
|
||||
* @param {string} uncompressed
|
||||
* @param {number} header
|
||||
*/
|
||||
export function compressU8WHeader(uncompressed, header) {
|
||||
let compressed = compress(uncompressed);
|
||||
let buf = new Uint8Array(2 + compressed.length * 2); // 2 bytes per character
|
||||
|
||||
buf[0] = header >>> 8;
|
||||
buf[1] = header % 256;
|
||||
for (let i = 0, TotalLen = compressed.length; i < TotalLen; i++) {
|
||||
let current_value = compressed.charCodeAt(i);
|
||||
buf[2 + i * 2] = current_value >>> 8;
|
||||
buf[2 + i * 2 + 1] = current_value % 256;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
//decompress from uint8array (UCS-2 big endian format)
|
||||
/**
|
||||
*
|
||||
* @param {Uint8Array} compressed
|
||||
*/
|
||||
export function decompressU8WHeader(compressed) {
|
||||
// let buf = new Array(compressed.length / 2); // 2 bytes per character
|
||||
// for (let i = 0, TotalLen = buf.length; i < TotalLen; i++) {
|
||||
// buf[i] = compressed[i * 2] * 256 + compressed[i * 2 + 1];
|
||||
// }
|
||||
|
||||
// let result = [];
|
||||
// buf.forEach(function (c) {
|
||||
// result.push(fromCharCode(c));
|
||||
// });
|
||||
let result = [];
|
||||
for (let i = 2, n = compressed.length; i < n; i += 2) {
|
||||
const code = compressed[i] * 256 + compressed[i + 1];
|
||||
result.push(fromCharCode(code));
|
||||
}
|
||||
return decompress(result.join(""));
|
||||
}
|
||||
|
||||
//compress into a string that is already URI encoded
|
||||
export function compressX64(input) {
|
||||
if (input == null) return "";
|
||||
return _compress(input, 6, function (a) {
|
||||
return keyStrUriSafe.charAt(a);
|
||||
});
|
||||
}
|
||||
|
||||
//decompress from an output of compressToEncodedURIComponent
|
||||
export function decompressX64(input) {
|
||||
if (input == null) return "";
|
||||
if (input == "") return null;
|
||||
input = input.replace(/ /g, "+");
|
||||
return _decompress(input.length, 32, function (index) {
|
||||
return getBaseValue(keyStrUriSafe, input.charAt(index));
|
||||
});
|
||||
}
|
||||
|
||||
function compress(uncompressed) {
|
||||
return _compress(uncompressed, 16, function (a) {
|
||||
return fromCharCode(a);
|
||||
});
|
||||
}
|
||||
|
||||
function _compress(uncompressed, bitsPerChar, getCharFromInt) {
|
||||
if (uncompressed == null) return "";
|
||||
let i,
|
||||
value,
|
||||
context_dictionary = {},
|
||||
context_dictionaryToCreate = {},
|
||||
context_c = "",
|
||||
context_wc = "",
|
||||
context_w = "",
|
||||
context_enlargeIn = 2, // Compensate for the first entry which should not count
|
||||
context_dictSize = 3,
|
||||
context_numBits = 2,
|
||||
context_data = [],
|
||||
context_data_val = 0,
|
||||
context_data_position = 0,
|
||||
ii;
|
||||
|
||||
for (ii = 0; ii < uncompressed.length; ii += 1) {
|
||||
context_c = uncompressed.charAt(ii);
|
||||
if (!hasOwnProperty.call(context_dictionary, context_c)) {
|
||||
context_dictionary[context_c] = context_dictSize++;
|
||||
context_dictionaryToCreate[context_c] = true;
|
||||
}
|
||||
|
||||
context_wc = context_w + context_c;
|
||||
if (hasOwnProperty.call(context_dictionary, context_wc)) {
|
||||
context_w = context_wc;
|
||||
} else {
|
||||
if (hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
|
||||
if (context_w.charCodeAt(0) < 256) {
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = context_data_val << 1;
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
}
|
||||
value = context_w.charCodeAt(0);
|
||||
for (i = 0; i < 8; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
} else {
|
||||
value = 1;
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = (context_data_val << 1) | value;
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = 0;
|
||||
}
|
||||
value = context_w.charCodeAt(0);
|
||||
for (i = 0; i < 16; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
}
|
||||
context_enlargeIn--;
|
||||
if (context_enlargeIn == 0) {
|
||||
context_enlargeIn = Math.pow(2, context_numBits);
|
||||
context_numBits++;
|
||||
}
|
||||
delete context_dictionaryToCreate[context_w];
|
||||
} else {
|
||||
value = context_dictionary[context_w];
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
}
|
||||
context_enlargeIn--;
|
||||
if (context_enlargeIn == 0) {
|
||||
context_enlargeIn = Math.pow(2, context_numBits);
|
||||
context_numBits++;
|
||||
}
|
||||
// Add wc to the dictionary.
|
||||
context_dictionary[context_wc] = context_dictSize++;
|
||||
context_w = String(context_c);
|
||||
}
|
||||
}
|
||||
|
||||
// Output the code for w.
|
||||
if (context_w !== "") {
|
||||
if (hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
|
||||
if (context_w.charCodeAt(0) < 256) {
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = context_data_val << 1;
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
}
|
||||
value = context_w.charCodeAt(0);
|
||||
for (i = 0; i < 8; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
} else {
|
||||
value = 1;
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = (context_data_val << 1) | value;
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = 0;
|
||||
}
|
||||
value = context_w.charCodeAt(0);
|
||||
for (i = 0; i < 16; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
}
|
||||
context_enlargeIn--;
|
||||
if (context_enlargeIn == 0) {
|
||||
context_enlargeIn = Math.pow(2, context_numBits);
|
||||
context_numBits++;
|
||||
}
|
||||
delete context_dictionaryToCreate[context_w];
|
||||
} else {
|
||||
value = context_dictionary[context_w];
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
}
|
||||
context_enlargeIn--;
|
||||
if (context_enlargeIn == 0) {
|
||||
context_enlargeIn = Math.pow(2, context_numBits);
|
||||
context_numBits++;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the end of the stream
|
||||
value = 2;
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
|
||||
// Flush the last char
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
context_data_val = context_data_val << 1;
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
break;
|
||||
} else context_data_position++;
|
||||
}
|
||||
return context_data.join("");
|
||||
}
|
||||
|
||||
function decompress(compressed) {
|
||||
if (compressed == null) return "";
|
||||
if (compressed == "") return null;
|
||||
return _decompress(compressed.length, 32768, function (index) {
|
||||
return compressed.charCodeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
function _decompress(length, resetValue, getNextValue) {
|
||||
let dictionary = [],
|
||||
next,
|
||||
enlargeIn = 4,
|
||||
dictSize = 4,
|
||||
numBits = 3,
|
||||
entry = "",
|
||||
result = [],
|
||||
i,
|
||||
w,
|
||||
bits,
|
||||
resb,
|
||||
maxpower,
|
||||
power,
|
||||
c,
|
||||
data = { val: getNextValue(0), position: resetValue, index: 1 };
|
||||
|
||||
for (i = 0; i < 3; i += 1) {
|
||||
dictionary[i] = i;
|
||||
}
|
||||
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, 2);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
|
||||
switch ((next = bits)) {
|
||||
case 0:
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, 8);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
c = fromCharCode(bits);
|
||||
break;
|
||||
case 1:
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, 16);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
c = fromCharCode(bits);
|
||||
break;
|
||||
case 2:
|
||||
return "";
|
||||
}
|
||||
dictionary[3] = c;
|
||||
w = c;
|
||||
result.push(c);
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (data.index > length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, numBits);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
|
||||
switch ((c = bits)) {
|
||||
case 0:
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, 8);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
|
||||
dictionary[dictSize++] = fromCharCode(bits);
|
||||
c = dictSize - 1;
|
||||
enlargeIn--;
|
||||
break;
|
||||
case 1:
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, 16);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
dictionary[dictSize++] = fromCharCode(bits);
|
||||
c = dictSize - 1;
|
||||
enlargeIn--;
|
||||
break;
|
||||
case 2:
|
||||
return result.join("");
|
||||
}
|
||||
|
||||
if (enlargeIn == 0) {
|
||||
enlargeIn = Math.pow(2, numBits);
|
||||
numBits++;
|
||||
}
|
||||
|
||||
if (dictionary[c]) {
|
||||
// @ts-ignore
|
||||
entry = dictionary[c];
|
||||
} else {
|
||||
if (c === dictSize) {
|
||||
entry = w + w.charAt(0);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
result.push(entry);
|
||||
|
||||
// Add w+entry[0] to the dictionary.
|
||||
dictionary[dictSize++] = w + entry.charAt(0);
|
||||
enlargeIn--;
|
||||
|
||||
w = entry;
|
||||
|
||||
if (enlargeIn == 0) {
|
||||
enlargeIn = Math.pow(2, numBits);
|
||||
numBits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/js/core/perlin_noise.js
Normal file
175
src/js/core/perlin_noise.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import { perlinNoiseData } from "./perlin_noise_data";
|
||||
import { Math_sqrt } from "./builtins";
|
||||
|
||||
class Grad {
|
||||
constructor(x, y, z) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
dot2(x, y) {
|
||||
return this.x * x + this.y * y;
|
||||
}
|
||||
|
||||
dot3(x, y, z) {
|
||||
return this.x * x + this.y * y + this.z * z;
|
||||
}
|
||||
}
|
||||
|
||||
function fade(t) {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return (1 - t) * a + t * b;
|
||||
}
|
||||
|
||||
const F2 = 0.5 * (Math_sqrt(3) - 1);
|
||||
const G2 = (3 - Math_sqrt(3)) / 6;
|
||||
|
||||
const F3 = 1 / 3;
|
||||
const G3 = 1 / 6;
|
||||
|
||||
export class PerlinNoise {
|
||||
constructor(seed) {
|
||||
this.perm = new Array(512);
|
||||
this.gradP = new Array(512);
|
||||
this.grad3 = [
|
||||
new Grad(1, 1, 0),
|
||||
new Grad(-1, 1, 0),
|
||||
new Grad(1, -1, 0),
|
||||
new Grad(-1, -1, 0),
|
||||
new Grad(1, 0, 1),
|
||||
new Grad(-1, 0, 1),
|
||||
new Grad(1, 0, -1),
|
||||
new Grad(-1, 0, -1),
|
||||
new Grad(0, 1, 1),
|
||||
new Grad(0, -1, 1),
|
||||
new Grad(0, 1, -1),
|
||||
new Grad(0, -1, -1),
|
||||
];
|
||||
|
||||
this.seed = seed;
|
||||
this.initializeFromSeed(seed);
|
||||
}
|
||||
|
||||
initializeFromSeed(seed) {
|
||||
const P = perlinNoiseData;
|
||||
|
||||
if (seed > 0 && seed < 1) {
|
||||
// Scale the seed out
|
||||
seed *= 65536;
|
||||
}
|
||||
|
||||
seed = Math.floor(seed);
|
||||
if (seed < 256) {
|
||||
seed |= seed << 8;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let v;
|
||||
if (i & 1) {
|
||||
v = P[i] ^ (seed & 255);
|
||||
} else {
|
||||
v = P[i] ^ ((seed >> 8) & 255);
|
||||
}
|
||||
|
||||
this.perm[i] = this.perm[i + 256] = v;
|
||||
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2d Perlin Noise
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {number}
|
||||
*/
|
||||
computePerlin2(x, y) {
|
||||
// Find unit grid cell containing point
|
||||
let X = Math.floor(x),
|
||||
Y = Math.floor(y);
|
||||
|
||||
// Get relative xy coordinates of point within that cell
|
||||
x = x - X;
|
||||
y = y - Y;
|
||||
|
||||
// Wrap the integer cells at 255 (smaller integer period can be introduced here)
|
||||
X = X & 255;
|
||||
Y = Y & 255;
|
||||
|
||||
// Calculate noise contributions from each of the four corners
|
||||
let n00 = this.gradP[X + this.perm[Y]].dot2(x, y);
|
||||
let n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1);
|
||||
let n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y);
|
||||
let n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1);
|
||||
|
||||
// Compute the fade curve value for x
|
||||
let u = fade(x);
|
||||
|
||||
// Interpolate the four results
|
||||
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), fade(y));
|
||||
}
|
||||
|
||||
computeSimplex2(xin, yin) {
|
||||
var n0, n1, n2; // Noise contributions from the three corners
|
||||
// Skew the input space to determine which simplex cell we're in
|
||||
var s = (xin + yin) * F2; // Hairy factor for 2D
|
||||
var i = Math.floor(xin + s);
|
||||
var j = Math.floor(yin + s);
|
||||
var t = (i + j) * G2;
|
||||
var x0 = xin - i + t; // The x,y distances from the cell origin, unskewed.
|
||||
var y0 = yin - j + t;
|
||||
// For the 2D case, the simplex shape is an equilateral triangle.
|
||||
// Determine which simplex we are in.
|
||||
var i1, j1; // Offsets for second (middle) corner of simplex in (i,j) coords
|
||||
if (x0 > y0) {
|
||||
// lower triangle, XY order: (0,0)->(1,0)->(1,1)
|
||||
i1 = 1;
|
||||
j1 = 0;
|
||||
} else {
|
||||
// upper triangle, YX order: (0,0)->(0,1)->(1,1)
|
||||
i1 = 0;
|
||||
j1 = 1;
|
||||
}
|
||||
// A step of (1,0) in (i,j) means a step of (1-c,-c) in (x,y), and
|
||||
// a step of (0,1) in (i,j) means a step of (-c,1-c) in (x,y), where
|
||||
// c = (3-sqrt(3))/6
|
||||
var x1 = x0 - i1 + G2; // Offsets for middle corner in (x,y) unskewed coords
|
||||
var y1 = y0 - j1 + G2;
|
||||
var x2 = x0 - 1 + 2 * G2; // Offsets for last corner in (x,y) unskewed coords
|
||||
var y2 = y0 - 1 + 2 * G2;
|
||||
// Work out the hashed gradient indices of the three simplex corners
|
||||
i &= 255;
|
||||
j &= 255;
|
||||
var gi0 = this.gradP[i + this.perm[j]];
|
||||
var gi1 = this.gradP[i + i1 + this.perm[j + j1]];
|
||||
var gi2 = this.gradP[i + 1 + this.perm[j + 1]];
|
||||
// Calculate the contribution from the three corners
|
||||
var t0 = 0.5 - x0 * x0 - y0 * y0;
|
||||
if (t0 < 0) {
|
||||
n0 = 0;
|
||||
} else {
|
||||
t0 *= t0;
|
||||
n0 = t0 * t0 * gi0.dot2(x0, y0); // (x,y) of grad3 used for 2D gradient
|
||||
}
|
||||
var t1 = 0.5 - x1 * x1 - y1 * y1;
|
||||
if (t1 < 0) {
|
||||
n1 = 0;
|
||||
} else {
|
||||
t1 *= t1;
|
||||
n1 = t1 * t1 * gi1.dot2(x1, y1);
|
||||
}
|
||||
var t2 = 0.5 - x2 * x2 - y2 * y2;
|
||||
if (t2 < 0) {
|
||||
n2 = 0;
|
||||
} else {
|
||||
t2 *= t2;
|
||||
n2 = t2 * t2 * gi2.dot2(x2, y2);
|
||||
}
|
||||
// Add contributions from each corner to get the final noise value.
|
||||
// The result is scaled to return values in the interval [-1,1].
|
||||
return 70 * (n0 + n1 + n2);
|
||||
}
|
||||
}
|
||||
258
src/js/core/perlin_noise_data.js
Normal file
258
src/js/core/perlin_noise_data.js
Normal file
@@ -0,0 +1,258 @@
|
||||
export const perlinNoiseData = [
|
||||
151,
|
||||
160,
|
||||
137,
|
||||
91,
|
||||
90,
|
||||
15,
|
||||
131,
|
||||
13,
|
||||
201,
|
||||
95,
|
||||
96,
|
||||
53,
|
||||
194,
|
||||
233,
|
||||
7,
|
||||
225,
|
||||
140,
|
||||
36,
|
||||
103,
|
||||
30,
|
||||
69,
|
||||
142,
|
||||
8,
|
||||
99,
|
||||
37,
|
||||
240,
|
||||
21,
|
||||
10,
|
||||
23,
|
||||
190,
|
||||
6,
|
||||
148,
|
||||
247,
|
||||
120,
|
||||
234,
|
||||
75,
|
||||
0,
|
||||
26,
|
||||
197,
|
||||
62,
|
||||
94,
|
||||
252,
|
||||
219,
|
||||
203,
|
||||
117,
|
||||
35,
|
||||
11,
|
||||
32,
|
||||
57,
|
||||
177,
|
||||
33,
|
||||
88,
|
||||
237,
|
||||
149,
|
||||
56,
|
||||
87,
|
||||
174,
|
||||
20,
|
||||
125,
|
||||
136,
|
||||
171,
|
||||
168,
|
||||
68,
|
||||
175,
|
||||
74,
|
||||
165,
|
||||
71,
|
||||
134,
|
||||
139,
|
||||
48,
|
||||
27,
|
||||
166,
|
||||
77,
|
||||
146,
|
||||
158,
|
||||
231,
|
||||
83,
|
||||
111,
|
||||
229,
|
||||
122,
|
||||
60,
|
||||
211,
|
||||
133,
|
||||
230,
|
||||
220,
|
||||
105,
|
||||
92,
|
||||
41,
|
||||
55,
|
||||
46,
|
||||
245,
|
||||
40,
|
||||
244,
|
||||
102,
|
||||
143,
|
||||
54,
|
||||
65,
|
||||
25,
|
||||
63,
|
||||
161,
|
||||
1,
|
||||
216,
|
||||
80,
|
||||
73,
|
||||
209,
|
||||
76,
|
||||
132,
|
||||
187,
|
||||
208,
|
||||
89,
|
||||
18,
|
||||
169,
|
||||
200,
|
||||
196,
|
||||
135,
|
||||
130,
|
||||
116,
|
||||
188,
|
||||
159,
|
||||
86,
|
||||
164,
|
||||
100,
|
||||
109,
|
||||
198,
|
||||
173,
|
||||
186,
|
||||
3,
|
||||
64,
|
||||
52,
|
||||
217,
|
||||
226,
|
||||
250,
|
||||
124,
|
||||
123,
|
||||
5,
|
||||
202,
|
||||
38,
|
||||
147,
|
||||
118,
|
||||
126,
|
||||
255,
|
||||
82,
|
||||
85,
|
||||
212,
|
||||
207,
|
||||
206,
|
||||
59,
|
||||
227,
|
||||
47,
|
||||
16,
|
||||
58,
|
||||
17,
|
||||
182,
|
||||
189,
|
||||
28,
|
||||
42,
|
||||
223,
|
||||
183,
|
||||
170,
|
||||
213,
|
||||
119,
|
||||
248,
|
||||
152,
|
||||
2,
|
||||
44,
|
||||
154,
|
||||
163,
|
||||
70,
|
||||
221,
|
||||
153,
|
||||
101,
|
||||
155,
|
||||
167,
|
||||
43,
|
||||
172,
|
||||
9,
|
||||
129,
|
||||
22,
|
||||
39,
|
||||
253,
|
||||
19,
|
||||
98,
|
||||
108,
|
||||
110,
|
||||
79,
|
||||
113,
|
||||
224,
|
||||
232,
|
||||
178,
|
||||
185,
|
||||
112,
|
||||
104,
|
||||
218,
|
||||
246,
|
||||
97,
|
||||
228,
|
||||
251,
|
||||
34,
|
||||
242,
|
||||
193,
|
||||
238,
|
||||
210,
|
||||
144,
|
||||
12,
|
||||
191,
|
||||
179,
|
||||
162,
|
||||
241,
|
||||
81,
|
||||
51,
|
||||
145,
|
||||
235,
|
||||
249,
|
||||
14,
|
||||
239,
|
||||
107,
|
||||
49,
|
||||
192,
|
||||
214,
|
||||
31,
|
||||
181,
|
||||
199,
|
||||
106,
|
||||
157,
|
||||
184,
|
||||
84,
|
||||
204,
|
||||
176,
|
||||
115,
|
||||
121,
|
||||
50,
|
||||
45,
|
||||
127,
|
||||
4,
|
||||
150,
|
||||
254,
|
||||
138,
|
||||
236,
|
||||
205,
|
||||
93,
|
||||
222,
|
||||
114,
|
||||
67,
|
||||
29,
|
||||
24,
|
||||
72,
|
||||
243,
|
||||
141,
|
||||
128,
|
||||
195,
|
||||
78,
|
||||
66,
|
||||
215,
|
||||
61,
|
||||
156,
|
||||
180,
|
||||
];
|
||||
69
src/js/core/polyfills.js
Normal file
69
src/js/core/polyfills.js
Normal file
@@ -0,0 +1,69 @@
|
||||
function mathPolyfills() {
|
||||
// Converts from degrees to radians.
|
||||
Math.radians = function (degrees) {
|
||||
return (degrees * Math_PI) / 180.0;
|
||||
};
|
||||
|
||||
// Converts from radians to degrees.
|
||||
Math.degrees = function (radians) {
|
||||
return (radians * 180.0) / Math_PI;
|
||||
};
|
||||
}
|
||||
|
||||
function stringPolyfills() {
|
||||
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart
|
||||
if (!String.prototype.padStart) {
|
||||
String.prototype.padStart = function padStart(targetLength, padString) {
|
||||
targetLength = targetLength >> 0; //truncate if number, or convert non-number to 0;
|
||||
padString = String(typeof padString !== "undefined" ? padString : " ");
|
||||
if (this.length >= targetLength) {
|
||||
return String(this);
|
||||
} else {
|
||||
targetLength = targetLength - this.length;
|
||||
if (targetLength > padString.length) {
|
||||
padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed
|
||||
}
|
||||
return padString.slice(0, targetLength) + String(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padEnd
|
||||
if (!String.prototype.padEnd) {
|
||||
String.prototype.padEnd = function padEnd(targetLength, padString) {
|
||||
targetLength = targetLength >> 0; //floor if number or convert non-number to 0;
|
||||
padString = String(typeof padString !== "undefined" ? padString : " ");
|
||||
if (this.length > targetLength) {
|
||||
return String(this);
|
||||
} else {
|
||||
targetLength = targetLength - this.length;
|
||||
if (targetLength > padString.length) {
|
||||
padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed
|
||||
}
|
||||
return String(this) + padString.slice(0, targetLength);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function initPolyfills() {
|
||||
mathPolyfills();
|
||||
stringPolyfills();
|
||||
}
|
||||
|
||||
function initExtensions() {
|
||||
String.prototype.replaceAll = function (search, replacement) {
|
||||
var target = this;
|
||||
return target.split(search).join(replacement);
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch polyfill
|
||||
import "whatwg-fetch";
|
||||
import { Math_PI } from "./builtins";
|
||||
|
||||
// Other polyfills
|
||||
initPolyfills();
|
||||
initExtensions();
|
||||
10
src/js/core/query_parameters.js
Normal file
10
src/js/core/query_parameters.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const queryString = require("query-string");
|
||||
const options = queryString.parse(location.search);
|
||||
|
||||
export let queryParamOptions = {
|
||||
embedProvider: null,
|
||||
};
|
||||
|
||||
if (options.embed) {
|
||||
queryParamOptions.embedProvider = options.embed;
|
||||
}
|
||||
300
src/js/core/read_write_proxy.js
Normal file
300
src/js/core/read_write_proxy.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { sha1 } from "./sensitive_utils.encrypt";
|
||||
import { createLogger } from "./logging";
|
||||
import { FILE_NOT_FOUND } from "../platform/storage";
|
||||
import { accessNestedPropertyReverse } from "./utils";
|
||||
import { IS_DEBUG, globalConfig } from "./config";
|
||||
import { JSON_stringify, JSON_parse } from "./builtins";
|
||||
import { ExplainedResult } from "./explained_result";
|
||||
import { decompressX64, compressX64 } from ".//lzstring";
|
||||
import { asyncCompressor, compressionPrefix } from "./async_compression";
|
||||
|
||||
const logger = createLogger("read_write_proxy");
|
||||
|
||||
const salt = accessNestedPropertyReverse(globalConfig, ["file", "info"]);
|
||||
|
||||
// Helper which only writes / reads if verify() works. Also performs migration
|
||||
export class ReadWriteProxy {
|
||||
constructor(app, filename) {
|
||||
/** @type {Application} */
|
||||
this.app = app;
|
||||
|
||||
this.filename = filename;
|
||||
|
||||
/** @type {object} */
|
||||
this.currentData = null;
|
||||
|
||||
// TODO: EXTREMELY HACKY! To verify we need to do this a step later
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
setTimeout(() => {
|
||||
assert(
|
||||
this.verify(this.getDefaultData()).result,
|
||||
"Verify() failed for default data: " + this.verify(this.getDefaultData()).reason
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- Methods to override
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
verify(data) {
|
||||
abstract;
|
||||
return ExplainedResult.bad();
|
||||
}
|
||||
|
||||
// Should return the default data
|
||||
getDefaultData() {
|
||||
abstract;
|
||||
return {};
|
||||
}
|
||||
|
||||
// Should return the current version as an integer
|
||||
getCurrentVersion() {
|
||||
abstract;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Should migrate the data (Modify in place)
|
||||
/** @returns {ExplainedResult} */
|
||||
migrate(data) {
|
||||
abstract;
|
||||
return ExplainedResult.bad();
|
||||
}
|
||||
|
||||
// -- / Methods
|
||||
|
||||
// Resets whole data, returns promise
|
||||
resetEverythingAsync() {
|
||||
logger.warn("Reset data to default");
|
||||
this.currentData = this.getDefaultData();
|
||||
return this.writeAsync();
|
||||
}
|
||||
|
||||
getCurrentData() {
|
||||
return this.currentData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the data asychronously, fails if verify() fails
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
writeAsync() {
|
||||
const verifyResult = this.internalVerifyEntry(this.currentData);
|
||||
|
||||
if (!verifyResult.result) {
|
||||
logger.error("Tried to write invalid data to", this.filename, "reason:", verifyResult.reason);
|
||||
return Promise.reject(verifyResult.reason);
|
||||
}
|
||||
const jsonString = JSON_stringify(this.currentData);
|
||||
|
||||
if (!this.app.pageVisible || this.app.unloaded) {
|
||||
logger.log("Saving file sync because in unload handler");
|
||||
const checksum = sha1(jsonString + salt);
|
||||
let compressed = compressionPrefix + compressX64(checksum + jsonString);
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
compressed = jsonString;
|
||||
}
|
||||
|
||||
if (!this.app.storage.writeFileSyncIfSupported(this.filename, compressed)) {
|
||||
return Promise.reject("Failed to write " + this.filename + " sync!");
|
||||
} else {
|
||||
logger.log("📄 Wrote (sync!)", this.filename);
|
||||
return Promise.resolve(compressed);
|
||||
}
|
||||
}
|
||||
|
||||
return asyncCompressor
|
||||
.compressFileAsync(jsonString)
|
||||
.then(compressed => {
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
compressed = jsonString;
|
||||
}
|
||||
return this.app.storage.writeFileAsync(this.filename, compressed);
|
||||
})
|
||||
.then(() => {
|
||||
logger.log("📄 Wrote", this.filename);
|
||||
return jsonString;
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error("Failed to write", this.filename, ":", err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// Reads the data asynchronously, fails if verify() fails
|
||||
readAsync() {
|
||||
// Start read request
|
||||
return (
|
||||
this.app.storage
|
||||
.readFileAsync(this.filename)
|
||||
|
||||
// Check for errors during read
|
||||
.catch(err => {
|
||||
if (err === FILE_NOT_FOUND) {
|
||||
logger.log("File not found, using default data");
|
||||
|
||||
// File not found or unreadable, assume default file
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return Promise.reject("file-error: " + err);
|
||||
})
|
||||
|
||||
// Decrypt data (if its encrypted)
|
||||
// @ts-ignore
|
||||
.then(rawData => {
|
||||
if (rawData == null) {
|
||||
// So, the file has not been found, use default data
|
||||
return JSON_stringify(this.getDefaultData());
|
||||
}
|
||||
|
||||
if (rawData.startsWith(compressionPrefix)) {
|
||||
const decompressed = decompressX64(rawData.substr(compressionPrefix.length));
|
||||
if (!decompressed) {
|
||||
// LZ string decompression failure
|
||||
return Promise.reject("bad-content / decompression-failed");
|
||||
}
|
||||
if (decompressed.length < 40) {
|
||||
// String too short
|
||||
return Promise.reject("bad-content / payload-too-small");
|
||||
}
|
||||
|
||||
// Compare stored checksum with actual checksum
|
||||
const checksum = decompressed.substring(0, 40);
|
||||
const jsonString = decompressed.substr(40);
|
||||
const desiredChecksum = sha1(jsonString + salt);
|
||||
if (desiredChecksum !== checksum) {
|
||||
// Checksum mismatch
|
||||
return Promise.reject("bad-content / checksum-mismatch");
|
||||
}
|
||||
return jsonString;
|
||||
} else {
|
||||
if (!G_IS_DEV) {
|
||||
return Promise.reject("bad-content / missing-compression");
|
||||
}
|
||||
}
|
||||
return rawData;
|
||||
})
|
||||
|
||||
// Parse JSON, this could throw but thats fine
|
||||
.then(res => {
|
||||
try {
|
||||
return JSON_parse(res);
|
||||
} catch (ex) {
|
||||
logger.error(
|
||||
"Failed to parse file content of",
|
||||
this.filename,
|
||||
":",
|
||||
ex,
|
||||
"(content was:",
|
||||
res,
|
||||
")"
|
||||
);
|
||||
throw new Error("invalid-serialized-data");
|
||||
}
|
||||
})
|
||||
|
||||
// Verify basic structure
|
||||
.then(contents => {
|
||||
const result = this.internalVerifyBasicStructure(contents);
|
||||
if (!result.isGood()) {
|
||||
return Promise.reject("verify-failed: " + result.reason);
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Check version and migrate if required
|
||||
.then(contents => {
|
||||
if (contents.version > this.getCurrentVersion()) {
|
||||
return Promise.reject("stored-data-is-newer");
|
||||
}
|
||||
|
||||
if (contents.version < this.getCurrentVersion()) {
|
||||
logger.log(
|
||||
"Trying to migrate data object from version",
|
||||
contents.version,
|
||||
"to",
|
||||
this.getCurrentVersion()
|
||||
);
|
||||
const migrationResult = this.migrate(contents); // modify in place
|
||||
if (migrationResult.isBad()) {
|
||||
return Promise.reject("migration-failed: " + migrationResult.reason);
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Verify
|
||||
.then(contents => {
|
||||
const verifyResult = this.internalVerifyEntry(contents);
|
||||
if (!verifyResult.result) {
|
||||
logger.error(
|
||||
"Read invalid data from",
|
||||
this.filename,
|
||||
"reason:",
|
||||
verifyResult.reason,
|
||||
"contents:",
|
||||
contents
|
||||
);
|
||||
return Promise.reject("invalid-data: " + verifyResult.reason);
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Store
|
||||
.then(contents => {
|
||||
this.currentData = contents;
|
||||
logger.log("📄 Read data with version", this.currentData.version, "from", this.filename);
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Catchall
|
||||
.catch(err => {
|
||||
return Promise.reject("Failed to read " + this.filename + ": " + err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
deleteAsync() {
|
||||
return this.app.storage.deleteFileAsync(this.filename);
|
||||
}
|
||||
|
||||
// Internal
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
internalVerifyBasicStructure(data) {
|
||||
if (!data) {
|
||||
return ExplainedResult.bad("Data is empty");
|
||||
}
|
||||
if (!Number.isInteger(data.version) || data.version < 0) {
|
||||
return ExplainedResult.bad(
|
||||
`Data has invalid version: ${data.version} (expected ${this.getCurrentVersion()})`
|
||||
);
|
||||
}
|
||||
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
internalVerifyEntry(data) {
|
||||
if (data.version !== this.getCurrentVersion()) {
|
||||
return ExplainedResult.bad(
|
||||
"Version mismatch, got " + data.version + " and expected " + this.getCurrentVersion()
|
||||
);
|
||||
}
|
||||
|
||||
const verifyStructureError = this.internalVerifyBasicStructure(data);
|
||||
if (!verifyStructureError.isGood()) {
|
||||
return verifyStructureError;
|
||||
}
|
||||
return this.verify(data);
|
||||
}
|
||||
}
|
||||
287
src/js/core/rectangle.js
Normal file
287
src/js/core/rectangle.js
Normal file
@@ -0,0 +1,287 @@
|
||||
import { globalConfig } from "./config";
|
||||
import { Math_ceil, Math_floor, Math_max, Math_min } from "./builtins";
|
||||
import { clamp, epsilonCompare, round2Digits } from "./utils";
|
||||
import { Vector } from "./vector";
|
||||
|
||||
export class Rectangle {
|
||||
constructor(x = 0, y = 0, w = 0, h = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a rectangle from top right bottom and left offsets
|
||||
* @param {number} top
|
||||
* @param {number} right
|
||||
* @param {number} bottom
|
||||
* @param {number} left
|
||||
*/
|
||||
static fromTRBL(top, right, bottom, left) {
|
||||
return new Rectangle(left, top, right - left, bottom - top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new square rectangle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} size
|
||||
*/
|
||||
static fromSquare(x, y, size) {
|
||||
return new Rectangle(x, y, size, size);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Vector} p1
|
||||
* @param {Vector} p2
|
||||
*/
|
||||
static fromTwoPoints(p1, p2) {
|
||||
const left = Math_min(p1.x, p2.x);
|
||||
const top = Math_min(p1.y, p2.y);
|
||||
const right = Math_max(p1.x, p2.x);
|
||||
const bottom = Math_max(p1.y, p2.y);
|
||||
return new Rectangle(left, top, right - left, bottom - top);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Rectangle} a
|
||||
* @param {Rectangle} b
|
||||
*/
|
||||
static intersects(a, b) {
|
||||
return a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rectangle arround a rotated point
|
||||
* @param {Array<Vector>} points
|
||||
* @param {number} angle
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
static getAroundPointsRotated(points, angle) {
|
||||
let minX = 1e10;
|
||||
let minY = 1e10;
|
||||
let maxX = -1e10;
|
||||
let maxY = -1e10;
|
||||
for (let i = 0; i < points.length; ++i) {
|
||||
const rotated = points[i].rotated(angle);
|
||||
minX = Math_min(minX, rotated.x);
|
||||
minY = Math_min(minY, rotated.y);
|
||||
maxX = Math_max(maxX, rotated.x);
|
||||
maxY = Math_max(maxY, rotated.y);
|
||||
}
|
||||
return new Rectangle(minX, minY, maxX - minX, maxY - minY);
|
||||
}
|
||||
|
||||
// Ensures the rectangle contains the given square
|
||||
extendBySquare(centerX, centerY, halfWidth, halfHeight) {
|
||||
if (this.isEmpty()) {
|
||||
// Just assign values since this rectangle is empty
|
||||
this.x = centerX - halfWidth;
|
||||
this.y = centerY - halfHeight;
|
||||
this.w = halfWidth * 2;
|
||||
this.h = halfHeight * 2;
|
||||
// console.log("Assigned", this.x, this.y, this.w, this.h);
|
||||
} else {
|
||||
// console.log("before", this.x, this.y, this.w, this.h);
|
||||
this.setLeft(Math_min(this.x, centerX - halfWidth));
|
||||
this.setRight(Math_max(this.right(), centerX + halfWidth));
|
||||
this.setTop(Math_min(this.y, centerY - halfHeight));
|
||||
this.setBottom(Math_max(this.bottom(), centerY + halfHeight));
|
||||
// console.log("Extended", this.x, this.y, this.w, this.h);
|
||||
}
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return epsilonCompare(this.w * this.h, 0);
|
||||
}
|
||||
|
||||
equalsEpsilon(other, epsilon) {
|
||||
return (
|
||||
epsilonCompare(this.x, other.x, epsilon) &&
|
||||
epsilonCompare(this.y, other.y, epsilon) &&
|
||||
epsilonCompare(this.w, other.w, epsilon) &&
|
||||
epsilonCompare(this.h, other.h, epsilon)
|
||||
);
|
||||
}
|
||||
|
||||
left() {
|
||||
return this.x;
|
||||
}
|
||||
|
||||
right() {
|
||||
return this.x + this.w;
|
||||
}
|
||||
|
||||
top() {
|
||||
return this.y;
|
||||
}
|
||||
|
||||
bottom() {
|
||||
return this.y + this.h;
|
||||
}
|
||||
|
||||
trbl() {
|
||||
return [this.y, this.right(), this.bottom(), this.x];
|
||||
}
|
||||
|
||||
getCenter() {
|
||||
return new Vector(this.x + this.w / 2, this.y + this.h / 2);
|
||||
}
|
||||
|
||||
setRight(right) {
|
||||
this.w = right - this.x;
|
||||
}
|
||||
|
||||
setBottom(bottom) {
|
||||
this.h = bottom - this.y;
|
||||
}
|
||||
|
||||
// Sets top while keeping bottom
|
||||
setTop(top) {
|
||||
const bottom = this.bottom();
|
||||
this.y = top;
|
||||
this.setBottom(bottom);
|
||||
}
|
||||
|
||||
// Sets left while keeping right
|
||||
setLeft(left) {
|
||||
const right = this.right();
|
||||
this.x = left;
|
||||
this.setRight(right);
|
||||
}
|
||||
|
||||
topLeft() {
|
||||
return new Vector(this.x, this.y);
|
||||
}
|
||||
|
||||
bottomRight() {
|
||||
return new Vector(this.right(), this.bottom());
|
||||
}
|
||||
|
||||
moveBy(x, y) {
|
||||
this.x += x;
|
||||
this.y += y;
|
||||
}
|
||||
|
||||
moveByVector(vec) {
|
||||
this.x += vec.x;
|
||||
this.y += vec.y;
|
||||
}
|
||||
|
||||
// Returns a scaled version which also scales the position of the rectangle
|
||||
allScaled(factor) {
|
||||
return new Rectangle(this.x * factor, this.y * factor, this.w * factor, this.h * factor);
|
||||
}
|
||||
|
||||
// Increases the rectangle in all directions
|
||||
expandInAllDirections(amount) {
|
||||
this.x -= amount;
|
||||
this.y -= amount;
|
||||
this.w += 2 * amount;
|
||||
this.h += 2 * amount;
|
||||
return this;
|
||||
}
|
||||
|
||||
// Culling helpers
|
||||
getMinStartTile() {
|
||||
return new Vector(this.x, this.y).snapWorldToTile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the given rectangle is contained
|
||||
* @param {Rectangle} rect
|
||||
* @returns {boolean}
|
||||
*/
|
||||
containsRect(rect) {
|
||||
return (
|
||||
this.x <= rect.right() &&
|
||||
rect.x <= this.right() &&
|
||||
this.y <= rect.bottom() &&
|
||||
rect.y <= this.bottom()
|
||||
);
|
||||
}
|
||||
|
||||
containsRect4Params(x, y, w, h) {
|
||||
return this.x <= x + w && x <= this.right() && this.y <= y + h && y <= this.bottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the rectangle contains the given circle at (x, y) with the radius (radius)
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} radius
|
||||
*/
|
||||
containsCircle(x, y, radius) {
|
||||
return (
|
||||
this.x <= x + radius &&
|
||||
x - radius <= this.right() &&
|
||||
this.y <= y + radius &&
|
||||
y - radius <= this.bottom()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if hte rectangle contains the given point
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
containsPoint(x, y) {
|
||||
return x >= this.x && x < this.right() && y >= this.y && y < this.bottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shared area with another rectangle, or null if there is no intersection
|
||||
* @param {Rectangle} rect
|
||||
* @returns {Rectangle|null}
|
||||
*/
|
||||
getUnion(rect) {
|
||||
const left = Math_max(this.x, rect.x);
|
||||
const top = Math_max(this.y, rect.y);
|
||||
|
||||
const right = Math_min(this.x + this.w, rect.x + rect.w);
|
||||
const bottom = Math_min(this.y + this.h, rect.y + rect.h);
|
||||
|
||||
if (right <= left || bottom <= top) {
|
||||
return null;
|
||||
}
|
||||
return Rectangle.fromTRBL(top, right, bottom, left);
|
||||
}
|
||||
|
||||
/**
|
||||
* Good for caching stuff
|
||||
*/
|
||||
toCompareableString() {
|
||||
return (
|
||||
round2Digits(this.x) +
|
||||
"/" +
|
||||
round2Digits(this.y) +
|
||||
"/" +
|
||||
round2Digits(this.w) +
|
||||
"/" +
|
||||
round2Digits(this.h)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new recangle in tile space which includes all tiles which are visible in this rect
|
||||
* @param {boolean=} includeHalfTiles
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
toTileCullRectangle(includeHalfTiles = true) {
|
||||
let scaled = this.allScaled(1.0 / globalConfig.tileSize);
|
||||
|
||||
if (includeHalfTiles) {
|
||||
// Increase rectangle size
|
||||
scaled = Rectangle.fromTRBL(
|
||||
Math_floor(scaled.y),
|
||||
Math_ceil(scaled.right()),
|
||||
Math_ceil(scaled.bottom()),
|
||||
Math_floor(scaled.x)
|
||||
);
|
||||
}
|
||||
|
||||
return scaled;
|
||||
}
|
||||
}
|
||||
72
src/js/core/request_channel.js
Normal file
72
src/js/core/request_channel.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createLogger } from "../core/logging";
|
||||
import { fastArrayDeleteValueIfContained } from "../core/utils";
|
||||
|
||||
const logger = createLogger("request_channel");
|
||||
|
||||
// Thrown when a request is aborted
|
||||
export const PROMISE_ABORTED = "promise-aborted";
|
||||
|
||||
export class RequestChannel {
|
||||
constructor() {
|
||||
/** @type {Array<Promise>} */
|
||||
this.pendingPromises = [];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Promise<any>} promise
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
watch(promise) {
|
||||
// log(this, "Added new promise:", promise, "(pending =", this.pendingPromises.length, ")");
|
||||
let cancelled = false;
|
||||
const wrappedPromise = new Promise((resolve, reject) => {
|
||||
promise.then(
|
||||
result => {
|
||||
// Remove from pending promises
|
||||
fastArrayDeleteValueIfContained(this.pendingPromises, wrappedPromise);
|
||||
|
||||
// If not cancelled, resolve promise with same payload
|
||||
if (!cancelled) {
|
||||
resolve.call(this, result);
|
||||
} else {
|
||||
logger.warn("Not resolving because promise got cancelled");
|
||||
// reject.call(this, PROMISE_ABORTED);
|
||||
}
|
||||
},
|
||||
err => {
|
||||
// Remove from pending promises
|
||||
fastArrayDeleteValueIfContained(this.pendingPromises, wrappedPromise);
|
||||
|
||||
// If not cancelled, reject promise with same payload
|
||||
if (!cancelled) {
|
||||
reject.call(this, err);
|
||||
} else {
|
||||
logger.warn("Not rejecting because promise got cancelled");
|
||||
// reject.call(this, PROMISE_ABORTED);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Add cancel handler
|
||||
// @ts-ignore
|
||||
wrappedPromise.cancel = function () {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
this.pendingPromises.push(wrappedPromise);
|
||||
return wrappedPromise;
|
||||
}
|
||||
|
||||
cancelAll() {
|
||||
if (this.pendingPromises.length > 0) {
|
||||
logger.log("Cancel all pending promises (", this.pendingPromises.length, ")");
|
||||
}
|
||||
for (let i = 0; i < this.pendingPromises.length; ++i) {
|
||||
// @ts-ignore
|
||||
this.pendingPromises[i].cancel();
|
||||
}
|
||||
this.pendingPromises = [];
|
||||
}
|
||||
}
|
||||
133
src/js/core/rng.js
Normal file
133
src/js/core/rng.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Math_random } from "./builtins";
|
||||
|
||||
// ALEA RNG
|
||||
|
||||
function Mash() {
|
||||
var n = 0xefc8249d;
|
||||
return function (data) {
|
||||
data = data.toString();
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
n += data.charCodeAt(i);
|
||||
var h = 0.02519603282416938 * n;
|
||||
n = h >>> 0;
|
||||
h -= n;
|
||||
h *= n;
|
||||
n = h >>> 0;
|
||||
h -= n;
|
||||
n += h * 0x100000000; // 2^32
|
||||
}
|
||||
return (n >>> 0) * 2.3283064365386963e-10; // 2^-32
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|string} seed
|
||||
*/
|
||||
function makeNewRng(seed) {
|
||||
// Johannes Baagøe <baagoe@baagoe.com>, 2010
|
||||
var c = 1;
|
||||
var mash = Mash();
|
||||
let s0 = mash(" ");
|
||||
let s1 = mash(" ");
|
||||
let s2 = mash(" ");
|
||||
|
||||
s0 -= mash(seed);
|
||||
if (s0 < 0) {
|
||||
s0 += 1;
|
||||
}
|
||||
s1 -= mash(seed);
|
||||
if (s1 < 0) {
|
||||
s1 += 1;
|
||||
}
|
||||
s2 -= mash(seed);
|
||||
if (s2 < 0) {
|
||||
s2 += 1;
|
||||
}
|
||||
mash = null;
|
||||
|
||||
var random = function () {
|
||||
var t = 2091639 * s0 + c * 2.3283064365386963e-10; // 2^-32
|
||||
s0 = s1;
|
||||
s1 = s2;
|
||||
return (s2 = t - (c = t | 0));
|
||||
};
|
||||
|
||||
random.exportState = function () {
|
||||
return [s0, s1, s2, c];
|
||||
};
|
||||
|
||||
random.importState = function (i) {
|
||||
s0 = +i[0] || 0;
|
||||
s1 = +i[1] || 0;
|
||||
s2 = +i[2] || 0;
|
||||
c = +i[3] || 0;
|
||||
};
|
||||
|
||||
return random;
|
||||
}
|
||||
|
||||
export class RandomNumberGenerator {
|
||||
/**
|
||||
*
|
||||
* @param {number|string=} seed
|
||||
*/
|
||||
constructor(seed) {
|
||||
this.internalRng = makeNewRng(seed || Math_random());
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seeds the generator
|
||||
* @param {number|string} seed
|
||||
*/
|
||||
reseed(seed) {
|
||||
this.internalRng = makeNewRng(seed || Math_random());
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number} between 0 and 1
|
||||
*/
|
||||
next() {
|
||||
return this.internalRng();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number} Integer in range [min, max[
|
||||
*/
|
||||
nextIntRange(min, max) {
|
||||
assert(Number.isFinite(min), "Minimum is no integer");
|
||||
assert(Number.isFinite(max), "Maximum is no integer");
|
||||
assert(max > min, "rng: max <= min");
|
||||
return Math.floor(this.next() * (max - min) + min);
|
||||
}
|
||||
/**
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number} Integer in range [min, max]
|
||||
*/
|
||||
nextIntRangeInclusive(min, max) {
|
||||
assert(Number.isFinite(min), "Minimum is no integer");
|
||||
assert(Number.isFinite(max), "Maximum is no integer");
|
||||
assert(max > min, "rng: max <= min");
|
||||
return Math.round(this.next() * (max - min) + min);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number} Number in range [min, max[
|
||||
*/
|
||||
nextRange(min, max) {
|
||||
assert(max > min, "rng: max <= min");
|
||||
return this.next() * (max - min) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the seed
|
||||
* @param {number} seed
|
||||
*/
|
||||
setSeed(seed) {
|
||||
this.internalRng = makeNewRng(seed);
|
||||
}
|
||||
}
|
||||
62
src/js/core/sensitive_utils.encrypt.js
Normal file
62
src/js/core/sensitive_utils.encrypt.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { globalConfig } from "./config";
|
||||
import { decompressX64, compressX64 } from "./lzstring";
|
||||
|
||||
const Rusha = require("rusha");
|
||||
|
||||
const encryptKey = globalConfig.info.sgSalt;
|
||||
|
||||
export function decodeHashedString(s) {
|
||||
return decompressX64(s);
|
||||
}
|
||||
|
||||
export function sha1(str) {
|
||||
return Rusha.createHash().update(str).digest("hex");
|
||||
}
|
||||
|
||||
// Window.location.host
|
||||
export function getNameOfProvider() {
|
||||
return window[decodeHashedString("DYewxghgLgliB2Q")][decodeHashedString("BYewzgLgdghgtgUyA")];
|
||||
}
|
||||
|
||||
export function compressWithChecksum(object) {
|
||||
const stringified = JSON.stringify(object);
|
||||
const checksum = Rusha.createHash()
|
||||
.update(stringified + encryptKey)
|
||||
.digest("hex");
|
||||
return compressX64(checksum + stringified);
|
||||
}
|
||||
|
||||
export function decompressWithChecksum(binary) {
|
||||
let decompressed = null;
|
||||
try {
|
||||
decompressed = decompressX64(binary);
|
||||
} catch (err) {
|
||||
throw new Error("failed-to-decompress");
|
||||
}
|
||||
|
||||
// Split into checksum and content
|
||||
if (!decompressed || decompressed.length < 41) {
|
||||
throw new Error("checksum-missing");
|
||||
}
|
||||
|
||||
const checksum = decompressed.substr(0, 40);
|
||||
const rawData = decompressed.substr(40);
|
||||
|
||||
// Validate checksum
|
||||
const computedChecksum = Rusha.createHash()
|
||||
.update(rawData + encryptKey)
|
||||
.digest("hex");
|
||||
if (computedChecksum !== checksum) {
|
||||
throw new Error("checksum-mismatch");
|
||||
}
|
||||
|
||||
// Try parsing the JSON
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
} catch (err) {
|
||||
throw new Error("failed-to-parse");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
66
src/js/core/signal.js
Normal file
66
src/js/core/signal.js
Normal file
@@ -0,0 +1,66 @@
|
||||
export const STOP_PROPAGATION = "stop_propagation";
|
||||
|
||||
export class Signal {
|
||||
constructor() {
|
||||
this.receivers = [];
|
||||
this.modifyCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new signal listener
|
||||
* @param {object} receiver
|
||||
* @param {object} scope
|
||||
*/
|
||||
add(receiver, scope = null) {
|
||||
assert(receiver, "receiver is null");
|
||||
this.receivers.push({ receiver, scope });
|
||||
++this.modifyCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the signal
|
||||
* @param {...any} payload
|
||||
*/
|
||||
dispatch() {
|
||||
const modifyState = this.modifyCount;
|
||||
|
||||
const n = this.receivers.length;
|
||||
for (let i = 0; i < n; ++i) {
|
||||
const { receiver, scope } = this.receivers[i];
|
||||
if (receiver.apply(scope, arguments) === STOP_PROPAGATION) {
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
|
||||
if (modifyState !== this.modifyCount) {
|
||||
// Signal got modified during iteration
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a receiver
|
||||
* @param {object} receiver
|
||||
*/
|
||||
remove(receiver) {
|
||||
let index = null;
|
||||
const n = this.receivers.length;
|
||||
for (let i = 0; i < n; ++i) {
|
||||
if (this.receivers[i].receiver === receiver) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert(index !== null, "Receiver not found in list");
|
||||
this.receivers.splice(index, 1);
|
||||
++this.modifyCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all receivers
|
||||
*/
|
||||
removeAll() {
|
||||
this.receivers = [];
|
||||
++this.modifyCount;
|
||||
}
|
||||
}
|
||||
78
src/js/core/singleton_factory.js
Normal file
78
src/js/core/singleton_factory.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// simple factory pattern
|
||||
export class SingletonFactory {
|
||||
constructor() {
|
||||
// Store array as well as dictionary, to speed up lookups
|
||||
this.entries = [];
|
||||
this.idToEntry = {};
|
||||
}
|
||||
|
||||
register(classHandle) {
|
||||
// First, construct instance
|
||||
const instance = new classHandle();
|
||||
|
||||
// Extract id
|
||||
const id = instance.getId();
|
||||
assert(id, "Factory: Invalid id for class " + classHandle.name + ": " + id);
|
||||
|
||||
// Check duplicates
|
||||
assert(!this.idToEntry[id], "Duplicate factory entry for " + id);
|
||||
|
||||
// Insert
|
||||
this.entries.push(instance);
|
||||
this.idToEntry[id] = instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given id is registered
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasId(id) {
|
||||
return !!this.idToEntry[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an instance by a given id
|
||||
* @param {string} id
|
||||
* @returns {object}
|
||||
*/
|
||||
findById(id) {
|
||||
const entry = this.idToEntry[id];
|
||||
if (!entry) {
|
||||
assert(false, "Factory: Object with id '" + id + "' is not registered!");
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an instance by its constructor (The class handle)
|
||||
* @param {object} classHandle
|
||||
* @returns {object}
|
||||
*/
|
||||
findByClass(classHandle) {
|
||||
for (let i = 0; i < this.entries.length; ++i) {
|
||||
if (this.entries[i] instanceof classHandle) {
|
||||
return this.entries[i];
|
||||
}
|
||||
}
|
||||
assert(false, "Factory: Object not found by classHandle (classid: " + classHandle.name + ")");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all entries
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getEntries() {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns amount of stored entries
|
||||
* @returns {number}
|
||||
*/
|
||||
getNumEntries() {
|
||||
return this.entries.length;
|
||||
}
|
||||
}
|
||||
351
src/js/core/sprites.js
Normal file
351
src/js/core/sprites.js
Normal file
@@ -0,0 +1,351 @@
|
||||
import { DrawParameters } from "./draw_parameters";
|
||||
import { Math_floor } from "./builtins";
|
||||
import { Rectangle } from "./rectangle";
|
||||
import { epsilonCompare, round3Digits } from "./utils";
|
||||
|
||||
const floorSpriteCoordinates = false;
|
||||
|
||||
const ORIGINAL_SCALE = "1";
|
||||
|
||||
export class BaseSprite {
|
||||
/**
|
||||
* Returns the raw handle
|
||||
* @returns {HTMLImageElement|HTMLCanvasElement}
|
||||
*/
|
||||
getRawTexture() {
|
||||
abstract;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sprite
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
draw(context, x, y, w, h) {
|
||||
// eslint-disable-line no-unused-vars
|
||||
abstract;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position of a sprite within an atlas
|
||||
*/
|
||||
export class SpriteAtlasLink {
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {number} param0.packedX
|
||||
* @param {number} param0.packedY
|
||||
* @param {number} param0.packOffsetX
|
||||
* @param {number} param0.packOffsetY
|
||||
* @param {number} param0.packedW
|
||||
* @param {number} param0.packedH
|
||||
* @param {number} param0.w
|
||||
* @param {number} param0.h
|
||||
* @param {HTMLImageElement|HTMLCanvasElement} param0.atlas
|
||||
*/
|
||||
constructor({ w, h, packedX, packedY, packOffsetX, packOffsetY, packedW, packedH, atlas }) {
|
||||
this.packedX = packedX;
|
||||
this.packedY = packedY;
|
||||
this.packedW = packedW;
|
||||
this.packedH = packedH;
|
||||
this.packOffsetX = packOffsetX;
|
||||
this.packOffsetY = packOffsetY;
|
||||
this.atlas = atlas;
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
}
|
||||
}
|
||||
|
||||
export class AtlasSprite extends BaseSprite {
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {string} param0.spriteName
|
||||
*/
|
||||
constructor({ spriteName = "sprite" }) {
|
||||
super();
|
||||
/** @type {Object.<string, SpriteAtlasLink>} */
|
||||
this.linksByResolution = {};
|
||||
this.spriteName = spriteName;
|
||||
}
|
||||
|
||||
getRawTexture() {
|
||||
return this.linksByResolution[ORIGINAL_SCALE].atlas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sprite onto a regular context using no contexts
|
||||
* @see {BaseSprite.draw}
|
||||
*/
|
||||
draw(context, x, y, w, h) {
|
||||
if (G_IS_DEV) {
|
||||
assert(context instanceof CanvasRenderingContext2D, "Not a valid context");
|
||||
}
|
||||
console.warn("drawing sprite regulary");
|
||||
|
||||
const link = this.linksByResolution[ORIGINAL_SCALE];
|
||||
|
||||
const width = w || link.w;
|
||||
const height = h || link.h;
|
||||
|
||||
const scaleW = width / link.w;
|
||||
const scaleH = height / link.h;
|
||||
|
||||
context.drawImage(
|
||||
link.atlas,
|
||||
|
||||
link.packedX,
|
||||
link.packedY,
|
||||
link.packedW,
|
||||
link.packedH,
|
||||
|
||||
x + link.packOffsetX * scaleW,
|
||||
y + link.packOffsetY * scaleH,
|
||||
link.packedW * scaleW,
|
||||
link.packedH * scaleH
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} size
|
||||
* @param {boolean=} clipping
|
||||
*/
|
||||
drawCachedCentered(parameters, x, y, size, clipping = true) {
|
||||
this.drawCached(parameters, x - size / 2, y - size / 2, size, size, clipping);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} size
|
||||
*/
|
||||
drawCentered(context, x, y, size) {
|
||||
this.draw(context, x - size / 2, y - size / 2, size, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sprite
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {boolean=} clipping Whether to perform culling
|
||||
*/
|
||||
drawCached(parameters, x, y, w = null, h = null, clipping = true) {
|
||||
if (G_IS_DEV) {
|
||||
assertAlways(parameters instanceof DrawParameters, "Not a valid context");
|
||||
assertAlways(!!w && w > 0, "Not a valid width:" + w);
|
||||
assertAlways(!!h && h > 0, "Not a valid height:" + h);
|
||||
}
|
||||
|
||||
const visibleRect = parameters.visibleRect;
|
||||
|
||||
const scale = parameters.desiredAtlasScale;
|
||||
const link = this.linksByResolution[scale];
|
||||
const scaleW = w / link.w;
|
||||
const scaleH = h / link.h;
|
||||
|
||||
let destX = x + link.packOffsetX * scaleW;
|
||||
let destY = y + link.packOffsetY * scaleH;
|
||||
let destW = link.packedW * scaleW;
|
||||
let destH = link.packedH * scaleH;
|
||||
|
||||
let srcX = link.packedX;
|
||||
let srcY = link.packedY;
|
||||
let srcW = link.packedW;
|
||||
let srcH = link.packedH;
|
||||
|
||||
let intersection = null;
|
||||
|
||||
if (clipping) {
|
||||
const rect = new Rectangle(destX, destY, destW, destH);
|
||||
intersection = rect.getUnion(visibleRect);
|
||||
if (!intersection) {
|
||||
return;
|
||||
}
|
||||
|
||||
srcX += (intersection.x - destX) / scaleW;
|
||||
srcY += (intersection.y - destY) / scaleH;
|
||||
|
||||
srcW *= intersection.w / destW;
|
||||
srcH *= intersection.h / destH;
|
||||
|
||||
destX = intersection.x;
|
||||
destY = intersection.y;
|
||||
|
||||
destW = intersection.w;
|
||||
destH = intersection.h;
|
||||
}
|
||||
|
||||
// assert(epsilonCompare(scaleW, scaleH), "Sprite should be square for cached rendering");
|
||||
|
||||
if (floorSpriteCoordinates) {
|
||||
parameters.context.drawImage(
|
||||
link.atlas,
|
||||
|
||||
// atlas src pos
|
||||
Math_floor(srcX),
|
||||
Math_floor(srcY),
|
||||
|
||||
// atlas src size
|
||||
Math_floor(srcW),
|
||||
Math_floor(srcH),
|
||||
|
||||
// dest pos
|
||||
Math_floor(destX),
|
||||
Math_floor(destY),
|
||||
|
||||
// dest size
|
||||
Math_floor(destW),
|
||||
Math_floor(destH)
|
||||
);
|
||||
} else {
|
||||
parameters.context.drawImage(
|
||||
link.atlas,
|
||||
|
||||
// atlas src pos
|
||||
srcX,
|
||||
srcY,
|
||||
|
||||
// atlas src siize
|
||||
srcW,
|
||||
srcH,
|
||||
|
||||
// dest pos and size
|
||||
destX,
|
||||
destY,
|
||||
destW,
|
||||
destH
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders into an html element
|
||||
* @param {HTMLElement} element
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
renderToHTMLElement(element, w = 1, h = 1) {
|
||||
element.style.position = "relative";
|
||||
element.innerHTML = this.getAsHTML(w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the html to render as icon
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
getAsHTML(w, h) {
|
||||
const link = this.linksByResolution["0.5"];
|
||||
|
||||
// Find out how much we have to scale it so that it fits
|
||||
const scaleX = w / link.w;
|
||||
const scaleY = h / link.h;
|
||||
|
||||
// Find out how big the scaled atlas is
|
||||
const atlasW = link.atlas.width * scaleX;
|
||||
const atlasH = link.atlas.height * scaleY;
|
||||
|
||||
// @ts-ignore
|
||||
const srcSafe = link.atlas.src.replaceAll("\\", "/");
|
||||
|
||||
// Find out how big we render the sprite
|
||||
const widthAbsolute = scaleX * link.packedW;
|
||||
const heightAbsolute = scaleY * link.packedH;
|
||||
|
||||
// Compute the position in the relative container
|
||||
const leftRelative = (link.packOffsetX * scaleX) / w;
|
||||
const topRelative = (link.packOffsetY * scaleY) / h;
|
||||
const widthRelative = widthAbsolute / w;
|
||||
const heightRelative = heightAbsolute / h;
|
||||
|
||||
// Scale the atlas relative to the width and height of the element
|
||||
const bgW = atlasW / widthAbsolute;
|
||||
const bgH = atlasH / heightAbsolute;
|
||||
|
||||
// Figure out what the position of the atlas is
|
||||
const bgX = link.packedX * scaleX;
|
||||
const bgY = link.packedY * scaleY;
|
||||
|
||||
// Fuck you, whoever thought its a good idea to make background-position work like it does now
|
||||
const bgXRelative = -bgX / (widthAbsolute - atlasW);
|
||||
const bgYRelative = -bgY / (heightAbsolute - atlasH);
|
||||
|
||||
return `
|
||||
<span class="spritesheetImage" style="
|
||||
background-image: url('${srcSafe}');
|
||||
left: ${round3Digits(leftRelative * 100.0)}%;
|
||||
top: ${round3Digits(topRelative * 100.0)}%;
|
||||
width: ${round3Digits(widthRelative * 100.0)}%;
|
||||
height: ${round3Digits(heightRelative * 100.0)}%;
|
||||
background-repeat: repeat;
|
||||
background-position: ${round3Digits(bgXRelative * 100.0)}% ${round3Digits(
|
||||
bgYRelative * 100.0
|
||||
)}%;
|
||||
background-size: ${round3Digits(bgW * 100.0)}% ${round3Digits(bgH * 100.0)}%;
|
||||
"></span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export class RegularSprite extends BaseSprite {
|
||||
constructor(sprite, w, h) {
|
||||
super();
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.sprite = sprite;
|
||||
}
|
||||
|
||||
getRawTexture() {
|
||||
return this.sprite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sprite, do *not* use this for sprites which are rendered! Only for drawing
|
||||
* images into buffers
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
draw(context, x, y, w, h) {
|
||||
assert(context, "No context given");
|
||||
assert(x !== undefined, "No x given");
|
||||
assert(y !== undefined, "No y given");
|
||||
assert(w !== undefined, "No width given");
|
||||
assert(h !== undefined, "No height given");
|
||||
context.drawImage(this.sprite, x, y, w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sprite, do *not* use this for sprites which are rendered! Only for drawing
|
||||
* images into buffers
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
drawCentered(context, x, y, w, h) {
|
||||
assert(context, "No context given");
|
||||
assert(x !== undefined, "No x given");
|
||||
assert(y !== undefined, "No y given");
|
||||
assert(w !== undefined, "No width given");
|
||||
assert(h !== undefined, "No height given");
|
||||
context.drawImage(this.sprite, x - w / 2, y - h / 2, w, h);
|
||||
}
|
||||
}
|
||||
121
src/js/core/state_manager.js
Normal file
121
src/js/core/state_manager.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/* typehints:start*/
|
||||
import { Application } from "../application";
|
||||
/* typehints:end*/
|
||||
|
||||
import { GameState } from "./game_state";
|
||||
import { createLogger } from "./logging";
|
||||
import { APPLICATION_ERROR_OCCURED } from "./error_handler";
|
||||
import { waitNextFrame, removeAllChildren } from "./utils";
|
||||
|
||||
const logger = createLogger("state_manager");
|
||||
|
||||
/**
|
||||
* This is the main state machine which drives the game states.
|
||||
*/
|
||||
export class StateManager {
|
||||
/**
|
||||
* @param {Application} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/** @type {GameState} */
|
||||
this.currentState = null;
|
||||
|
||||
/** @type {Object.<string, new() => GameState>} */
|
||||
this.stateClasses = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new state class, should be a GameState derived class
|
||||
* @param {object} stateClass
|
||||
*/
|
||||
register(stateClass) {
|
||||
// Create a dummy to retrieve the key
|
||||
const dummy = new stateClass();
|
||||
assert(dummy instanceof GameState, "Not a state!");
|
||||
const key = dummy.getKey();
|
||||
assert(!this.stateClasses[key], `State '${key}' is already registered!`);
|
||||
this.stateClasses[key] = stateClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new state or returns the instance from the cache
|
||||
* @param {string} key
|
||||
*/
|
||||
constructState(key) {
|
||||
if (this.stateClasses[key]) {
|
||||
return new this.stateClasses[key]();
|
||||
}
|
||||
assert(false, `State '${key}' is not known!`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to a given state
|
||||
* @param {string} key State Key
|
||||
*/
|
||||
moveToState(key, payload = {}) {
|
||||
if (APPLICATION_ERROR_OCCURED) {
|
||||
console.warn("Skipping state transition because of application crash");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentState) {
|
||||
if (key === this.currentState.getKey()) {
|
||||
logger.error(`State '${key}' is already active!`);
|
||||
return false;
|
||||
}
|
||||
this.currentState.internalLeaveCallback();
|
||||
|
||||
// Remove all references
|
||||
for (const stateKey in this.currentState) {
|
||||
if (this.currentState.hasOwnProperty(stateKey)) {
|
||||
delete this.currentState[stateKey];
|
||||
}
|
||||
}
|
||||
this.currentState = null;
|
||||
}
|
||||
|
||||
this.currentState = this.constructState(key);
|
||||
this.currentState.internalRegisterCallback(this, this.app);
|
||||
|
||||
// Clean up old elements
|
||||
removeAllChildren(document.body);
|
||||
|
||||
document.body.className = "gameState " + (this.currentState.getHasFadeIn() ? "" : "arrived");
|
||||
document.body.id = "state_" + key;
|
||||
document.body.innerHTML = this.currentState.internalGetFullHtml();
|
||||
|
||||
const dialogParent = document.createElement("div");
|
||||
dialogParent.classList.add("modalDialogParent");
|
||||
document.body.appendChild(dialogParent);
|
||||
|
||||
this.app.sound.playThemeMusic(this.currentState.getThemeMusic());
|
||||
|
||||
this.currentState.internalEnterCallback(payload);
|
||||
this.currentState.onResized(this.app.screenWidth, this.app.screenHeight);
|
||||
|
||||
this.app.analytics.trackStateEnter(key);
|
||||
|
||||
window.history.pushState(
|
||||
{
|
||||
key,
|
||||
},
|
||||
key
|
||||
);
|
||||
|
||||
waitNextFrame().then(() => {
|
||||
document.body.classList.add("arrived");
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current state
|
||||
* @returns {GameState}
|
||||
*/
|
||||
getCurrentState() {
|
||||
return this.currentState;
|
||||
}
|
||||
}
|
||||
39
src/js/core/tracked_state.js
Normal file
39
src/js/core/tracked_state.js
Normal file
@@ -0,0 +1,39 @@
|
||||
export class TrackedState {
|
||||
constructor(callbackMethod = null, callbackScope = null) {
|
||||
this.lastSeenValue = null;
|
||||
|
||||
if (callbackMethod) {
|
||||
this.callback = callbackMethod;
|
||||
if (callbackScope) {
|
||||
this.callback = this.callback.bind(callbackScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(value, changeHandler = null, changeScope = null) {
|
||||
if (value !== this.lastSeenValue) {
|
||||
// Copy value since the changeHandler call could actually modify our lastSeenValue
|
||||
const valueCopy = value;
|
||||
this.lastSeenValue = value;
|
||||
if (changeHandler) {
|
||||
if (changeScope) {
|
||||
changeHandler.call(changeScope, valueCopy);
|
||||
} else {
|
||||
changeHandler(valueCopy);
|
||||
}
|
||||
} else if (this.callback) {
|
||||
this.callback(value);
|
||||
} else {
|
||||
assert(false, "No callback specified");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSilent(value) {
|
||||
this.lastSeenValue = value;
|
||||
}
|
||||
|
||||
get() {
|
||||
return this.lastSeenValue;
|
||||
}
|
||||
}
|
||||
889
src/js/core/utils.js
Normal file
889
src/js/core/utils.js
Normal file
@@ -0,0 +1,889 @@
|
||||
import { globalConfig, IS_DEBUG } from "./config";
|
||||
import {
|
||||
Math_abs,
|
||||
Math_atan2,
|
||||
Math_ceil,
|
||||
Math_floor,
|
||||
Math_log10,
|
||||
Math_max,
|
||||
Math_min,
|
||||
Math_PI,
|
||||
Math_pow,
|
||||
Math_random,
|
||||
Math_round,
|
||||
Math_sin,
|
||||
performanceNow,
|
||||
} from "./builtins";
|
||||
import { Vector } from "./vector";
|
||||
|
||||
// Constants
|
||||
export const TOP = new Vector(0, -1);
|
||||
export const RIGHT = new Vector(1, 0);
|
||||
export const BOTTOM = new Vector(0, 1);
|
||||
export const LEFT = new Vector(-1, 0);
|
||||
export const ALL_DIRECTIONS = [TOP, RIGHT, BOTTOM, LEFT];
|
||||
|
||||
export const thousand = 1000;
|
||||
export const million = 1000 * 1000;
|
||||
export const billion = 1000 * 1000 * 1000;
|
||||
|
||||
/**
|
||||
* Returns the build id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getBuildId() {
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
return "local-dev";
|
||||
} else if (G_IS_DEV) {
|
||||
return "dev-" + getPlatformName() + "-" + G_BUILD_COMMIT_HASH;
|
||||
} else {
|
||||
return "prod-" + getPlatformName() + "-" + G_BUILD_COMMIT_HASH;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the environment id (dev, prod, etc)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getEnvironmentId() {
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
return "local-dev";
|
||||
} else if (G_IS_DEV) {
|
||||
return "dev-" + getPlatformName();
|
||||
} else if (G_IS_RELEASE) {
|
||||
return "release-" + getPlatformName();
|
||||
} else {
|
||||
return "staging-" + getPlatformName();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this platform is android
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAndroid() {
|
||||
if (!G_IS_MOBILE_APP) {
|
||||
return false;
|
||||
}
|
||||
const platform = window.device.platform;
|
||||
return platform === "Android" || platform === "amazon-fireos";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this platform is iOs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isIos() {
|
||||
if (!G_IS_MOBILE_APP) {
|
||||
return false;
|
||||
}
|
||||
return window.device.platform === "iOS";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a platform name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPlatformName() {
|
||||
if (G_IS_STANDALONE) {
|
||||
return "standalone";
|
||||
} else if (G_IS_BROWSER) {
|
||||
return "browser";
|
||||
} else if (G_IS_MOBILE_APP && isAndroid()) {
|
||||
return "android";
|
||||
} else if (G_IS_MOBILE_APP && isIos()) {
|
||||
return "ios";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IPC renderer, or null if not within the standalone
|
||||
* @returns {object|null}
|
||||
*/
|
||||
let ipcRenderer = null;
|
||||
export function getIPCRenderer() {
|
||||
if (!G_IS_STANDALONE) {
|
||||
return null;
|
||||
}
|
||||
if (!ipcRenderer) {
|
||||
ipcRenderer = eval("require")("electron").ipcRenderer;
|
||||
}
|
||||
return ipcRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a sensitive token by only displaying the first digits of it. Use for
|
||||
* stuff like savegame keys etc which should not appear in the log.
|
||||
* @param {string} key
|
||||
*/
|
||||
export function formatSensitive(key) {
|
||||
if (!key) {
|
||||
return "<null>";
|
||||
}
|
||||
key = key || "";
|
||||
return "[" + key.substr(0, 8) + "...]";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new 2D array with the given fill method
|
||||
* @param {number} w Width
|
||||
* @param {number} h Height
|
||||
* @param {(function(number, number) : any) | number | boolean | string | null | undefined} filler Either Fill method, which should return the content for each cell, or static content
|
||||
* @param {string=} context Optional context for memory tracking
|
||||
* @returns {Array<Array<any>>}
|
||||
*/
|
||||
export function make2DArray(w, h, filler, context = null) {
|
||||
if (typeof filler === "function") {
|
||||
const tiles = new Array(w);
|
||||
for (let x = 0; x < w; ++x) {
|
||||
const row = new Array(h);
|
||||
for (let y = 0; y < h; ++y) {
|
||||
row[y] = filler(x, y);
|
||||
}
|
||||
tiles[x] = row;
|
||||
}
|
||||
return tiles;
|
||||
} else {
|
||||
const tiles = new Array(w);
|
||||
const row = new Array(h);
|
||||
for (let y = 0; y < h; ++y) {
|
||||
row[y] = filler;
|
||||
}
|
||||
|
||||
for (let x = 0; x < w; ++x) {
|
||||
tiles[x] = row.slice();
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a new 2D array with undefined contents
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {string=} context
|
||||
* @returns {Array<Array<any>>}
|
||||
*/
|
||||
export function make2DUndefinedArray(w, h, context = null) {
|
||||
const result = new Array(w);
|
||||
for (let x = 0; x < w; ++x) {
|
||||
result[x] = new Array(h);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a given 2D array with the given fill method
|
||||
* @param {Array<Array<any>>} array
|
||||
* @param {number} w Width
|
||||
* @param {number} h Height
|
||||
* @param {(function(number, number) : any) | number | boolean | string | null | undefined} filler Either Fill method, which should return the content for each cell, or static content
|
||||
*/
|
||||
export function clear2DArray(array, w, h, filler) {
|
||||
assert(array.length === w, "Array dims mismatch w");
|
||||
assert(array[0].length === h, "Array dims mismatch h");
|
||||
if (typeof filler === "function") {
|
||||
for (let x = 0; x < w; ++x) {
|
||||
const row = array[x];
|
||||
for (let y = 0; y < h; ++y) {
|
||||
row[y] = filler(x, y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let x = 0; x < w; ++x) {
|
||||
const row = array[x];
|
||||
for (let y = 0; y < h; ++y) {
|
||||
row[y] = filler;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new map (an empty object without any props)
|
||||
*/
|
||||
export function newEmptyMap() {
|
||||
return Object.create(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random integer in the range [start,end]
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
*/
|
||||
export function randomInt(start, end) {
|
||||
return start + Math_round(Math_random() * (end - start));
|
||||
}
|
||||
|
||||
/**
|
||||
* Access an object in a very annoying way, used for obsfuscation.
|
||||
* @param {any} obj
|
||||
* @param {Array<string>} keys
|
||||
*/
|
||||
export function accessNestedPropertyReverse(obj, keys) {
|
||||
let result = obj;
|
||||
for (let i = keys.length - 1; i >= 0; --i) {
|
||||
result = result[keys[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a random entry of an array
|
||||
* @param {Array | string} arr
|
||||
*/
|
||||
export function randomChoice(arr) {
|
||||
return arr[Math_floor(Math_random() * arr.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes from an array by swapping with the last element
|
||||
* @param {Array<any>} array
|
||||
* @param {number} index
|
||||
*/
|
||||
export function fastArrayDelete(array, index) {
|
||||
if (index < 0 || index >= array.length) {
|
||||
throw new Error("Out of bounds");
|
||||
}
|
||||
// When the element is not the last element
|
||||
if (index !== array.length - 1) {
|
||||
// Get the last element, and swap it with the one we want to delete
|
||||
const last = array[array.length - 1];
|
||||
array[index] = last;
|
||||
}
|
||||
|
||||
// Finally remove the last element
|
||||
array.length -= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes from an array by swapping with the last element. Searches
|
||||
* for the value in the array first
|
||||
* @param {Array<any>} array
|
||||
* @param {any} value
|
||||
*/
|
||||
export function fastArrayDeleteValue(array, value) {
|
||||
if (array == null) {
|
||||
throw new Error("Tried to delete from non array!");
|
||||
}
|
||||
const index = array.indexOf(value);
|
||||
if (index < 0) {
|
||||
console.error("Value", value, "not contained in array:", array, "!");
|
||||
return value;
|
||||
}
|
||||
return fastArrayDelete(array, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see fastArrayDeleteValue
|
||||
* @param {Array<any>} array
|
||||
* @param {any} value
|
||||
*/
|
||||
export function fastArrayDeleteValueIfContained(array, value) {
|
||||
if (array == null) {
|
||||
throw new Error("Tried to delete from non array!");
|
||||
}
|
||||
const index = array.indexOf(value);
|
||||
if (index < 0) {
|
||||
return value;
|
||||
}
|
||||
return fastArrayDelete(array, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes from an array at the given index
|
||||
* @param {Array<any>} array
|
||||
* @param {number} index
|
||||
*/
|
||||
export function arrayDelete(array, index) {
|
||||
if (index < 0 || index >= array.length) {
|
||||
throw new Error("Out of bounds");
|
||||
}
|
||||
array.splice(index, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given value from an array
|
||||
* @param {Array<any>} array
|
||||
* @param {any} value
|
||||
*/
|
||||
export function arrayDeleteValue(array, value) {
|
||||
if (array == null) {
|
||||
throw new Error("Tried to delete from non array!");
|
||||
}
|
||||
const index = array.indexOf(value);
|
||||
if (index < 0) {
|
||||
console.error("Value", value, "not contained in array:", array, "!");
|
||||
return value;
|
||||
}
|
||||
return arrayDelete(array, index);
|
||||
}
|
||||
|
||||
// Converts a direction into a 0 .. 7 index
|
||||
/**
|
||||
* Converts a direction into a index from 0 .. 7, used for miners, zombies etc which have 8 sprites
|
||||
* @param {Vector} offset direction
|
||||
* @param {boolean} inverse if inverse, the direction is reversed
|
||||
* @returns {number} in range [0, 7]
|
||||
*/
|
||||
export function angleToSpriteIndex(offset, inverse = false) {
|
||||
const twoPi = 2.0 * Math_PI;
|
||||
const factor = inverse ? -1 : 1;
|
||||
const offs = inverse ? 2.5 : 3.5;
|
||||
const angle = (factor * Math_atan2(offset.y, offset.x) + offs * Math_PI) % twoPi;
|
||||
|
||||
const index = Math_round((angle / twoPi) * 8) % 8;
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two floats for epsilon equality
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function epsilonCompare(a, b, epsilon = 1e-5) {
|
||||
return Math_abs(a - b) < epsilon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a float for epsilon equal to 0
|
||||
* @param {number} a
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function epsilonIsZero(a) {
|
||||
return epsilonCompare(a, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates two numbers
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @param {number} x Mix factor, 0 means 100% a, 1 means 100%b, rest is interpolated
|
||||
*/
|
||||
export function lerp(a, b, x) {
|
||||
return a * (1 - x) + b * x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a value which is nice to display, e.g. 15669 -> 15000. Also handles fractional stuff
|
||||
* @param {number} num
|
||||
*/
|
||||
export function findNiceValue(num) {
|
||||
if (num > 1e8) {
|
||||
return num;
|
||||
}
|
||||
if (num < 0.00001) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const roundAmount = 0.5 * Math_pow(10, Math_floor(Math_log10(num) - 1));
|
||||
const niceValue = Math_floor(num / roundAmount) * roundAmount;
|
||||
if (num >= 10) {
|
||||
return Math_round(niceValue);
|
||||
}
|
||||
if (num >= 1) {
|
||||
return Math_round(niceValue * 10) / 10;
|
||||
}
|
||||
|
||||
return Math_round(niceValue * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a nice integer value
|
||||
* @see findNiceValue
|
||||
* @param {number} num
|
||||
*/
|
||||
export function findNiceIntegerValue(num) {
|
||||
return Math_ceil(findNiceValue(num));
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart rounding + fractional handling
|
||||
* @param {number} n
|
||||
*/
|
||||
function roundSmart(n) {
|
||||
if (n < 100) {
|
||||
return n.toFixed(1);
|
||||
}
|
||||
return Math_round(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a big number
|
||||
* @param {number} num
|
||||
* @param {string=} divider THe divider for numbers like 50,000 (divider=',')
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatBigNumber(num, divider = ".") {
|
||||
const sign = num < 0 ? "-" : "";
|
||||
num = Math_abs(num);
|
||||
|
||||
if (num > 1e54) {
|
||||
return sign + "inf";
|
||||
}
|
||||
|
||||
if (num < 10 && !Number.isInteger(num)) {
|
||||
return sign + num.toFixed(2);
|
||||
}
|
||||
if (num < 50 && !Number.isInteger(num)) {
|
||||
return sign + num.toFixed(1);
|
||||
}
|
||||
num = Math_floor(num);
|
||||
|
||||
if (num < 1000) {
|
||||
return sign + "" + num;
|
||||
}
|
||||
|
||||
// if (num > 1e51) return sign + T.common.number_format.sedecillion.replace("%amount%", "" + roundSmart(num / 1e51));
|
||||
// if (num > 1e48)
|
||||
// return sign + T.common.number_format.quinquadecillion.replace("%amount%", "" + roundSmart(num / 1e48));
|
||||
// if (num > 1e45)
|
||||
// return sign + T.common.number_format.quattuordecillion.replace("%amount%", "" + roundSmart(num / 1e45));
|
||||
// if (num > 1e42) return sign + T.common.number_format.tredecillion.replace("%amount%", "" + roundSmart(num / 1e42));
|
||||
// if (num > 1e39) return sign + T.common.number_format.duodecillions.replace("%amount%", "" + roundSmart(num / 1e39));
|
||||
// if (num > 1e36) return sign + T.common.number_format.undecillions.replace("%amount%", "" + roundSmart(num / 1e36));
|
||||
// if (num > 1e33) return sign + T.common.number_format.decillions.replace("%amount%", "" + roundSmart(num / 1e33));
|
||||
// if (num > 1e30) return sign + T.common.number_format.nonillions.replace("%amount%", "" + roundSmart(num / 1e30));
|
||||
// if (num > 1e27) return sign + T.common.number_format.octillions.replace("%amount%", "" + roundSmart(num / 1e27));
|
||||
// if (num >= 1e24) return sign + T.common.number_format.septillions.replace("%amount%", "" + roundSmart(num / 1e24));
|
||||
// if (num >= 1e21) return sign + T.common.number_format.sextillions.replace("%amount%", "" + roundSmart(num / 1e21));
|
||||
// if (num >= 1e18) return sign + T.common.number_format.quintillions.replace("%amount%", "" + roundSmart(num / 1e18));
|
||||
// if (num >= 1e15) return sign + T.common.number_format.quantillions.replace("%amount%", "" + roundSmart(num / 1e15));
|
||||
// if (num >= 1e12) return sign + T.common.number_format.trillions.replace("%amount%", "" + roundSmart(num / 1e12));
|
||||
// if (num >= 1e9) return sign + T.common.number_format.billions.replace("%amount%", "" + roundSmart(num / 1e9));
|
||||
// if (num >= 1e6) return sign + T.common.number_format.millions.replace("%amount%", "" + roundSmart(num / 1e6));
|
||||
// if (num > 99999) return sign + T.common.number_format.thousands.replace("%amount%", "" + roundSmart(num / 1e3));
|
||||
|
||||
let rest = num;
|
||||
let out = "";
|
||||
|
||||
while (rest >= 1000) {
|
||||
out = (rest % 1000).toString().padStart(3, "0") + (out !== "" ? divider : "") + out;
|
||||
rest = Math_floor(rest / 1000);
|
||||
}
|
||||
|
||||
out = rest + divider + out;
|
||||
return sign + out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a big number, but does not add any suffix and instead uses its full representation
|
||||
* @param {number} num
|
||||
* @param {string=} divider THe divider for numbers like 50,000 (divider=',')
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatBigNumberFull(num, divider = T.common.number_format.divider_thousands || " ") {
|
||||
if (num < 1000) {
|
||||
return num + "";
|
||||
}
|
||||
if (num > 1e54) {
|
||||
return "infinite";
|
||||
}
|
||||
let rest = num;
|
||||
let out = "";
|
||||
while (rest >= 1000) {
|
||||
out = (rest % 1000).toString().padStart(3, "0") + divider + out;
|
||||
rest = Math_floor(rest / 1000);
|
||||
}
|
||||
out = rest + divider + out;
|
||||
|
||||
return out.substring(0, out.length - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an amount of seconds into something like "5s ago"
|
||||
* @param {number} secs Seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSecondsToTimeAgo(secs) {
|
||||
const seconds = Math_floor(secs);
|
||||
const minutes = Math_floor(seconds / 60);
|
||||
const hours = Math_floor(minutes / 60);
|
||||
const days = Math_floor(hours / 24);
|
||||
|
||||
const trans = T.common.time;
|
||||
|
||||
if (seconds <= 60) {
|
||||
if (seconds <= 1) {
|
||||
return trans.one_second_before;
|
||||
}
|
||||
return trans.seconds_before.replace("%amount%", "" + seconds);
|
||||
} else if (minutes <= 60) {
|
||||
if (minutes <= 1) {
|
||||
return trans.one_minute_before;
|
||||
}
|
||||
return trans.minutes_before.replace("%amount%", "" + minutes);
|
||||
} else if (hours <= 60) {
|
||||
if (hours <= 1) {
|
||||
return trans.one_hour_before;
|
||||
}
|
||||
return trans.hours_before.replace("%amount%", "" + hours);
|
||||
} else {
|
||||
if (days <= 1) {
|
||||
return trans.one_day_before;
|
||||
}
|
||||
return trans.days_before.replace("%amount%", "" + days);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats seconds into a readable string like "5h 23m"
|
||||
* @param {number} secs Seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSeconds(secs) {
|
||||
const trans = T.common.time;
|
||||
secs = Math_ceil(secs);
|
||||
if (secs < 60) {
|
||||
return trans.seconds_short.replace("%seconds%", "" + secs);
|
||||
} else if (secs < 60 * 60) {
|
||||
const minutes = Math_floor(secs / 60);
|
||||
const seconds = secs % 60;
|
||||
return trans.minutes_seconds_short
|
||||
.replace("%seconds%", "" + seconds)
|
||||
.replace("%minutes%", "" + minutes);
|
||||
} else {
|
||||
const hours = Math_floor(secs / 3600);
|
||||
const minutes = Math_floor(secs / 60) % 60;
|
||||
return trans.hours_minutes_short.replace("%minutes%", "" + minutes).replace("%hours%", "" + hours);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delayes a promise so that it will resolve after a *minimum* amount of time only
|
||||
* @param {Promise<any>} promise The promise to delay
|
||||
* @param {number} minTimeMs The time to make it run at least
|
||||
* @returns {Promise<any>} The delayed promise
|
||||
*/
|
||||
export function artificialDelayedPromise(promise, minTimeMs = 500) {
|
||||
if (G_IS_DEV && globalConfig.debug.noArtificialDelays) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
const startTime = performanceNow();
|
||||
return promise.then(
|
||||
result => {
|
||||
const timeTaken = performanceNow() - startTime;
|
||||
const waitTime = Math_floor(minTimeMs - timeTaken);
|
||||
if (waitTime > 0) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve(result);
|
||||
}, waitTime);
|
||||
});
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
},
|
||||
error => {
|
||||
const timeTaken = performanceNow() - startTime;
|
||||
const waitTime = Math_floor(minTimeMs - timeTaken);
|
||||
if (waitTime > 0) {
|
||||
// @ts-ignore
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(error);
|
||||
}, waitTime);
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a sine-based animation which pulsates from 0 .. 1 .. 0
|
||||
* @param {number} time Current time in seconds
|
||||
* @param {number} duration Duration of the full pulse in seconds
|
||||
* @param {number} seed Seed to offset the animation
|
||||
*/
|
||||
export function pulseAnimation(time, duration = 1.0, seed = 0.0) {
|
||||
return Math_sin((time * Math_PI * 2.0) / duration + seed * 5642.86729349) * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the smallest angle between two angles
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {number} 0 .. 2 PI
|
||||
*/
|
||||
export function smallestAngle(a, b) {
|
||||
return safeMod(a - b + Math_PI, 2.0 * Math_PI) - Math_PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modulo which works for negative numbers
|
||||
* @param {number} n
|
||||
* @param {number} m
|
||||
*/
|
||||
export function safeMod(n, m) {
|
||||
return ((n % m) + m) % m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an angle between 0 and 2 pi
|
||||
* @param {number} angle
|
||||
*/
|
||||
export function wrapAngle(angle) {
|
||||
return safeMod(angle, 2.0 * Math_PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits two frames so the ui is updated
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function waitNextFrame() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
window.requestAnimationFrame(function () {
|
||||
window.requestAnimationFrame(function () {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds 1 digit
|
||||
* @param {number} n
|
||||
* @returns {number}
|
||||
*/
|
||||
export function round1Digit(n) {
|
||||
return Math_floor(n * 10.0) / 10.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds 2 digits
|
||||
* @param {number} n
|
||||
* @returns {number}
|
||||
*/
|
||||
export function round2Digits(n) {
|
||||
return Math_floor(n * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds 3 digits
|
||||
* @param {number} n
|
||||
* @returns {number}
|
||||
*/
|
||||
export function round3Digits(n) {
|
||||
return Math_floor(n * 1000.0) / 1000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds 4 digits
|
||||
* @param {number} n
|
||||
* @returns {number}
|
||||
*/
|
||||
export function round4Digits(n) {
|
||||
return Math_floor(n * 10000.0) / 10000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a value between [min, max]
|
||||
* @param {number} v
|
||||
* @param {number=} minimum Default 0
|
||||
* @param {number=} maximum Default 1
|
||||
*/
|
||||
export function clamp(v, minimum = 0, maximum = 1) {
|
||||
return Math_max(minimum, Math_min(maximum, v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures how long a function took
|
||||
* @param {string} name
|
||||
* @param {function():void} target
|
||||
*/
|
||||
export function measure(name, target) {
|
||||
const now = performanceNow();
|
||||
for (let i = 0; i < 25; ++i) {
|
||||
target();
|
||||
}
|
||||
const dur = (performanceNow() - now) / 25.0;
|
||||
console.warn("->", name, "took", dur.toFixed(2), "ms");
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a new div
|
||||
* @param {Element} parent
|
||||
* @param {string=} id
|
||||
* @param {Array<string>=} classes
|
||||
* @param {string=} innerHTML
|
||||
*/
|
||||
export function makeDiv(parent, id = null, classes = [], innerHTML = "") {
|
||||
const div = document.createElement("div");
|
||||
if (id) {
|
||||
div.id = id;
|
||||
}
|
||||
for (let i = 0; i < classes.length; ++i) {
|
||||
div.classList.add(classes[i]);
|
||||
}
|
||||
div.innerHTML = innerHTML;
|
||||
parent.appendChild(div);
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all children of the given element
|
||||
* @param {Element} elem
|
||||
*/
|
||||
export function removeAllChildren(elem) {
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(elem);
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
export function smartFadeNumber(current, newOne, minFade = 0.01, maxFade = 0.9) {
|
||||
const tolerance = Math.min(current, newOne) * 0.5 + 10;
|
||||
let fade = minFade;
|
||||
if (Math.abs(current - newOne) < tolerance) {
|
||||
fade = maxFade;
|
||||
}
|
||||
|
||||
return current * fade + newOne * (1 - fade);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes lockstep simulation by converting times like 34.0000000003 to 34.00.
|
||||
* We use 3 digits of precision, this allows to store sufficient precision of 1 ms without
|
||||
* the risk to simulation errors due to resync issues
|
||||
* @param {number} value
|
||||
*/
|
||||
export function quantizeFloat(value) {
|
||||
return Math.round(value * 1000.0) / 1000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe check to check if a timer is expired. quantizes numbers
|
||||
* @param {number} now Current time
|
||||
* @param {number} lastTick Last tick of the timer
|
||||
* @param {number} tickRate Interval of the timer
|
||||
*/
|
||||
export function checkTimerExpired(now, lastTick, tickRate) {
|
||||
if (!G_IS_PROD) {
|
||||
if (quantizeFloat(now) !== now) {
|
||||
console.error("Got non-quantizied time:" + now + " vs " + quantizeFloat(now));
|
||||
now = quantizeFloat(now);
|
||||
}
|
||||
if (quantizeFloat(lastTick) !== lastTick) {
|
||||
// FIXME: REENABLE
|
||||
// console.error("Got non-quantizied timer:" + lastTick + " vs " + quantizeFloat(lastTick));
|
||||
lastTick = quantizeFloat(lastTick);
|
||||
}
|
||||
} else {
|
||||
// just to be safe
|
||||
now = quantizeFloat(now);
|
||||
lastTick = quantizeFloat(lastTick);
|
||||
}
|
||||
/*
|
||||
Ok, so heres the issue (Died a bit while debugging it):
|
||||
|
||||
In multiplayer lockstep simulation, client A will simulate everything at T, but client B
|
||||
will simulate it at T + 3. So we are running into the following precision issue:
|
||||
Lets say on client A the time is T = 30. Then on clientB the time is T = 33.
|
||||
Now, our timer takes 0.1 seconds and ticked at 29.90 - What does happen now?
|
||||
Client A computes the timer and checks T > lastTick + interval. He computes
|
||||
|
||||
30 >= 29.90 + 0.1 <=> 30 >= 30.0000 <=> True <=> Tick performed
|
||||
|
||||
However, this is what it looks on client B:
|
||||
|
||||
33 >= 32.90 + 0.1 <=> 33 >= 32.999999999999998 <=> False <=> No tick performed!
|
||||
|
||||
This means that Client B will only tick at the *next* frame, which means it from now is out
|
||||
of sync by one tick, which means the game will resync further or later and be not able to recover,
|
||||
since it will run into the same issue over and over.
|
||||
*/
|
||||
|
||||
// The next tick, in our example it would be 30.0000 / 32.99999999998. In order to fix it, we quantize
|
||||
// it, so its now 30.0000 / 33.0000
|
||||
const nextTick = quantizeFloat(lastTick + tickRate);
|
||||
|
||||
// This check is safe, but its the only check where you may compare times. You always need to use
|
||||
// this method!
|
||||
return now >= nextTick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the game supports this browser
|
||||
*/
|
||||
export function isSupportedBrowser() {
|
||||
if (navigator.userAgent.toLowerCase().indexOf("firefox") >= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isSupportedBrowserForMultiplayer();
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/4565112/javascript-how-to-find-out-if-the-user-browser-is-chrome/13348618#13348618
|
||||
export function isSupportedBrowserForMultiplayer() {
|
||||
// please note,
|
||||
// that IE11 now returns undefined again for window.chrome
|
||||
// and new Opera 30 outputs true for window.chrome
|
||||
// but needs to check if window.opr is not undefined
|
||||
// and new IE Edge outputs to true now for window.chrome
|
||||
// and if not iOS Chrome check
|
||||
// so use the below updated condition
|
||||
|
||||
if (G_IS_MOBILE_APP || G_IS_STANDALONE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
var isChromium = window.chrome;
|
||||
var winNav = window.navigator;
|
||||
var vendorName = winNav.vendor;
|
||||
// @ts-ignore
|
||||
var isOpera = typeof window.opr !== "undefined";
|
||||
var isIEedge = winNav.userAgent.indexOf("Edge") > -1;
|
||||
var isIOSChrome = winNav.userAgent.match("CriOS");
|
||||
|
||||
if (isIOSChrome) {
|
||||
// is Google Chrome on IOS
|
||||
return false;
|
||||
} else if (
|
||||
isChromium !== null &&
|
||||
typeof isChromium !== "undefined" &&
|
||||
vendorName === "Google Inc." &&
|
||||
isIEedge === false
|
||||
) {
|
||||
// is Google Chrome
|
||||
return true;
|
||||
} else {
|
||||
// not Google Chrome
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a json schema object
|
||||
* @param {any} properties
|
||||
*/
|
||||
export function schemaObject(properties) {
|
||||
return {
|
||||
type: "object",
|
||||
required: Object.keys(properties).slice(),
|
||||
additionalProperties: false,
|
||||
properties,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quickly
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} deg
|
||||
* @returns {Vector}
|
||||
*/
|
||||
export function fastRotateMultipleOf90(x, y, deg) {
|
||||
switch (deg) {
|
||||
case 0: {
|
||||
return new Vector(x, y);
|
||||
}
|
||||
case 90: {
|
||||
return new Vector(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
665
src/js/core/vector.js
Normal file
665
src/js/core/vector.js
Normal file
@@ -0,0 +1,665 @@
|
||||
import { globalConfig } from "./config";
|
||||
import {
|
||||
Math_abs,
|
||||
Math_floor,
|
||||
Math_PI,
|
||||
Math_max,
|
||||
Math_min,
|
||||
Math_round,
|
||||
Math_hypot,
|
||||
Math_atan2,
|
||||
Math_sin,
|
||||
Math_cos,
|
||||
} from "./builtins";
|
||||
|
||||
const tileSize = globalConfig.tileSize;
|
||||
const halfTileSize = globalConfig.halfTileSize;
|
||||
|
||||
/**
|
||||
* @enum {string}
|
||||
*/
|
||||
export const enumDirection = {
|
||||
top: "top",
|
||||
right: "right",
|
||||
bottom: "bottom",
|
||||
left: "left",
|
||||
};
|
||||
|
||||
/**
|
||||
* @enum {string}
|
||||
*/
|
||||
export const enumInvertedDirections = {
|
||||
[enumDirection.top]: enumDirection.bottom,
|
||||
[enumDirection.right]: enumDirection.left,
|
||||
[enumDirection.bottom]: enumDirection.top,
|
||||
[enumDirection.left]: enumDirection.right,
|
||||
};
|
||||
|
||||
/**
|
||||
* @enum {number}
|
||||
*/
|
||||
export const enumDirectionToAngle = {
|
||||
[enumDirection.top]: 0,
|
||||
[enumDirection.right]: 90,
|
||||
[enumDirection.bottom]: 180,
|
||||
[enumDirection.left]: 270,
|
||||
};
|
||||
|
||||
/**
|
||||
* @enum {enumDirection}
|
||||
*/
|
||||
export const enumAngleToDirection = {
|
||||
0: enumDirection.top,
|
||||
90: enumDirection.right,
|
||||
180: enumDirection.bottom,
|
||||
270: enumDirection.left,
|
||||
};
|
||||
|
||||
export class Vector {
|
||||
/**
|
||||
*
|
||||
* @param {number=} x
|
||||
* @param {number=} y
|
||||
*/
|
||||
constructor(x, y) {
|
||||
this.x = x || 0;
|
||||
this.y = y || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* return a copy of the vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
copy() {
|
||||
return new Vector(this.x, this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a vector and return a new vector
|
||||
* @param {Vector} other
|
||||
* @returns {Vector}
|
||||
*/
|
||||
add(other) {
|
||||
return new Vector(this.x + other.x, this.y + other.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a vector
|
||||
* @param {Vector} other
|
||||
* @returns {Vector}
|
||||
*/
|
||||
addInplace(other) {
|
||||
this.x += other.x;
|
||||
this.y += other.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substracts a vector and return a new vector
|
||||
* @param {Vector} other
|
||||
* @returns {Vector}
|
||||
*/
|
||||
sub(other) {
|
||||
return new Vector(this.x - other.x, this.y - other.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies with a vector and return a new vector
|
||||
* @param {Vector} other
|
||||
* @returns {Vector}
|
||||
*/
|
||||
mul(other) {
|
||||
return new Vector(this.x * other.x, this.y * other.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds two scalars and return a new vector
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {Vector}
|
||||
*/
|
||||
addScalars(x, y) {
|
||||
return new Vector(this.x + x, this.y + y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Substracts a scalar and return a new vector
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
subScalar(f) {
|
||||
return new Vector(this.x - f, this.y - f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Substracts two scalars and return a new vector
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {Vector}
|
||||
*/
|
||||
subScalars(x, y) {
|
||||
return new Vector(this.x - x, this.y - y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the euclidian length
|
||||
* @returns {number}
|
||||
*/
|
||||
length() {
|
||||
return Math_hypot(this.x, this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the square length
|
||||
* @returns {number}
|
||||
*/
|
||||
lengthSquare() {
|
||||
return this.x * this.x + this.y * this.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divides both components by a scalar and return a new vector
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
divideScalar(f) {
|
||||
return new Vector(this.x / f, this.y / f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divides both components by the given scalars and return a new vector
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {Vector}
|
||||
*/
|
||||
divideScalars(a, b) {
|
||||
return new Vector(this.x / a, this.y / b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divides both components by a scalar
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
divideScalarInplace(f) {
|
||||
this.x /= f;
|
||||
this.y /= f;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies both components with a scalar and return a new vector
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
multiplyScalar(f) {
|
||||
return new Vector(this.x * f, this.y * f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies both components with two scalars and returns a new vector
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {Vector}
|
||||
*/
|
||||
multiplyScalars(a, b) {
|
||||
return new Vector(this.x * a, this.y * b);
|
||||
}
|
||||
|
||||
/**
|
||||
* For both components, compute the maximum of each component and the given scalar, and return a new vector.
|
||||
* For example:
|
||||
* - new Vector(-1, 5).maxScalar(0) -> Vector(0, 5)
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
maxScalar(f) {
|
||||
return new Vector(Math_max(f, this.x), Math_max(f, this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a scalar to both components and return a new vector
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
addScalar(f) {
|
||||
return new Vector(this.x + f, this.y + f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the component wise minimum and return a new vector
|
||||
* @param {Vector} v
|
||||
* @returns {Vector}
|
||||
*/
|
||||
min(v) {
|
||||
return new Vector(Math_min(v.x, this.x), Math_min(v.y, this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the component wise maximum and return a new vector
|
||||
* @param {Vector} v
|
||||
* @returns {Vector}
|
||||
*/
|
||||
max(v) {
|
||||
return new Vector(Math_max(v.x, this.x), Math_max(v.y, this.y));
|
||||
}
|
||||
/**
|
||||
* Computes the component wise absolute
|
||||
* @returns {Vector}
|
||||
*/
|
||||
abs() {
|
||||
return new Vector(Math_abs(this.x), Math_abs(this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the scalar product
|
||||
* @param {Vector} v
|
||||
* @returns {number}
|
||||
*/
|
||||
dot(v) {
|
||||
return this.x * v.x + this.y * v.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the distance to a given vector
|
||||
* @param {Vector} v
|
||||
* @returns {number}
|
||||
*/
|
||||
distance(v) {
|
||||
return Math_hypot(this.x - v.x, this.y - v.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the square distance to a given vectort
|
||||
* @param {Vector} v
|
||||
* @returns {number}
|
||||
*/
|
||||
distanceSquare(v) {
|
||||
const dx = this.x - v.x;
|
||||
const dy = this.y - v.y;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes and returns the center between both points
|
||||
* @param {Vector} v
|
||||
* @returns {Vector}
|
||||
*/
|
||||
centerPoint(v) {
|
||||
const cx = this.x + v.x;
|
||||
const cy = this.y + v.y;
|
||||
return new Vector(cx / 2, cy / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes componentwise floor and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
floor() {
|
||||
return new Vector(Math_floor(this.x), Math_floor(this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes componentwise round and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
round() {
|
||||
return new Vector(Math_round(this.x), Math_round(this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this vector from world to tile space and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
toTileSpace() {
|
||||
return new Vector(Math_floor(this.x / tileSize), Math_floor(this.y / tileSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this vector from world to street space and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
toStreetSpace() {
|
||||
return new Vector(Math_floor(this.x / halfTileSize + 0.25), Math_floor(this.y / halfTileSize + 0.25));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this vector to world space and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
toWorldSpace() {
|
||||
return new Vector(this.x * tileSize, this.y * tileSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this vector to world space and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
toWorldSpaceCenterOfTile() {
|
||||
return new Vector(this.x * tileSize + halfTileSize, this.y * tileSize + halfTileSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the top left tile position of this vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
snapWorldToTile() {
|
||||
return new Vector(Math_floor(this.x / tileSize) * tileSize, Math_floor(this.y / tileSize) * tileSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the vector, dividing by the length(), and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
normalize() {
|
||||
const len = Math_max(1e-5, Math_hypot(this.x, this.y));
|
||||
return new Vector(this.x / len, this.y / len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the vector, dividing by the length(), and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
normalizeIfGreaterOne() {
|
||||
const len = Math_max(1, Math_hypot(this.x, this.y));
|
||||
return new Vector(this.x / len, this.y / len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized vector to the other point
|
||||
* @param {Vector} v
|
||||
* @returns {Vector}
|
||||
*/
|
||||
normalizedDirection(v) {
|
||||
const dx = v.x - this.x;
|
||||
const dy = v.y - this.y;
|
||||
const len = Math_max(1e-5, Math_hypot(dx, dy));
|
||||
return new Vector(dx / len, dy / len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a perpendicular vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
findPerpendicular() {
|
||||
return new Vector(-this.y, this.x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unnormalized direction to the other point
|
||||
* @param {Vector} v
|
||||
* @returns {Vector}
|
||||
*/
|
||||
direction(v) {
|
||||
return new Vector(v.x - this.x, v.y - this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representation of the vector
|
||||
* @returns {string}
|
||||
*/
|
||||
toString() {
|
||||
return this.x + "," + this.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares both vectors for exact equality. Does not do an epsilon compare
|
||||
* @param {Vector} v
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
equals(v) {
|
||||
return this.x === v.x && this.y === v.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates this vector
|
||||
* @param {number} angle
|
||||
* @returns {Vector} new vector
|
||||
*/
|
||||
rotated(angle) {
|
||||
const sin = Math_sin(angle);
|
||||
const cos = Math_cos(angle);
|
||||
return new Vector(this.x * cos - this.y * sin, this.x * sin + this.y * cos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates this vector
|
||||
* @param {number} angle
|
||||
* @returns {Vector} this vector
|
||||
*/
|
||||
rotateInplaceFastMultipleOf90(angle) {
|
||||
// const sin = Math_sin(angle);
|
||||
// const cos = Math_cos(angle);
|
||||
// let sin = 0, cos = 1;
|
||||
assert(angle >= 0 && angle <= 360, "Invalid angle, please clamp first: " + angle);
|
||||
|
||||
switch (angle) {
|
||||
case 0:
|
||||
case 360: {
|
||||
return this;
|
||||
}
|
||||
case 90: {
|
||||
// sin = 1;
|
||||
// cos = 0;
|
||||
|
||||
const x = this.x;
|
||||
this.x = -this.y;
|
||||
this.y = x;
|
||||
return this;
|
||||
}
|
||||
case 180: {
|
||||
// sin = 0
|
||||
// cos = -1
|
||||
this.x = -this.x;
|
||||
this.y = -this.y;
|
||||
return this;
|
||||
}
|
||||
case 270: {
|
||||
// sin = -1
|
||||
// cos = 0
|
||||
const x = this.x;
|
||||
this.x = this.y;
|
||||
this.y = -x;
|
||||
return this;
|
||||
}
|
||||
default: {
|
||||
assertAlways(false, "Invalid fast inplace rotation: " + angle);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
// return new Vector(this.x * cos - this.y * sin, this.x * sin + this.y * cos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates this vector
|
||||
* @param {number} angle
|
||||
* @returns {Vector} new vector
|
||||
*/
|
||||
rotateFastMultipleOf90(angle) {
|
||||
assert(angle >= 0 && angle <= 360, "Invalid angle, please clamp first: " + angle);
|
||||
|
||||
switch (angle) {
|
||||
case 360:
|
||||
case 0: {
|
||||
return new Vector(this.x, this.y);
|
||||
}
|
||||
case 90: {
|
||||
return new Vector(-this.y, this.x);
|
||||
}
|
||||
case 180: {
|
||||
return new Vector(-this.x, -this.y);
|
||||
}
|
||||
case 270: {
|
||||
return new Vector(this.y, -this.x);
|
||||
}
|
||||
default: {
|
||||
assertAlways(false, "Invalid fast inplace rotation: " + angle);
|
||||
return new Vector();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to rotate a direction
|
||||
* @param {enumDirection} direction
|
||||
* @param {number} angle
|
||||
* @returns {enumDirection}
|
||||
*/
|
||||
static transformDirectionFromMultipleOf90(direction, angle) {
|
||||
if (angle === 0 || angle === 360) {
|
||||
return direction;
|
||||
}
|
||||
assert(angle >= 0 && angle <= 360, "Invalid angle: " + angle);
|
||||
switch (direction) {
|
||||
case enumDirection.top: {
|
||||
switch (angle) {
|
||||
case 90:
|
||||
return enumDirection.right;
|
||||
case 180:
|
||||
return enumDirection.bottom;
|
||||
case 270:
|
||||
return enumDirection.left;
|
||||
default:
|
||||
assertAlways(false, "Invalid angle: " + angle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
case enumDirection.right: {
|
||||
switch (angle) {
|
||||
case 90:
|
||||
return enumDirection.bottom;
|
||||
case 180:
|
||||
return enumDirection.left;
|
||||
case 270:
|
||||
return enumDirection.top;
|
||||
default:
|
||||
assertAlways(false, "Invalid angle: " + angle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
case enumDirection.bottom: {
|
||||
switch (angle) {
|
||||
case 90:
|
||||
return enumDirection.left;
|
||||
case 180:
|
||||
return enumDirection.top;
|
||||
case 270:
|
||||
return enumDirection.right;
|
||||
default:
|
||||
assertAlways(false, "Invalid angle: " + angle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
case enumDirection.left: {
|
||||
switch (angle) {
|
||||
case 90:
|
||||
return enumDirection.top;
|
||||
case 180:
|
||||
return enumDirection.right;
|
||||
case 270:
|
||||
return enumDirection.bottom;
|
||||
default:
|
||||
assertAlways(false, "Invalid angle: " + angle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
default:
|
||||
assertAlways(false, "Invalid angle: " + angle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares both vectors for epsilon equality
|
||||
* @param {Vector} v
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
equalsEpsilon(v, epsilon = 1e-5) {
|
||||
return Math_abs(this.x - v.x) < 1e-5 && Math_abs(this.y - v.y) < epsilon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the angle
|
||||
* @returns {number} 0 .. 2 PI
|
||||
*/
|
||||
angle() {
|
||||
return Math_atan2(this.y, this.x) + Math_PI / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the vector to a string
|
||||
* @returns {string}
|
||||
*/
|
||||
serializeTile() {
|
||||
return String.fromCharCode(33 + this.x) + String.fromCharCode(33 + this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simple representation of the vector
|
||||
*/
|
||||
serializeSimple() {
|
||||
return { x: this.x, y: this.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
serializeTileToInt() {
|
||||
return this.x + this.y * 256;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} i
|
||||
* @returns {Vector}
|
||||
*/
|
||||
static deserializeTileFromInt(i) {
|
||||
const x = i % 256;
|
||||
const y = Math_floor(i / 256);
|
||||
return new Vector(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a vector from a string
|
||||
* @param {string} s
|
||||
* @returns {Vector}
|
||||
*/
|
||||
static deserializeTile(s) {
|
||||
return new Vector(s.charCodeAt(0) - 33, s.charCodeAt(1) - 33);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a vector from a serialized json object
|
||||
* @param {object} obj
|
||||
* @returns {Vector}
|
||||
*/
|
||||
static fromSerializedObject(obj) {
|
||||
if (obj) {
|
||||
return new Vector(obj.x || 0, obj.y || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates two vectors, for a = 0, returns v1 and for a = 1 return v2, otherwise interpolate
|
||||
* @param {Vector} v1
|
||||
* @param {Vector} v2
|
||||
* @param {number} a
|
||||
*/
|
||||
export function mixVector(v1, v2, a) {
|
||||
return new Vector(v1.x * (1 - a) + v2.x * a, v1.y * (1 - a) + v2.y * a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from string direction to actual vector
|
||||
* @enum {Vector}
|
||||
*/
|
||||
export const enumDirectionToVector = {
|
||||
top: new Vector(0, -1),
|
||||
right: new Vector(1, 0),
|
||||
bottom: new Vector(0, 1),
|
||||
left: new Vector(-1, 0),
|
||||
};
|
||||
Reference in New Issue
Block a user