1
0
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:
Tobias Springer
2020-05-09 16:45:23 +02:00
commit 93c6ea683d
304 changed files with 56031 additions and 0 deletions

View 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
View 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();

View 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();

View 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),
// };

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

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

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

View 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
View 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;
}
// CohenSutherland 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);
}

View 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:&nbsp;
<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;

View 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
View 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
View 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();
}
}

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

View 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,
});
}
}

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

View 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
View 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();

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

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

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

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

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

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

View 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
View 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
View 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),
};