From c399e0d7f1061078951bcc217b4cc3cc79f6f9ff Mon Sep 17 00:00:00 2001 From: Bagel03 <70449196+Bagel03@users.noreply.github.com> Date: Fri, 18 Nov 2022 22:53:49 -0500 Subject: [PATCH] Cleaned up /core --- src/ts/core/animation_frame.ts | 24 +- src/ts/core/assert.ts | 7 +- src/ts/core/async_compression.ts | 34 ++- src/ts/core/atlas_definitions.ts | 20 +- src/ts/core/background_resources_loader.ts | 152 +++++++--- src/ts/core/buffer_maintainer.ts | 68 +++-- src/ts/core/buffer_utils.ts | 55 +++- src/ts/core/click_detector.ts | 175 +++++++++--- src/ts/core/config.local.template.ts | 248 ++++++++--------- src/ts/core/config.ts | 44 ++- src/ts/core/dpi_manager.ts | 43 +-- src/ts/core/draw_parameters.ts | 16 +- src/ts/core/draw_utils.ts | 69 ++++- src/ts/core/explained_result.ts | 20 +- src/ts/core/factory.ts | 30 +- src/ts/core/game_state.ts | 109 +++++--- src/ts/core/global_registries.ts | 31 ++- src/ts/core/globals.ts | 5 +- src/ts/core/input_distributor.ts | 125 ++++++--- src/ts/core/input_receiver.ts | 6 +- src/ts/core/loader.ts | 68 +++-- src/ts/core/logging.ts | 102 ++++--- src/ts/core/lzstring.ts | 142 ++++++---- src/ts/core/modal_dialog_elements.ts | 218 ++++++++++++--- src/ts/core/modal_dialog_forms.ts | 122 +++++--- src/ts/core/polyfills.ts | 18 +- src/ts/core/query_parameters.ts | 1 + src/ts/core/read_write_proxy.ts | 308 ++++++++++++--------- src/ts/core/rectangle.ts | 110 ++++---- src/ts/core/request_channel.ts | 57 ++-- src/ts/core/restriction_manager.ts | 10 +- src/ts/core/rng.ts | 28 +- src/ts/core/sensitive_utils.encrypt.ts | 4 + src/ts/core/signal.ts | 28 +- src/ts/core/singleton_factory.ts | 39 +-- src/ts/core/sprites.ts | 272 +++++++++++++----- src/ts/core/stale_area_detector.ts | 37 ++- src/ts/core/state_manager.ts | 44 ++- src/ts/core/steam_sso.ts | 29 +- src/ts/core/textual_game_state.ts | 36 ++- src/ts/core/tracked_state.ts | 27 +- src/ts/core/utils.ts | 193 ++++++++----- src/ts/core/vector.ts | 161 ++++++----- 43 files changed, 2183 insertions(+), 1152 deletions(-) diff --git a/src/ts/core/animation_frame.ts b/src/ts/core/animation_frame.ts index b77f76a4..318c2645 100644 --- a/src/ts/core/animation_frame.ts +++ b/src/ts/core/animation_frame.ts @@ -1,16 +1,21 @@ import { Signal } from "./signal"; + // @ts-ignore import BackgroundAnimationFrameEmitterWorker from "../webworkers/background_animation_frame_emittter.worker"; import { createLogger } from "./logging"; + const logger = createLogger("animation_frame"); const maxDtMs = 1000; const resetDtMs = 16; export class AnimationFrame { - public frameEmitted = new Signal(); - public bgFrameEmitted = new Signal(); + public frameEmitted = new Signal<[dt: number]>(); + public bgFrameEmitted = new Signal<[dt: number]>(); + public lastTime = performance.now(); public bgLastTime = performance.now(); + public boundMethod = this.handleAnimationFrame.bind(this); + public backgroundWorker = new BackgroundAnimationFrameEmitterWorker(); constructor() { @@ -19,28 +24,35 @@ export class AnimationFrame { }); this.backgroundWorker.addEventListener("message", this.handleBackgroundTick.bind(this)); } + handleBackgroundTick() { const time = performance.now(); + 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(); + this.handleAnimationFrame(0); } - handleAnimationFrame(time) { + + handleAnimationFrame(time: number) { let dt = time - this.lastTime; + if (dt > maxDtMs) { dt = resetDtMs; } + try { this.frameEmitted.dispatch(dt); - } - catch (ex) { + } catch (ex) { console.error(ex); } this.lastTime = time; diff --git a/src/ts/core/assert.ts b/src/ts/core/assert.ts index 4d86d4aa..129da7dc 100644 --- a/src/ts/core/assert.ts +++ b/src/ts/core/assert.ts @@ -1,13 +1,15 @@ import { createLogger } from "./logging"; + const logger = createLogger("assert"); + let assertionErrorShown = false; + function initAssert() { /** * Expects a given condition to be true - * @param {} failureMessage */ // @ts-ignore - window.assert = function (condition: Boolean, ...failureMessage: ...String) { + window.assert = function (condition: boolean, ...failureMessage: string[]) { if (!condition) { logger.error("assertion failed:", ...failureMessage); if (!assertionErrorShown) { @@ -18,4 +20,5 @@ function initAssert() { } }; } + initAssert(); diff --git a/src/ts/core/async_compression.ts b/src/ts/core/async_compression.ts index 9d5a19f3..31d4d1bb 100644 --- a/src/ts/core/async_compression.ts +++ b/src/ts/core/async_compression.ts @@ -1,20 +1,24 @@ // @ts-ignore import CompressionWorker from "../webworkers/compression.worker"; + import { createLogger } from "./logging"; import { round2Digits } from "./utils"; + const logger = createLogger("async_compression"); + export let compressionPrefix = String.fromCodePoint(1); -function checkCryptPrefix(prefix) { + +function checkCryptPrefix(prefix: string) { try { window.localStorage.setItem("prefix_test", prefix); window.localStorage.removeItem("prefix_test"); return true; - } - catch (ex) { + } catch (ex) { logger.warn("Prefix '" + prefix + "' not available"); return false; } } + if (!checkCryptPrefix(compressionPrefix)) { logger.warn("Switching to basic prefix"); compressionPrefix = " "; @@ -22,15 +26,18 @@ if (!checkCryptPrefix(compressionPrefix)) { logger.warn("Prefix not available, ls seems to be unavailable"); } } + export type JobEntry = { - errorHandler: function(: void):void; - resolver: function(: void):void; + errorHandler: (err: any) => void; + resolver: (res: any) => void; startTime: number; }; class AsynCompression { public worker = new CompressionWorker(); + public currentJobId = 1000; + public currentJobs: { [idx: number]: JobEntry; } = {}; @@ -43,12 +50,22 @@ class AsynCompression { logger.error("Failed to resolve job result, job id", jobId, "is not known"); return; } + const duration = performance.now() - jobData.startTime; - logger.log("Got job", jobId, "response within", round2Digits(duration), "ms: ", result.length, "bytes"); + logger.log( + "Got job", + jobId, + "response within", + round2Digits(duration), + "ms: ", + result.length, + "bytes" + ); 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 = []; @@ -61,6 +78,7 @@ class AsynCompression { } }); } + /** * Compresses any object */ @@ -71,9 +89,9 @@ class AsynCompression { compressionPrefix, }); } + /** * Queues a new job - * {} */ internalQueueJob(job: string, data: any): Promise { const jobId = ++this.currentJobId; @@ -87,9 +105,11 @@ class AsynCompression { resolver: resolve, startTime: performance.now(), }; + logger.log("Posting job", job, "/", jobId); this.worker.postMessage({ jobId, job, data }); }); } } + export const asyncCompressor = new AsynCompression(); diff --git a/src/ts/core/atlas_definitions.ts b/src/ts/core/atlas_definitions.ts index 84822a40..2007edd5 100644 --- a/src/ts/core/atlas_definitions.ts +++ b/src/ts/core/atlas_definitions.ts @@ -1,4 +1,3 @@ - export type Size = { w: number; h: number; @@ -24,20 +23,29 @@ export type AtlasMeta = { smartupdate: string; }; export type SourceData = { - frames: Object; + frames: { + [idx: string]: SpriteDefinition; + }; meta: AtlasMeta; }; export class AtlasDefinition { - public meta = meta; - public sourceData = frames; - public sourceFileName = meta.image; + public meta: AtlasMeta; + public sourceData: { + [idx: string]: SpriteDefinition; + }; + public sourceFileName: string; - constructor({ frames, meta }) { + constructor({ frames, meta }: SourceData) { + this.meta = meta; + this.sourceData = frames; + this.sourceFileName = meta.image; } + getFullSourcePath() { return this.sourceFileName; } } + export const atlasFiles: AtlasDefinition[] = require // @ts-ignore .context("../../../res_built/atlas/", false, /.*\.json/i) diff --git a/src/ts/core/background_resources_loader.ts b/src/ts/core/background_resources_loader.ts index 8eb8d42d..c57c0ab1 100644 --- a/src/ts/core/background_resources_loader.ts +++ b/src/ts/core/background_resources_loader.ts @@ -1,6 +1,6 @@ -/* typehints:start */ import type { Application } from "../application"; -/* typehints:end */ +import type { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; + import { initSpriteCache } from "../game/meta_building_registry"; import { MUSIC, SOUNDS } from "../platform/sound"; import { T } from "../translations"; @@ -10,14 +10,24 @@ import { Loader } from "./loader"; import { createLogger } from "./logging"; import { Signal } from "./signal"; import { clamp, getLogoSprite, timeoutPromise } from "./utils"; + const logger = createLogger("background_loader"); -const MAIN_MENU_ASSETS = { + +type Assets = { + sprites: string[]; + sounds: string[]; + atlas: AtlasDefinition[]; + css: string[]; +}; + +const MAIN_MENU_ASSETS: Assets = { sprites: [getLogoSprite()], sounds: [SOUNDS.uiClick, SOUNDS.uiError, SOUNDS.dialogError, SOUNDS.dialogOk], atlas: [], css: [], }; -const INGAME_ASSETS = { + +const INGAME_ASSETS: Assets = { sprites: [], sounds: [ ...Array.from(Object.values(MUSIC)), @@ -26,32 +36,36 @@ const INGAME_ASSETS = { atlas: atlasFiles, css: ["async-resources.css"], }; + if (G_IS_STANDALONE) { MAIN_MENU_ASSETS.sounds = [...Array.from(Object.values(MUSIC)), ...Array.from(Object.values(SOUNDS))]; INGAME_ASSETS.sounds = []; } + const LOADER_TIMEOUT_PER_RESOURCE = 180000; + // Cloudflare does not send content-length headers with brotli compression, // so store the actual (compressed) file sizes so we can show a progress bar. const HARDCODED_FILE_SIZES = { "async-resources.css": 2216145, }; + export class BackgroundResourcesLoader { - public app = app; public mainMenuPromise = null; public ingamePromise = null; - public resourceStateChangedSignal = new Signal(); + public resourceStateChangedSignal = new Signal<[{ progress: number }]>(); - constructor(app) { - } - getMainMenuPromise() { + constructor(public app) {} + + getMainMenuPromise(): Promise { if (this.mainMenuPromise) { return this.mainMenuPromise; } logger.log("⏰ Loading main menu assets"); return (this.mainMenuPromise = this.loadAssets(MAIN_MENU_ASSETS)); } - getIngamePromise() { + + getIngamePromise(): Promise { if (this.ingamePromise) { return this.ingamePromise; } @@ -59,46 +73,77 @@ export class BackgroundResourcesLoader { const promise = this.loadAssets(INGAME_ASSETS).then(() => initSpriteCache()); return (this.ingamePromise = promise); } - async loadAssets({ sprites, sounds, atlas, css }: { + + async loadAssets({ + sprites, + sounds, + atlas, + css, + }: { sprites: string[]; sounds: string[]; atlas: AtlasDefinition[]; css: string[]; }) { - let promiseFunctions: ((progressHandler: (progress: number) => void) => Promise)[] = []; + let promiseFunctions: ((progressHandler: (progress: number) => void) => Promise)[] = []; + // CSS for (let i = 0; i < css.length; ++i) { - promiseFunctions.push(progress => timeoutPromise(this.internalPreloadCss(css[i], progress), LOADER_TIMEOUT_PER_RESOURCE).catch(err => { - logger.error("Failed to load css:", css[i], err); - throw new Error("HUD Stylesheet " + css[i] + " failed to load: " + err); - })); + promiseFunctions.push(progress => + timeoutPromise(this.internalPreloadCss(css[i], progress), LOADER_TIMEOUT_PER_RESOURCE).catch( + err => { + logger.error("Failed to load css:", css[i], err); + throw new Error("HUD Stylesheet " + css[i] + " failed to load: " + err); + } + ) + ); } + // ATLAS FILES for (let i = 0; i < atlas.length; ++i) { - promiseFunctions.push(progress => timeoutPromise(Loader.preloadAtlas(atlas[i], progress), LOADER_TIMEOUT_PER_RESOURCE).catch(err => { - logger.error("Failed to load atlas:", atlas[i].sourceFileName, err); - throw new Error("Atlas " + atlas[i].sourceFileName + " failed to load: " + err); - })); + promiseFunctions.push(progress => + timeoutPromise(Loader.preloadAtlas(atlas[i], progress), LOADER_TIMEOUT_PER_RESOURCE).catch( + err => { + logger.error("Failed to load atlas:", atlas[i].sourceFileName, err); + throw new Error("Atlas " + atlas[i].sourceFileName + " failed to load: " + err); + } + ) + ); } + // HUD Sprites for (let i = 0; i < sprites.length; ++i) { - promiseFunctions.push(progress => timeoutPromise(Loader.preloadCSSSprite(sprites[i], progress), LOADER_TIMEOUT_PER_RESOURCE).catch(err => { - logger.error("Failed to load css sprite:", sprites[i], err); - throw new Error("HUD Sprite " + sprites[i] + " failed to load: " + err); - })); + promiseFunctions.push(progress => + timeoutPromise( + Loader.preloadCSSSprite(sprites[i], progress), + LOADER_TIMEOUT_PER_RESOURCE + ).catch(err => { + logger.error("Failed to load css sprite:", sprites[i], err); + throw new Error("HUD Sprite " + sprites[i] + " failed to load: " + err); + }) + ); } + // SFX & Music for (let i = 0; i < sounds.length; ++i) { - promiseFunctions.push(progress => timeoutPromise(this.app.sound.loadSound(sounds[i]), LOADER_TIMEOUT_PER_RESOURCE).catch(err => { - logger.warn("Failed to load sound, will not be available:", sounds[i], err); - })); + promiseFunctions.push(progress => + timeoutPromise(this.app.sound.loadSound(sounds[i]), LOADER_TIMEOUT_PER_RESOURCE).catch( + err => { + logger.warn("Failed to load sound, will not be available:", sounds[i], err); + } + ) + ); } + const originalAmount = promiseFunctions.length; const start = performance.now(); + logger.log("⏰ Preloading", originalAmount, "assets"); + let progress = 0; this.resourceStateChangedSignal.dispatch({ progress }); let promises = []; + for (let i = 0; i < promiseFunctions.length; i++) { let lastIndividualProgress = 0; const progressHandler = individualProgress => { @@ -107,49 +152,64 @@ export class BackgroundResourcesLoader { progress += delta / originalAmount; this.resourceStateChangedSignal.dispatch({ progress }); }; - promises.push(promiseFunctions[i](progressHandler).then(() => { - progressHandler(1); - })); + promises.push( + promiseFunctions[i](progressHandler).then(() => { + progressHandler(1); + }) + ); } await Promise.all(promises); + logger.log("⏰ Preloaded assets in", Math.round(performance.now() - start), "ms"); } + /** * Shows an error when a resource failed to load and allows to reload the game */ - showLoaderError(dialogs, err) { + showLoaderError(dialogs: HUDModalDialogs, err: string) { if (G_IS_STANDALONE) { dialogs - .showWarning(T.dialogs.resourceLoadFailed.title, T.dialogs.resourceLoadFailed.descSteamDemo + "
" + err, ["retry"]) + .showWarning( + T.dialogs.resourceLoadFailed.title, + T.dialogs.resourceLoadFailed.descSteamDemo + "
" + err, + ["retry"] + ) .retry.add(() => window.location.reload()); - } - else { + } else { dialogs - .showWarning(T.dialogs.resourceLoadFailed.title, T.dialogs.resourceLoadFailed.descWeb.replace("", `${T.dialogs.resourceLoadFailed.demoLinkText}`) + - "
" + - err, ["retry"]) + .showWarning( + T.dialogs.resourceLoadFailed.title, + T.dialogs.resourceLoadFailed.descWeb.replace( + "", + `${T.dialogs.resourceLoadFailed.demoLinkText}` + ) + + "
" + + err, + ["retry"] + ) .retry.add(() => window.location.reload()); } } - preloadWithProgress(src, progressHandler) { + + preloadWithProgress(src: string, progressHandler: (percent: number) => void): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); let notifiedNotComputable = false; + const fullUrl = cachebust(src); xhr.open("GET", fullUrl, true); xhr.responseType = "arraybuffer"; xhr.onprogress = function (ev) { if (ev.lengthComputable) { progressHandler(ev.loaded / ev.total); - } - else { + } else { if (window.location.search.includes("alwaysLogFileSize")) { console.warn("Progress:", src, ev.loaded); } + if (HARDCODED_FILE_SIZES[src]) { progressHandler(clamp(ev.loaded / HARDCODED_FILE_SIZES[src])); - } - else { + } else { if (!notifiedNotComputable) { notifiedNotComputable = true; console.warn("Progress not computable:", src, ev.loaded); @@ -158,14 +218,15 @@ export class BackgroundResourcesLoader { } } }; + xhr.onloadend = function () { if (!xhr.status.toString().match(/^2/)) { reject(fullUrl + ": " + xhr.status + " " + xhr.statusText); - } - else { + } else { if (!notifiedNotComputable) { progressHandler(1); } + const options = {}; const headers = xhr.getAllResponseHeaders(); const contentType = headers.match(/^Content-Type:\s*(.*?)$/im); @@ -179,7 +240,8 @@ export class BackgroundResourcesLoader { xhr.send(); }); } - internalPreloadCss(src, progressHandler) { + + internalPreloadCss(src: string, progressHandler: (percent: number) => void) { return this.preloadWithProgress(src, progressHandler).then(blobSrc => { var styleElement = document.createElement("link"); styleElement.href = blobSrc; diff --git a/src/ts/core/buffer_maintainer.ts b/src/ts/core/buffer_maintainer.ts index 821f03bc..e63da5ef 100644 --- a/src/ts/core/buffer_maintainer.ts +++ b/src/ts/core/buffer_maintainer.ts @@ -2,6 +2,7 @@ import { GameRoot } from "../game/root"; import { clearBufferBacklog, freeCanvas, getBufferStats, makeOffscreenBuffer } from "./buffer_utils"; import { createLogger } from "./logging"; import { round1Digit } from "./utils"; + export type CacheEntry = { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D; @@ -9,16 +10,18 @@ export type CacheEntry = { }; const logger = createLogger("buffers"); + const bufferGcDurationSeconds = 0.5; + export class BufferMaintainer { - public root = root; public cache: Map> = new Map(); public iterationIndex = 1; public lastIteration = 0; - constructor(root) { + constructor(public root) { this.root.signals.gameFrameStarted.add(this.update, this); } + /** * Returns the buffer stats */ @@ -30,14 +33,18 @@ export class BufferMaintainer { }; this.cache.forEach((subCache, key) => { ++stats.rootKeys; + subCache.forEach((cacheEntry, subKey) => { ++stats.subKeys; + const canvas = cacheEntry.canvas; stats.vramBytes += canvas.width * canvas.height * 4; }); }); + return stats; } + /** * Goes to the next buffer iteration, clearing all buffers which were not used * for a few iterations @@ -46,28 +53,34 @@ export class BufferMaintainer { 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 || + if ( + cacheEntry.lastUse < minIteration || // @ts-ignore - cacheEntry.canvas._contextLost) { + cacheEntry.canvas._contextLost + ) { unusedSubKeys.push(subKey); freeCanvas(cacheEntry.canvas); ++deletedKeys; - } - else { + } 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(); + // if (G_IS_DEV) { // const bufferStats = getBufferStats(); // const mbUsed = round1Digit(bufferStats.vramUsage / (1024 * 1024)); @@ -89,8 +102,10 @@ export class BufferMaintainer { // "MB" // ); // } + ++this.iterationIndex; } + update() { const now = this.root.time.realtimeNow(); if (now - this.lastIteration > bufferGcDurationSeconds) { @@ -98,18 +113,30 @@ export class BufferMaintainer { this.garbargeCollect(); } } - /** - * {} - * - */ - getForKey({ key, subKey, w, h, dpi, redrawMethod, additionalParams }: { + + getForKey({ + key, + subKey, + w, + h, + dpi, + redrawMethod, + additionalParams = {}, + }: { key: string; subKey: string; w: number; h: number; dpi: number; - redrawMethod: function(: void, : void, : void, : void, : void, : void):void; - additionalParams: object=; + redrawMethod: ( + canvas: HTMLCanvasElement, + context: CanvasRenderingContext2D, + w: number, + h: number, + dpi: number, + addParams?: object + ) => void; + additionalParams: object; }): HTMLCanvasElement { // First, create parent key let parent = this.cache.get(key); @@ -117,21 +144,26 @@ export class BufferMaintainer { 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, @@ -139,14 +171,8 @@ export class BufferMaintainer { }); return canvas; } - /** - * {} - * - */ - getForKeyOrNullNoUpdate({ key, subKey }: { - key: string; - subKey: string; - }): ?HTMLCanvasElement { + + getForKeyOrNullNoUpdate({ key, subKey }: { key: string; subKey: string }): HTMLCanvasElement | null { let parent = this.cache.get(key); if (!parent) { return null; diff --git a/src/ts/core/buffer_utils.ts b/src/ts/core/buffer_utils.ts index 8902a18d..540c03a2 100644 --- a/src/ts/core/buffer_utils.ts +++ b/src/ts/core/buffer_utils.ts @@ -1,16 +1,20 @@ import { globalConfig } from "./config"; import { fastArrayDelete } from "./utils"; import { createLogger } from "./logging"; + const logger = createLogger("buffer_utils"); + /** * Enables images smoothing on a context */ export function enableImageSmoothing(context: CanvasRenderingContext2D) { context.imageSmoothingEnabled = true; context.webkitImageSmoothingEnabled = true; + // @ts-ignore context.imageSmoothingQuality = globalConfig.smoothing.quality; } + /** * Disables image smoothing on a context */ @@ -18,16 +22,19 @@ export function disableImageSmoothing(context: CanvasRenderingContext2D) { context.imageSmoothingEnabled = false; context.webkitImageSmoothingEnabled = false; } + export type CanvasCacheEntry = { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D; }; const registeredCanvas: Array = []; + /** * Buckets for each width * height combination */ const freeCanvasBuckets: Map> = new Map(); + /** * Track statistics */ @@ -38,12 +45,14 @@ const stats = { numReused: 0, numCreated: 0, }; + export function getBufferVramUsageBytes(canvas: HTMLCanvasElement) { assert(canvas, "no canvas given"); assert(Number.isFinite(canvas.width), "bad canvas width: " + canvas.width); assert(Number.isFinite(canvas.height), "bad canvas height" + canvas.height); return canvas.width * canvas.height * 4; } + /** * Returns stats on the allocated buffers */ @@ -52,12 +61,14 @@ export function getBufferStats() { freeCanvasBuckets.forEach(bucket => { numBuffersFree += bucket.length; }); + return { ...stats, backlogKeys: freeCanvasBuckets.size, backlogSize: numBuffersFree, }; } + /** * Clears the backlog buffers if they grew too much */ @@ -72,14 +83,15 @@ export function clearBufferBacklog() { } }); } + /** * Creates a new offscreen buffer - * {} */ -export function makeOffscreenBuffer(w: Number, h: Number, { smooth = true, reusable = true, label = "buffer" }): [ - HTMLCanvasElement, - CanvasRenderingContext2D -] { +export function makeOffscreenBuffer( + w: number, + h: number, + { smooth = true, reusable = true, label = "buffer" } +): [HTMLCanvasElement, CanvasRenderingContext2D] { assert(w > 0 && h > 0, "W or H < 0"); if (w % 1 !== 0 || h % 1 !== 0) { // console.warn("Subpixel offscreen buffer size:", w, h); @@ -89,43 +101,56 @@ export function makeOffscreenBuffer(w: Number, h: Number, { smooth = true, reusa 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; + // Ok, search in cache first const bucket = freeCanvasBuckets.get(w * h) || []; + for (let i = 0; i < bucket.length; ++i) { const { canvas: useableCanvas, context: useableContext } = bucket[i]; if (useableCanvas.width === w && useableCanvas.height === h) { // Ok we found one canvas = useableCanvas; context = useableContext; + // Restore past state context.restore(); context.save(); context.clearRect(0, 0, canvas.width, canvas.height); + delete canvas.style.width; delete canvas.style.height; + stats.numReused++; stats.backlogVramUsage -= getBufferVramUsageBytes(canvas); fastArrayDelete(bucket, i); break; } } + // None found , create new one if (!canvas) { canvas = document.createElement("canvas"); context = canvas.getContext("2d" /*, { alpha } */); + stats.numCreated++; + canvas.width = w; canvas.height = h; + // Initial state context.save(); + canvas.addEventListener("webglcontextlost", () => { console.warn("canvas::webglcontextlost", canvas); // @ts-ignore @@ -139,35 +164,42 @@ export function makeOffscreenBuffer(w: Number, h: Number, { smooth = true, reusa } // @ts-ignore canvas._contextLost = false; + // @ts-ignore canvas.label = label; if (smooth) { enableImageSmoothing(context); - } - else { + } else { disableImageSmoothing(context); } + if (reusable) { registerCanvas(canvas, context); } + return [canvas, context]; } + /** * Frees a canvas */ -export function registerCanvas(canvas: HTMLCanvasElement, context) { +export function registerCanvas(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D) { registeredCanvas.push({ canvas, context }); + stats.bufferCount += 1; const bytesUsed = getBufferVramUsageBytes(canvas); stats.vramUsage += bytesUsed; } + /** * Frees a canvas */ export function freeCanvas(canvas: HTMLCanvasElement) { 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; @@ -175,20 +207,23 @@ export function freeCanvas(canvas: HTMLCanvasElement) { break; } } + if (index < 0) { logger.error("Tried to free unregistered canvas of size", canvas.width, canvas.height); return; } fastArrayDelete(registeredCanvas, index); + const key = canvas.width * canvas.height; const bucket = freeCanvasBuckets.get(key); if (bucket) { bucket.push(data); - } - else { + } else { freeCanvasBuckets.set(key, [data]); } + stats.bufferCount -= 1; + const bytesUsed = getBufferVramUsageBytes(canvas); stats.vramUsage -= bytesUsed; stats.backlogVramUsage += bytesUsed; diff --git a/src/ts/core/click_detector.ts b/src/ts/core/click_detector.ts index a64f98d4..2cfe77c0 100644 --- a/src/ts/core/click_detector.ts +++ b/src/ts/core/click_detector.ts @@ -5,19 +5,26 @@ import { Vector } from "./vector"; import { IS_MOBILE, SUPPORT_TOUCH } from "./config"; import { SOUNDS } from "../platform/sound"; import { GLOBAL_APP } from "./globals"; + const logger = createLogger("click_detector"); + export const MAX_MOVE_DISTANCE_PX = IS_MOBILE ? 20 : 80; + // For debugging const registerClickDetectors = G_IS_DEV && true; if (registerClickDetectors) { - window.activeClickDetectors = []; + window.activeClickDetectors = []; } + // Store active click detectors so we can cancel them const ongoingClickDetectors: Array = []; + // Store when the last touch event was registered, to avoid accepting a touch *and* a click event + export let clickDetectorGlobals = { lastTouchTime: -1000, }; + export type ClickDetectorConstructorArgs = { consumeEvents?: boolean; preventDefault?: boolean; @@ -32,30 +39,67 @@ export type ClickDetectorConstructorArgs = { // Detects clicks export class ClickDetector { public clickDownPosition = null; - public consumeEvents = consumeEvents; - public preventDefault = preventDefault; - public applyCssClass = applyCssClass; - public captureTouchmove = captureTouchmove; - public targetOnly = targetOnly; - public clickSound = clickSound; - public maxDistance = maxDistance; - public preventClick = preventClick; - public click = new Signal(); - public rightClick = new Signal(); - public touchstart = new Signal(); - public touchmove = new Signal(); - public touchend = new Signal(); - public touchcancel = new Signal(); - public touchstartSimple = new Signal(); - public touchmoveSimple = new Signal(); - public touchendSimple = new Signal(); - public clickStartTime = null; + public consumeEvents: boolean; + public preventDefault: boolean; + public applyCssClass: string; + public captureTouchmove: boolean; + public targetOnly: boolean; + public clickSound: string; + public maxDistance: number; + public preventClick: boolean; + + // Bound Methods + public handlerTouchStart = this.internalOnPointerDown.bind(this); + public handlerTouchEnd = this.internalOnPointerEnd.bind(this); + public handlerTouchMove = this.internalOnPointerMove.bind(this); + public handlerTouchCancel = this.internalOnTouchCancel.bind(this); + public handlerPreventClick = this.internalPreventClick.bind(this); + + // Signals + public click = new Signal<[pos: Vector, ev?: TouchEvent | MouseEvent]>(); + public rightClick = new Signal<[pos: Vector, ev: MouseEvent]>(); + public touchstart = new Signal<[ev: TouchEvent | MouseEvent]>(); + public touchmove = new Signal<[ev: TouchEvent | MouseEvent]>(); + public touchend = new Signal<[ev: TouchEvent | MouseEvent]>(); + public touchcancel = new Signal<[ev: TouchEvent | MouseEvent]>(); + + public touchstartSimple = new Signal<[x: number, y: number]>(); + public touchmoveSimple = new Signal<[x: number, y: number]>(); + public touchendSimple = new Signal<[ev?: TouchEvent | MouseEvent]>(); + + public clickStartTime: number = null; + public cancelled = false; - constructor(element, { consumeEvents = false, preventDefault = true, applyCssClass = "pressed", captureTouchmove = false, targetOnly = false, maxDistance = MAX_MOVE_DISTANCE_PX, clickSound = SOUNDS.uiClick, preventClick = false, }) { + public element?: HTMLElement; + + constructor( + element: Element, + { + consumeEvents = false, + preventDefault = true, + applyCssClass = "pressed", + captureTouchmove = false, + targetOnly = false, + maxDistance = MAX_MOVE_DISTANCE_PX, + clickSound = SOUNDS.uiClick, + preventClick = false, + } + ) { assert(element, "No element given!"); - this.internalBindTo(element as HTMLElement)); + + this.consumeEvents = consumeEvents; + this.preventDefault = preventDefault; + this.applyCssClass = applyCssClass; + this.captureTouchmove = captureTouchmove; + this.targetOnly = targetOnly; + this.clickSound = clickSound; + this.maxDistance = maxDistance; + this.preventClick = preventClick; + + this.internalBindTo(element as HTMLElement); } + /** * Cleans up all event listeners of this detector */ @@ -65,20 +109,22 @@ export class ClickDetector { const index = window.activeClickDetectors.indexOf(this); if (index < 0) { logger.error("Click detector cleanup but is not active"); - } - else { + } else { window.activeClickDetectors.splice(index, 1); } } const options = this.internalGetEventListenerOptions(); + if (SUPPORT_TOUCH) { 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) { if (SUPPORT_TOUCH) { this.element.removeEventListener("touchmove", this.handlerTouchMove, options); @@ -88,19 +134,23 @@ export class ClickDetector { if (this.preventClick) { this.element.removeEventListener("click", this.handlerPreventClick, options); } + this.click.removeAll(); this.touchstart.removeAll(); this.touchmove.removeAll(); this.touchend.removeAll(); this.touchcancel.removeAll(); + this.element = null; } } // INTERNAL METHODS - internalPreventClick(event: Event) { + + internalPreventClick(event: Event) { window.focus(); event.preventDefault(); } + /** * Internal method to get the options to pass to an event listener */ @@ -110,17 +160,14 @@ export class ClickDetector { passive: !this.preventDefault, }; } + /** * Binds the click detector to an element */ internalBindTo(element: HTMLElement) { 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); + if (this.preventClick) { - this.handlerPreventClick = this.internalPreventClick.bind(this); element.addEventListener("click", this.handlerPreventClick, options); } if (SUPPORT_TOUCH) { @@ -142,12 +189,14 @@ export class ClickDetector { } 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 */ @@ -156,52 +205,70 @@ export class ClickDetector { // 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 = performance.now(); + // console.log("Got touches", event.targetTouches.length, "vs", expectedRemainingTouches); if (event.targetTouches.length !== expectedRemainingTouches) { return false; } } + if (event instanceof MouseEvent) { if (performance.now() - clickDetectorGlobals.lastTouchTime < 1000.0) { return false; } } + return true; } + /** - * Extracts the mous position from an event - * {} The client space position + * Extracts the mouse position from an event + * @param event The client space position */ static extractPointerPosition(event: TouchEvent | MouseEvent): Vector { if (window.TouchEvent && event instanceof TouchEvent) { if (event.changedTouches.length !== 1) { - logger.warn("Got unexpected target touches:", event.targetTouches.length, "->", event.targetTouches); + 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 + * Cancels all ongoing events on this detector */ cancelOngoingEvents() { if (this.applyCssClass && this.element) { @@ -212,16 +279,19 @@ export class ClickDetector { this.cancelled = true; fastArrayDeleteValueIfContained(ongoingClickDetectors, this); } + /** * Internal pointer down handler */ internalOnPointerDown(event: TouchEvent | MouseEvent) { window.focus(); + if (!this.internalEventPreHandler(event, 1)) { return false; } - const position = this.constructor as typeof ClickDetector).extractPointerPosition(event); + const position = (this.constructor as typeof ClickDetector).extractPointerPosition(event); + if (event instanceof MouseEvent) { const isRightClick = event.button === 2; if (isRightClick) { @@ -232,33 +302,40 @@ export class ClickDetector { 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 = performance.now(); 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 { + } 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) { GLOBAL_APP.sound.playUiSound(this.clickSound); } + return false; } + /** * Internal pointer move handler */ @@ -267,61 +344,68 @@ export class ClickDetector { return false; } this.touchmove.dispatch(event); - - const pos = this.constructor as typeof ClickDetector).extractPointerPosition(event); + const pos = (this.constructor as typeof ClickDetector).extractPointerPosition(event); this.touchmoveSimple.dispatch(pos.x, pos.y); return false; } + /** * Internal pointer end handler */ internalOnPointerEnd(event: TouchEvent | MouseEvent) { window.focus(); + 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.button === 2; 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 { + } 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 = this.constructor as typeof ClickDetector).extractPointerPosition(event); + const pos = (this.constructor as typeof ClickDetector).extractPointerPosition(event); const distance = pos.distance(this.clickDownPosition); if (!IS_MOBILE || distance <= this.maxDistance) { dispatchClick = true; dispatchClickPos = pos; - } - else { + } else { console.warn("[ClickDetector] Touch does not count as click:", "(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) { @@ -332,6 +416,7 @@ export class ClickDetector { } return false; } + /** * Internal touch cancel handler */ @@ -339,10 +424,12 @@ export class ClickDetector { 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); diff --git a/src/ts/core/config.local.template.ts b/src/ts/core/config.local.template.ts index 41d19346..62925b13 100644 --- a/src/ts/core/config.local.template.ts +++ b/src/ts/core/config.local.template.ts @@ -1,126 +1,126 @@ export default { -// You can set any debug options here! -/* dev:start */ -// ----------------------------------------------------------------------------------- -// Quickly enters the game and skips the main menu - good for fast iterating -// fastGameEnter: true, -// ----------------------------------------------------------------------------------- -// Skips any delays like transitions between states and such -// noArtificialDelays: true, -// ----------------------------------------------------------------------------------- -// Disables writing of savegames, useful for testing the same savegame over and over -// disableSavegameWrite: true, -// ----------------------------------------------------------------------------------- -// Shows bounds of all entities -// showEntityBounds: true, -// ----------------------------------------------------------------------------------- -// Shows arrows for every ejector / acceptor -// showAcceptorEjectors: true, -// ----------------------------------------------------------------------------------- -// Disables the music (Overrides any setting, can cause weird behaviour) -// disableMusic: true, -// ----------------------------------------------------------------------------------- -// Do not render static map entities (=most buildings) -// doNotRenderStatics: true, -// ----------------------------------------------------------------------------------- -// Allow to zoom freely without limits -// disableZoomLimits: true, -// ----------------------------------------------------------------------------------- -// All rewards can be unlocked by passing just 1 of any shape -// rewardsInstant: true, -// ----------------------------------------------------------------------------------- -// Unlocks all buildings -// allBuildingsUnlocked: true, -// ----------------------------------------------------------------------------------- -// Disables cost of blueprints -// blueprintsNoCost: true, -// ----------------------------------------------------------------------------------- -// Disables cost of upgrades -// upgradesNoCost: true, -// ----------------------------------------------------------------------------------- -// Disables the dialog when completing a level -// disableUnlockDialog: true, -// ----------------------------------------------------------------------------------- -// Disables the simulation - This effectively pauses the game. -// disableLogicTicks: true, -// ----------------------------------------------------------------------------------- -// Test the rendering if everything is clipped out properly -// testClipping: true, -// ----------------------------------------------------------------------------------- -// Allows to render slower, useful for recording at half speed to avoid stuttering -// framePausesBetweenTicks: 250, -// ----------------------------------------------------------------------------------- -// Replace all translations with emojis to see which texts are translateable -// testTranslations: true, -// ----------------------------------------------------------------------------------- -// Enables an inspector which shows information about the entity below the cursor -// enableEntityInspector: true, -// ----------------------------------------------------------------------------------- -// Enables ads in the local build (normally they are deactivated there) -// testAds: true, -// ----------------------------------------------------------------------------------- -// Allows unlocked achievements to be logged to console in the local build -// testAchievements: true, -// ----------------------------------------------------------------------------------- -// Enables use of (some) existing flags within the puzzle mode context -// testPuzzleMode: true, -// ----------------------------------------------------------------------------------- -// Disables the automatic switch to an overview when zooming out -// disableMapOverview: true, -// ----------------------------------------------------------------------------------- -// Disables the notification when there are new entries in the changelog since last played -// disableUpgradeNotification: true, -// ----------------------------------------------------------------------------------- -// Makes belts almost infinitely fast -// instantBelts: true, -// ----------------------------------------------------------------------------------- -// Makes item processors almost infinitely fast -// instantProcessors: true, -// ----------------------------------------------------------------------------------- -// Makes miners almost infinitely fast -// instantMiners: true, -// ----------------------------------------------------------------------------------- -// When using fastGameEnter, controls whether a new game is started or the last one is resumed -// resumeGameOnFastEnter: true, -// ----------------------------------------------------------------------------------- -// Special option used to render the trailer -// renderForTrailer: true, -// ----------------------------------------------------------------------------------- -// Whether to render changes -// renderChanges: true, -// ----------------------------------------------------------------------------------- -// Whether to render belt paths -// renderBeltPaths: true, -// ----------------------------------------------------------------------------------- -// Whether to check belt paths -// checkBeltPaths: true, -// ----------------------------------------------------------------------------------- -// Whether to items / s instead of items / m in stats -// detailedStatistics: true, -// ----------------------------------------------------------------------------------- -// Shows detailed information about which atlas is used -// showAtlasInfo: true, -// ----------------------------------------------------------------------------------- -// Renders the rotation of all wires -// renderWireRotations: true, -// ----------------------------------------------------------------------------------- -// Renders information about wire networks -// renderWireNetworkInfos: true, -// ----------------------------------------------------------------------------------- -// Disables ejector animations and processing -// disableEjectorProcessing: true, -// ----------------------------------------------------------------------------------- -// Allows manual ticking -// manualTickOnly: true, -// ----------------------------------------------------------------------------------- -// Disables slow asserts, useful for debugging performance -// disableSlowAsserts: true, -// ----------------------------------------------------------------------------------- -// Allows to load a mod from an external source for developing it -// externalModUrl: "http://localhost:3005/combined.js", -// ----------------------------------------------------------------------------------- -// Visualizes the shape grouping on belts -// showShapeGrouping: true -// ----------------------------------------------------------------------------------- -/* dev:end */ + // You can set any debug options here! + /* dev:start */ + // ----------------------------------------------------------------------------------- + // Quickly enters the game and skips the main menu - good for fast iterating + // fastGameEnter: true, + // ----------------------------------------------------------------------------------- + // Skips any delays like transitions between states and such + // noArtificialDelays: true, + // ----------------------------------------------------------------------------------- + // Disables writing of savegames, useful for testing the same savegame over and over + // disableSavegameWrite: true, + // ----------------------------------------------------------------------------------- + // Shows bounds of all entities + // showEntityBounds: true, + // ----------------------------------------------------------------------------------- + // Shows arrows for every ejector / acceptor + // showAcceptorEjectors: true, + // ----------------------------------------------------------------------------------- + // Disables the music (Overrides any setting, can cause weird behaviour) + // disableMusic: true, + // ----------------------------------------------------------------------------------- + // Do not render static map entities (=most buildings) + // doNotRenderStatics: true, + // ----------------------------------------------------------------------------------- + // Allow to zoom freely without limits + // disableZoomLimits: true, + // ----------------------------------------------------------------------------------- + // All rewards can be unlocked by passing just 1 of any shape + // rewardsInstant: true, + // ----------------------------------------------------------------------------------- + // Unlocks all buildings + // allBuildingsUnlocked: true, + // ----------------------------------------------------------------------------------- + // Disables cost of blueprints + // blueprintsNoCost: true, + // ----------------------------------------------------------------------------------- + // Disables cost of upgrades + // upgradesNoCost: true, + // ----------------------------------------------------------------------------------- + // Disables the dialog when completing a level + // disableUnlockDialog: true, + // ----------------------------------------------------------------------------------- + // Disables the simulation - This effectively pauses the game. + // disableLogicTicks: true, + // ----------------------------------------------------------------------------------- + // Test the rendering if everything is clipped out properly + // testClipping: true, + // ----------------------------------------------------------------------------------- + // Allows to render slower, useful for recording at half speed to avoid stuttering + // framePausesBetweenTicks: 250, + // ----------------------------------------------------------------------------------- + // Replace all translations with emojis to see which texts are translateable + // testTranslations: true, + // ----------------------------------------------------------------------------------- + // Enables an inspector which shows information about the entity below the cursor + // enableEntityInspector: true, + // ----------------------------------------------------------------------------------- + // Enables ads in the local build (normally they are deactivated there) + // testAds: true, + // ----------------------------------------------------------------------------------- + // Allows unlocked achievements to be logged to console in the local build + // testAchievements: true, + // ----------------------------------------------------------------------------------- + // Enables use of (some) existing flags within the puzzle mode context + // testPuzzleMode: true, + // ----------------------------------------------------------------------------------- + // Disables the automatic switch to an overview when zooming out + // disableMapOverview: true, + // ----------------------------------------------------------------------------------- + // Disables the notification when there are new entries in the changelog since last played + // disableUpgradeNotification: true, + // ----------------------------------------------------------------------------------- + // Makes belts almost infinitely fast + // instantBelts: true, + // ----------------------------------------------------------------------------------- + // Makes item processors almost infinitely fast + // instantProcessors: true, + // ----------------------------------------------------------------------------------- + // Makes miners almost infinitely fast + // instantMiners: true, + // ----------------------------------------------------------------------------------- + // When using fastGameEnter, controls whether a new game is started or the last one is resumed + // resumeGameOnFastEnter: true, + // ----------------------------------------------------------------------------------- + // Special option used to render the trailer + // renderForTrailer: true, + // ----------------------------------------------------------------------------------- + // Whether to render changes + // renderChanges: true, + // ----------------------------------------------------------------------------------- + // Whether to render belt paths + // renderBeltPaths: true, + // ----------------------------------------------------------------------------------- + // Whether to check belt paths + // checkBeltPaths: true, + // ----------------------------------------------------------------------------------- + // Whether to items / s instead of items / m in stats + // detailedStatistics: true, + // ----------------------------------------------------------------------------------- + // Shows detailed information about which atlas is used + // showAtlasInfo: true, + // ----------------------------------------------------------------------------------- + // Renders the rotation of all wires + // renderWireRotations: true, + // ----------------------------------------------------------------------------------- + // Renders information about wire networks + // renderWireNetworkInfos: true, + // ----------------------------------------------------------------------------------- + // Disables ejector animations and processing + // disableEjectorProcessing: true, + // ----------------------------------------------------------------------------------- + // Allows manual ticking + // manualTickOnly: true, + // ----------------------------------------------------------------------------------- + // Disables slow asserts, useful for debugging performance + // disableSlowAsserts: true, + // ----------------------------------------------------------------------------------- + // Allows to load a mod from an external source for developing it + // externalModUrl: "http://localhost:3005/combined.js", + // ----------------------------------------------------------------------------------- + // Visualizes the shape grouping on belts + // showShapeGrouping: true + // ----------------------------------------------------------------------------------- + /* dev:end */ }; diff --git a/src/ts/core/config.ts b/src/ts/core/config.ts index a7ab9099..0fb5d558 100644 --- a/src/ts/core/config.ts +++ b/src/ts/core/config.ts @@ -1,21 +1,26 @@ -/* typehints:start */ import type { Application } from "../application"; -/* typehints:end */ -export const IS_DEBUG = G_IS_DEV && + +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; + export const SUPPORT_TOUCH = false; + const smoothCanvas = true; + export const THIRDPARTY_URLS = { discord: "https://discord.gg/HN7EVzV", github: "https://github.com/tobspr-games/shapez.io", reddit: "https://www.reddit.com/r/shapezio", shapeViewer: "https://viewer.shapez.io", + twitter: "https://twitter.com/tobspr", patreon: "https://www.patreon.com/tobsprgames", privacyPolicy: "https://tobspr.io/privacy.html", + standaloneCampaignLink: "https://get.shapez.io/bundle/$campaign", puzzleDlcStorePage: "https://get.shapez.io/mm_puzzle_dlc?target=dlc", levelTutorialVideos: { @@ -23,52 +28,69 @@ export const THIRDPARTY_URLS = { 25: "https://www.youtube.com/watch?v=7OCV1g40Iew&", 26: "https://www.youtube.com/watch?v=gfm6dS1dCoY", }, + modBrowser: "https://shapez.mod.io/", }; + export function openStandaloneLink(app: Application, campaign: string) { const discount = globalConfig.currentDiscount > 0 ? "_discount" + globalConfig.currentDiscount : ""; const event = campaign + discount; app.platformWrapper.openExternalLink(THIRDPARTY_URLS.standaloneCampaignLink.replace("$campaign", event)); app.gameAnalytics.noteMinor("g.stdlink." + event); } + 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.5, shapesSharpness: 1.3, + // Achievements achievementSliceDuration: 10, + // Production analytics statisticsGraphDpi: 2.5, statisticsGraphSlices: 100, analyticsSliceDurationSeconds: G_IS_DEV ? 1 : 10, + minimumTickRate: 25, maximumTickRate: 500, + // Map mapChunkSize: 16, chunkAggregateSize: 4, mapChunkOverviewMinZoom: 0.9, mapChunkWorldSize: null, + maxBeltShapeBundleSize: 20, + // Belt speeds // NOTICE: Update webpack.production.config too! beltSpeedItemsPerSecond: 2, minerSpeedItemsPerSecond: 0, + defaultItemDiameter: 20, + itemSpacingOnBelts: 0.63, + wiresSpeedItemsPerSecond: 6, + undergroundBeltMaxTilesByTier: [5, 9], + readerAnalyzeIntervalSeconds: 10, + goalAcceptorItemsRequired: 12, goalAcceptorsPerProducer: 5, puzzleModeSpeed: 3, puzzleMinBoundsSize: 2, puzzleMaxBoundsSize: 20, puzzleValidationDurationSeconds: 30, + buildingSpeeds: { cutter: 1 / 4, cutterQuad: 1 / 4, @@ -81,39 +103,53 @@ export const globalConfig = { mixer: 1 / 5, stacker: 1 / 8, }, + // Zooming initialZoom: 1.9, minZoomLevel: 0.1, maxZoomLevel: 3, + // Global game speed gameSpeed: 1, + warmupTimeSecondsFast: 0.25, warmupTimeSecondsRegular: 0.25, + smoothing: { smoothMainCanvas: smoothCanvas && true, quality: "low", // Low is CRUCIAL for mobile performance! }, + rendering: {}, debug: require("./config.local").default, + currentDiscount: 0, + // Secret vars info: { // Binary file salt file: "Ec'])@^+*9zMevK3uMV4432x9%iK'=", + // Savegame salt sgSalt: "}95Q3%8/.837Lqym_BJx%q7)pAHJbF", + // Analytics key analyticsApiKey: "baf6a50f0cc7dfdec5a0e21c88a1c69a4b34bc4a", }, }; + export const IS_MOBILE = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); + // Automatic calculations globalConfig.minerSpeedItemsPerSecond = globalConfig.beltSpeedItemsPerSecond / 5; + globalConfig.mapChunkWorldSize = globalConfig.mapChunkSize * globalConfig.tileSize; + // Dynamic calculations if (globalConfig.debug.disableMapOverview) { globalConfig.mapChunkOverviewMinZoom = 0; } + // Stuff for making the trailer if (G_IS_DEV && globalConfig.debug.renderForTrailer) { globalConfig.debug.framePausesBetweenTicks = 32; @@ -124,9 +160,11 @@ if (G_IS_DEV && globalConfig.debug.renderForTrailer) { globalConfig.debug.disableSavegameWrite = true; // globalConfig.beltSpeedItemsPerSecond *= 2; } + if (globalConfig.debug.fastGameEnter) { globalConfig.debug.noArtificialDelays = true; } + if (G_IS_DEV && globalConfig.debug.noArtificialDelays) { globalConfig.warmupTimeSecondsFast = 0; globalConfig.warmupTimeSecondsRegular = 0; diff --git a/src/ts/core/dpi_manager.ts b/src/ts/core/dpi_manager.ts index fd4d1fc5..1e219afd 100644 --- a/src/ts/core/dpi_manager.ts +++ b/src/ts/core/dpi_manager.ts @@ -1,61 +1,64 @@ import { globalConfig } from "./config"; import { round1Digit, round2Digits } from "./utils"; + /** * Returns the current dpi - * {} */ export function getDeviceDPI(): number { return window.devicePixelRatio || 1; } + /** - * - * {} Smoothed dpi + * @param dpi Smoothed dpi */ export function smoothenDpi(dpi: number): number { if (dpi < 0.05) { return 0.05; - } - else if (dpi < 0.2) { + } else if (dpi < 0.2) { return round2Digits(Math.round(dpi / 0.04) * 0.04); - } - else if (dpi < 1) { + } else if (dpi < 1) { return round1Digit(Math.round(dpi / 0.1) * 0.1); - } - else if (dpi < 4) { + } else if (dpi < 4) { return round1Digit(Math.round(dpi / 0.5) * 0.5); - } - else { + } else { return 4; } } + // Initial dpi // setDPIMultiplicator(1); + /** - * Prepares a context for hihg dpi rendering + * Prepares a context for high dpi rendering */ export function prepareHighDPIContext(context: CanvasRenderingContext2D, 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 { + } else { context.imageSmoothingEnabled = false; context.webkitImageSmoothingEnabled = false; } } + /** * Resizes a high dpi canvas */ export function resizeHighDPICanvas(canvas: HTMLCanvasElement, w: number, h: number, 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; @@ -65,6 +68,7 @@ export function resizeHighDPICanvas(canvas: HTMLCanvasElement, w: number, h: num prepareHighDPIContext(canvas.getContext("2d"), smooth); } } + /** * Resizes a canvas */ @@ -81,10 +85,16 @@ export function resizeCanvas(canvas: HTMLCanvasElement, w: number, h: number, se // console.log("Resizing canvas to", actualW, "x", actualH); } } + /** * Resizes a canvas and makes sure its cleared */ -export function resizeCanvasAndClear(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D, w: number, h: number) { +export function resizeCanvasAndClear( + canvas: HTMLCanvasElement, + context: CanvasRenderingContext2D, + w: number, + h: number +) { const actualW = Math.ceil(w); const actualH = Math.ceil(h); if (actualW !== canvas.width || actualH !== canvas.height) { @@ -93,8 +103,7 @@ export function resizeCanvasAndClear(canvas: HTMLCanvasElement, context: CanvasR canvas.style.width = actualW + "px"; canvas.style.height = actualH + "px"; // console.log("Resizing canvas to", actualW, "x", actualH); - } - else { + } else { context.clearRect(0, 0, actualW, actualH); } } diff --git a/src/ts/core/draw_parameters.ts b/src/ts/core/draw_parameters.ts index 8b0ea6fb..cfdf81c2 100644 --- a/src/ts/core/draw_parameters.ts +++ b/src/ts/core/draw_parameters.ts @@ -1,14 +1,20 @@ import { globalConfig } from "./config"; + export type GameRoot = import("../game/root").GameRoot; export type Rectangle = import("./rectangle").Rectangle; export class DrawParameters { - public context: CanvasRenderingContext2D = context; - public visibleRect: Rectangle = visibleRect; - public desiredAtlasScale: string = desiredAtlasScale; - public zoomLevel: number = zoomLevel; - public root: GameRoot = root; + public context: CanvasRenderingContext2D; + public visibleRect: Rectangle; + public desiredAtlasScale: string; + public zoomLevel: number; + public root: GameRoot; constructor({ context, visibleRect, desiredAtlasScale, zoomLevel, root }) { + this.context = context; + this.visibleRect = visibleRect; + this.desiredAtlasScale = desiredAtlasScale; + this.zoomLevel = zoomLevel; + this.root = root; } } diff --git a/src/ts/core/draw_utils.ts b/src/ts/core/draw_utils.ts index 16bd6f3f..a4821286 100644 --- a/src/ts/core/draw_utils.ts +++ b/src/ts/core/draw_utils.ts @@ -1,63 +1,94 @@ +import type { AtlasSprite } from "./sprites"; +import type { DrawParameters } from "./draw_parameters"; -export type AtlasSprite = import("./sprites").AtlasSprite; -export type DrawParameters = import("./draw_parameters").DrawParameters; import { globalConfig } from "./config"; import { createLogger } from "./logging"; import { Rectangle } from "./rectangle"; + const logger = createLogger("draw_utils"); + export function initDrawUtils() { CanvasRenderingContext2D.prototype.beginRoundedRect = function (x, y, w, h, r) { this.beginPath(); + if (r < 0.05) { this.rect(x, y, w, h); return; } + if (w < 2 * r) { r = w / 2; } + if (h < 2 * r) { r = h / 2; } + 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); }; + CanvasRenderingContext2D.prototype.beginCircle = function (x, y, r) { this.beginPath(); + if (r < 0.05) { this.rect(x, y, 1, 1); return; } + this.arc(x, y, r, 0, 2.0 * Math.PI); }; } -export function drawRotatedSprite({ parameters, sprite, x, y, angle, size, offsetX = 0, offsetY = 0 }: { + +export function drawRotatedSprite({ + parameters, + sprite, + x, + y, + angle, + size, + offsetX = 0, + offsetY = 0, +}: { parameters: DrawParameters; sprite: AtlasSprite; x: number; y: number; angle: number; size: number; - offsetX: number=; - offsetY: number=; + offsetX: number; + offsetY: number; }) { if (angle === 0) { sprite.drawCachedCentered(parameters, x + offsetX, y + offsetY, size); return; } + 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); } + let warningsShown = 0; + /** * Draws a sprite with clipping */ -export function drawSpriteClipped({ parameters, sprite, x, y, w, h, originalW, originalH }: { +export function drawSpriteClipped({ + parameters, + sprite, + x, + y, + w, + h, + originalW, + originalH, +}: { parameters: DrawParameters; sprite: HTMLCanvasElement; x: number; @@ -72,7 +103,11 @@ export function drawSpriteClipped({ parameters, sprite, x, y, w, h, originalW, o if (!intersection) { // Clipped if (++warningsShown % 200 === 1) { - logger.warn("Sprite drawn clipped but it's not on screen - perform culling before (", warningsShown, "warnings)"); + logger.warn( + "Sprite drawn clipped but it's not on screen - perform culling before (", + warningsShown, + "warnings)" + ); } if (G_IS_DEV && globalConfig.debug.testClipping) { parameters.context.fillStyle = "yellow"; @@ -80,9 +115,19 @@ export function drawSpriteClipped({ parameters, sprite, x, y, w, h, originalW, o } return; } - parameters.context.drawImage(sprite, - // src pos and size - ((intersection.x - x) / w) * originalW, ((intersection.y - y) / h) * originalH, (originalW * intersection.w) / w, (originalH * intersection.h) / h, - // dest pos and size - intersection.x, intersection.y, intersection.w, intersection.h); + + parameters.context.drawImage( + sprite, + // src pos and size + ((intersection.x - x) / w) * originalW, + ((intersection.y - y) / h) * originalH, + (originalW * intersection.w) / w, + (originalH * intersection.h) / h, + + // dest pos and size + intersection.x, + intersection.y, + intersection.w, + intersection.h + ); } diff --git a/src/ts/core/explained_result.ts b/src/ts/core/explained_result.ts index b5d68fef..b5fbd386 100644 --- a/src/ts/core/explained_result.ts +++ b/src/ts/core/explained_result.ts @@ -1,28 +1,36 @@ export class ExplainedResult { - public result: boolean = result; - public reason: string = reason; + public result: boolean; + public reason: string; + + constructor(result = true, reason: string = null, additionalProps = {}) { + this.result = result; + this.reason = reason; - constructor(result = true, reason = null, additionalProps = {}) { // 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) { + + static bad(reason?: string, additionalProps?: object) { return new ExplainedResult(false, reason, additionalProps); } - static requireAll(...args) { + + static requireAll(...args: (() => ExplainedResult)[]) { for (let i = 0; i < args.length; ++i) { - const subResult = args[i].call(); + const subResult = args[i].call(undefined); if (!subResult.isGood()) { return subResult; } diff --git a/src/ts/core/factory.ts b/src/ts/core/factory.ts index cdb34713..08cb1e15 100644 --- a/src/ts/core/factory.ts +++ b/src/ts/core/factory.ts @@ -1,18 +1,20 @@ import { createLogger } from "./logging"; + const logger = createLogger("factory"); + // simple factory pattern -export class Factory { - public id = id; - public entries = []; - public entryIds = []; +export class Factory string }> { + public entries: T[] = []; + public entryIds: string[] = []; public idToEntry = {}; - constructor(id) { - } + constructor(public id?: string) {} + getId() { return this.id; } - register(entry) { + + register(entry: T) { // Extract id const id = entry.getId(); assert(id, "Factory: Invalid id for class: " + entry); @@ -23,18 +25,18 @@ export class Factory { this.entryIds.push(id); this.idToEntry[id] = entry; } + /** * Checks if a given id is registered - * {} */ hasId(id: string): boolean { return !!this.idToEntry[id]; } + /** * Finds an instance by a given id - * {} */ - findById(id: string): object { + findById(id: string): T { const entry = this.idToEntry[id]; if (!entry) { logger.error("Object with id", id, "is not registered on factory", this.id, "!"); @@ -43,23 +45,23 @@ export class Factory { } return entry; } + /** * Returns all entries - * {} */ - getEntries(): Array { + getEntries(): Array { return this.entries; } + /** * Returns all registered ids - * {} */ getAllIds(): Array { return this.entryIds; } + /** * Returns amount of stored entries - * {} */ getNumEntries(): number { return this.entries.length; diff --git a/src/ts/core/game_state.ts b/src/ts/core/game_state.ts index b5698290..d1f14344 100644 --- a/src/ts/core/game_state.ts +++ b/src/ts/core/game_state.ts @@ -1,48 +1,53 @@ -/* typehints:start */ import type { Application } from "../application"; import type { StateManager } from "./state_manager"; -/* typehints:end */ + import { globalConfig } from "./config"; -import { ClickDetector } from "./click_detector"; +import { ClickDetector, ClickDetectorConstructorArgs } 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 { - public key = key; +export abstract class GameState { public stateManager: StateManager = null; public app: Application = null; public fadingOut = false; public clickDetectors: Array = []; - public inputReciever = new InputReceiver("state-" + key); + public inputReceiver: InputReceiver; public asyncChannel = new RequestChannel(); + + public htmlElement: HTMLElement; + /** * Constructs a new state with the given id */ - - constructor(key) { - this.inputReciever.backButton.add(this.onBackButton, this); + constructor(public key) { + this.inputReceiver = new InputReceiver("state-" + key); + this.inputReceiver.backButton.add(this.onBackButton, this); } + //// GETTERS / HELPER METHODS //// + /** * Returns the states key - * {} */ getKey(): string { return this.key; } + /** * Returns the html element of the state - * {} */ getDivElement(): HTMLElement { return document.getElementById("state_" + this.key); } + /** * Transfers to a new state */ @@ -51,8 +56,10 @@ export class GameState { 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; @@ -63,16 +70,16 @@ export class GameState { setTimeout(() => { this.stateManager.moveToState(stateKey, payload); }, fadeTime); - } - else { + } else { this.stateManager.moveToState(stateKey, payload); } } + /** * 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. */ - trackClicks(element: Element, handler: function():void, args: import("./click_detector").ClickDetectorConstructorArgs= = {}) { + trackClicks(element: Element, handler: () => void, args: ClickDetectorConstructorArgs = {}) { const detector = new ClickDetector(element, args); detector.click.add(handler, this); if (G_IS_DEV) { @@ -82,50 +89,62 @@ export class GameState { } this.clickDetectors.push(detector); } + /** * Cancels all promises on the api as well as our async channel */ cancelAllAsyncOperations() { this.asyncChannel.cancelAll(); } + //// CALLBACKS //// + /** * Callback when entering the state, to be overriddemn */ - onEnter(payload: any) { } + onEnter(payload: any) {} + /** * Callback when leaving the state */ - onLeave() { } + onLeave() {} + /** * Callback when the app got paused (on android, this means in background) */ - onAppPause() { } + onAppPause() {} + /** * Callback when the app got resumed (on android, this means in foreground again) */ - onAppResume() { } + onAppResume() {} + /** * Render callback */ - onRender(dt: number) { } + onRender(dt: number) {} + /** * Background tick callback, called while the game is inactiev */ - onBackgroundTick(dt: number) { } + onBackgroundTick(dt: number) {} + /** * Called when the screen resized */ - onResized(w: number, h: number) { } + onResized(w: number, h: number) {} + /** * Internal backbutton handler, called when the hardware back button is pressed or * the escape key is pressed */ - onBackButton() { } + onBackButton() {} + //// INTERFACE //// + /** * Should return how many mulliseconds to fade in / out the state. Not recommended to override! - * {} Time in milliseconds to fade out + * @returns Time in milliseconds to fade out */ getInOutFadeTime(): number { if (globalConfig.debug.noArtificialDelays) { @@ -133,38 +152,36 @@ export class GameState { } return 200; } + /** * Should return whether to fade in the game state. This will then apply the right css classes * for the fadein. - * {} */ getHasFadeIn(): boolean { 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 - * {} */ getHasFadeOut(): boolean { return true; } + /** * Returns if this state should get paused if it does not have focus - * {} true to pause the updating of the game + * @returns true to pause the updating of the game */ getPauseOnFocusLost(): boolean { return true; } + /** * Should return the html code of the state. - * {} - * @abstract */ - getInnerHTML(): string { - abstract; - return ""; - } + abstract getInnerHTML(): string; + /** * Returns if the state has an unload confirmation, this is the * "Are you sure you want to leave the page" message. @@ -172,46 +189,52 @@ export class GameState { getHasUnloadConfirmation() { return false; } + /** * Should return the theme music for this state - * {} */ getThemeMusic(): string | null { return MUSIC.menu; } + /** * Should return true if the player is currently ingame - * {} */ getIsIngame(): boolean { return false; } + /** * Should return whether to clear the whole body content before entering the state. - * {} */ getRemovePreviousContent(): boolean { return true; } + //////////////////// + //// INTERNAL //// + /** * Internal callback from the manager. Do not override! */ - internalRegisterCallback(stateManager: StateManager, app) { + internalRegisterCallback(stateManager: StateManager, app: Application) { assert(stateManager, "No state manager"); assert(app, "No app"); this.stateManager = stateManager; this.app = app; } + /** * Internal callback when entering the state. Do not override! */ internalEnterCallback(payload: any, callCallback: boolean = true) { logSection(this.key, "#26a69a"); - this.app.inputMgr.pushReciever(this.inputReciever); + this.app.inputMgr.pushReciever(this.inputReceiver); + 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) { @@ -219,33 +242,38 @@ export class GameState { 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.app.inputMgr.popReciever(this.inputReceiver); this.internalCleanUpClickDetectors(); this.asyncChannel.cancelAll(); } + /** * Internal app pause callback */ internalOnAppPauseCallback() { this.onAppPause(); } + /** * Internal app resume callback */ internalOnAppResumeCallback() { this.onAppResume(); } + /** * Cleans up all click detectors */ @@ -257,16 +285,17 @@ export class GameState { this.clickDetectors = []; } } + /** * Internal method to get the HTML of the game state. - * {} */ internalGetFullHtml(): string { return this.getInnerHTML(); } + /** * Internal method to compute the time to fade in / out - * {} time to fade in / out in ms + * @returns time to fade in / out in ms */ internalGetFadeInOutTime(): number { if (G_IS_DEV && globalConfig.debug.fastGameEnter) { diff --git a/src/ts/core/global_registries.ts b/src/ts/core/global_registries.ts index 72612406..4ad4cf75 100644 --- a/src/ts/core/global_registries.ts +++ b/src/ts/core/global_registries.ts @@ -1,22 +1,27 @@ import { SingletonFactory } from "./singleton_factory"; import { Factory } from "./factory"; -export type BaseGameSpeed = import("../game/time/base_game_speed").BaseGameSpeed; -export type Component = import("../game/component").Component; -export type BaseItem = import("../game/base_item").BaseItem; -export type GameMode = import("../game/game_mode").GameMode; -export type MetaBuilding = import("../game/meta_building").MetaBuilding; -export let gMetaBuildingRegistry: SingletonFactoryTemplate = new SingletonFactory(); +import { BaseGameSpeed } from "../game/time/base_game_speed"; +import { Component } from "../game/component"; +import { BaseItem } from "../game/base_item"; +import { GameMode } from "../game/game_mode"; +import { MetaBuilding } from "../game/meta_building"; + +// These factories are here to remove circular dependencies + +export let gMetaBuildingRegistry = new SingletonFactory(); + export let gBuildingsByCategory: { [idx: string]: Array>; } = null; -export let gComponentRegistry: FactoryTemplate = new Factory("component"); -export let gGameModeRegistry: FactoryTemplate = new Factory("gameMode"); -export let gGameSpeedRegistry: FactoryTemplate = new Factory("gamespeed"); -export let gItemRegistry: FactoryTemplate = new Factory("item"); + +export let gComponentRegistry = new Factory("component"); +export let gGameModeRegistry = new Factory("gameMode"); +export let gGameSpeedRegistry = new Factory("gamespeed"); +export let gItemRegistry = new Factory("item"); + // Helpers -export function initBuildingsByCategory(buildings: { - [idx: string]: Array>; -}) { + +export function initBuildingsByCategory(buildings: { [idx: string]: Array> }) { gBuildingsByCategory = buildings; } diff --git a/src/ts/core/globals.ts b/src/ts/core/globals.ts index 923515c5..9febec34 100644 --- a/src/ts/core/globals.ts +++ b/src/ts/core/globals.ts @@ -1,15 +1,16 @@ -/* typehints:start */ import type { 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! */ export let GLOBAL_APP: Application = null; + export function setGlobalApp(app: Application) { assert(!GLOBAL_APP, "Create application twice!"); GLOBAL_APP = app; } + export const BUILD_OPTIONS = { HAVE_ASSERT: G_HAVE_ASSERT, APP_ENVIRONMENT: G_APP_ENVIRONMENT, diff --git a/src/ts/core/input_distributor.ts b/src/ts/core/input_distributor.ts index e3f0c2e6..3ed191f8 100644 --- a/src/ts/core/input_distributor.ts +++ b/src/ts/core/input_distributor.ts @@ -1,91 +1,120 @@ -/* typehints:start */ -import type { Application } from "../application"; +import { Application } from "../application"; import type { 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 { - public app = app; - public recieverStack: Array = []; - public filters: Array = []; + public app: Application; + public receiverStack: Array = []; + public filters: Array<(arg: any) => boolean> = []; + /** All keys which are currently down */ public keysDown = new Set(); - constructor(app) { + constructor(app: Application) { + this.app = app; this.bindToEvents(); } + /** * Attaches a new filter which can filter and reject events */ - installFilter(filter: function(: boolean):boolean) { + installFilter(filter: (arg: any) => boolean) { this.filters.push(filter); } + /** * Removes an attached filter */ - dismountFilter(filter: function(: boolean):boolean) { + dismountFilter(filter: (arg: any) => boolean) { fastArrayDeleteValue(this.filters, filter); } - pushReciever(reciever: InputReceiver) { + + pushReciever(reciever: InputReceiver) { 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)); + this.receiverStack.push(reciever); + + if (this.receiverStack.length > 10) { + logger.error( + "Reciever stack is huge, probably some dead receivers arround:", + this.receiverStack.map(x => x.context) + ); } } - popReciever(reciever: InputReceiver) { - if (this.recieverStack.indexOf(reciever) < 0) { + + popReciever(reciever: InputReceiver) { + if (this.receiverStack.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)); + if (this.receiverStack[this.receiverStack.length - 1] !== reciever) { + logger.warn( + "Popping reciever", + reciever.context, + "which is not on top of the stack. Stack is: ", + this.receiverStack.map(x => x.context) + ); } - arrayDeleteValue(this.recieverStack, reciever); + arrayDeleteValue(this.receiverStack, reciever); } - isRecieverAttached(reciever: InputReceiver) { - return this.recieverStack.indexOf(reciever) >= 0; + + isRecieverAttached(reciever: InputReceiver) { + return this.receiverStack.indexOf(reciever) >= 0; } - isRecieverOnTop(reciever: InputReceiver) { - return (this.isRecieverAttached(reciever) && - this.recieverStack[this.recieverStack.length - 1] === reciever); + + isRecieverOnTop(reciever: InputReceiver) { + return ( + this.isRecieverAttached(reciever) && + this.receiverStack[this.receiverStack.length - 1] === reciever + ); } - makeSureAttachedAndOnTop(reciever: InputReceiver) { + + makeSureAttachedAndOnTop(reciever: InputReceiver) { this.makeSureDetached(reciever); this.pushReciever(reciever); } - makeSureDetached(reciever: InputReceiver) { + + makeSureDetached(reciever: InputReceiver) { if (this.isRecieverAttached(reciever)) { - arrayDeleteValue(this.recieverStack, reciever); + arrayDeleteValue(this.receiverStack, reciever); } } - destroyReceiver(reciever: InputReceiver) { + + destroyReceiver(reciever: InputReceiver) { this.makeSureDetached(reciever); reciever.cleanup(); } + // Internal + getTopReciever() { - if (this.recieverStack.length > 0) { - return this.recieverStack[this.recieverStack.length - 1]; + if (this.receiverStack.length > 0) { + return this.receiverStack[this.receiverStack.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.handleKeyMouseDown.bind(this)); window.addEventListener("keyup", this.handleKeyMouseUp.bind(this)); + window.addEventListener("mousedown", this.handleKeyMouseDown.bind(this)); window.addEventListener("mouseup", this.handleKeyMouseUp.bind(this)); + window.addEventListener("blur", this.handleBlur.bind(this)); + document.addEventListener("paste", this.handlePaste.bind(this)); } + forwardToReceiver(eventId, payload = null) { // Check filters for (let i = 0; i < this.filters.length; ++i) { @@ -93,6 +122,7 @@ export class InputDistributor { return STOP_PROPAGATION; } } + const reciever = this.getTopReciever(); if (!reciever) { logger.warn("Dismissing event because not reciever was found:", eventId); @@ -102,11 +132,13 @@ export class InputDistributor { assert(signal instanceof Signal, "Not a valid event id"); return signal.dispatch(payload); } - handleBackButton(event: Event) { + + handleBackButton(event: Event) { event.preventDefault(); event.stopPropagation(); this.forwardToReceiver("backButton"); } + /** * Handles when the page got blurred */ @@ -114,13 +146,15 @@ export class InputDistributor { this.forwardToReceiver("pageBlur", {}); this.keysDown.clear(); } - + handlePaste(ev) { this.forwardToReceiver("paste", ev); } - handleKeyMouseDown(event: KeyboardEvent | MouseEvent) { + + handleKeyMouseDown(event: KeyboardEvent | MouseEvent) { const keyCode = event instanceof MouseEvent ? event.button + 1 : event.keyCode; - if (keyCode === 4 || // MB4 + if ( + keyCode === 4 || // MB4 keyCode === 5 || // MB5 keyCode === 9 || // TAB keyCode === 16 || // SHIFT @@ -130,18 +164,23 @@ export class InputDistributor { ) { event.preventDefault(); } + const isInitial = !this.keysDown.has(keyCode); this.keysDown.add(keyCode); - if (this.forwardToReceiver("keydown", { - keyCode: keyCode, - shift: event.shiftKey, - alt: event.altKey, - ctrl: event.ctrlKey, - initial: isInitial, - event, - }) === STOP_PROPAGATION) { + + if ( + this.forwardToReceiver("keydown", { + keyCode: keyCode, + shift: event.shiftKey, + alt: event.altKey, + ctrl: event.ctrlKey, + initial: isInitial, + event, + }) === STOP_PROPAGATION + ) { return; } + if (keyCode === 27) { // Escape key event.preventDefault(); @@ -149,9 +188,11 @@ export class InputDistributor { return this.forwardToReceiver("backButton"); } } - handleKeyMouseUp(event: KeyboardEvent | MouseEvent) { + + handleKeyMouseUp(event: KeyboardEvent | MouseEvent) { const keyCode = event instanceof MouseEvent ? event.button + 1 : event.keyCode; this.keysDown.delete(keyCode); + this.forwardToReceiver("keyup", { keyCode: keyCode, shift: event.shiftKey, diff --git a/src/ts/core/input_receiver.ts b/src/ts/core/input_receiver.ts index 23b3ce89..58949ff7 100644 --- a/src/ts/core/input_receiver.ts +++ b/src/ts/core/input_receiver.ts @@ -1,6 +1,5 @@ import { Signal } from "./signal"; export class InputReceiver { - public context = context; public backButton = new Signal(); public keydown = new Signal(); public keyup = new Signal(); @@ -8,13 +7,14 @@ export class InputReceiver { public destroyed = new Signal(); public paste = new Signal(); - constructor(context = "unknown") { - } + constructor(public context: string = "unknown") {} + cleanup() { this.backButton.removeAll(); this.keydown.removeAll(); this.keyup.removeAll(); this.paste.removeAll(); + this.destroyed.dispatch(); } } diff --git a/src/ts/core/loader.ts b/src/ts/core/loader.ts index 040e1696..d441514f 100644 --- a/src/ts/core/loader.ts +++ b/src/ts/core/loader.ts @@ -2,25 +2,27 @@ import { makeOffscreenBuffer } from "./buffer_utils"; import { AtlasSprite, BaseSprite, RegularSprite, SpriteAtlasLink } from "./sprites"; import { cachebust } from "./cachebust"; import { createLogger } from "./logging"; -export type Application = import("../application").Application; -export type AtlasDefinition = import("./atlas_definitions").AtlasDefinition; + +import type { Application } from "../application"; +import type { AtlasDefinition } from "./atlas_definitions"; const logger = createLogger("loader"); + const missingSpriteIds = {}; + class LoaderImpl { public app = null; public sprites: Map = new Map(); public rawImages = []; + public spriteNotFoundSprite: AtlasSprite; - constructor() { - } - linkAppAfterBoot(app: Application) { + linkAppAfterBoot(app: Application) { this.app = app; this.makeSpriteNotFoundCanvas(); } + /** * Fetches a given sprite from the cache - * {} */ getSpriteInternal(key: string): BaseSprite { const sprite = this.sprites.get(key); @@ -34,45 +36,53 @@ class LoaderImpl { } return sprite; } + /** * Returns an atlas sprite from the cache - * {} */ getSprite(key: string): AtlasSprite { const sprite = this.getSpriteInternal(key); assert(sprite instanceof AtlasSprite || sprite === this.spriteNotFoundSprite, "Not an atlas sprite"); - return sprite as AtlasSprite); + return sprite as AtlasSprite; } + /** * Returns a regular sprite from the cache - * {} */ getRegularSprite(key: string): RegularSprite { const sprite = this.getSpriteInternal(key); - assert(sprite instanceof RegularSprite || sprite === this.spriteNotFoundSprite, "Not a regular sprite"); - return sprite as RegularSprite); + assert( + sprite instanceof RegularSprite || sprite === this.spriteNotFoundSprite, + "Not a regular sprite" + ); + return sprite as RegularSprite; } + /** * - * {} */ - internalPreloadImage(key: string, progressHandler: (progress: number) => void): Promise { + internalPreloadImage( + key: string, + progressHandler: (progress: number) => void + ): Promise { return this.app.backgroundResourceLoader .preloadWithProgress("res/" + key, progress => { - progressHandler(progress); - }) + progressHandler(progress); + }) .then(url => { - return new Promise((resolve, reject) => { - const image = new Image(); - image.addEventListener("load", () => resolve(image)); - image.addEventListener("error", err => reject("Failed to load sprite " + key + ": " + err)); - image.src = url; + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => resolve(image)); + image.addEventListener("error", err => + reject("Failed to load sprite " + key + ": " + err) + ); + image.src = url; + }); }); - }); } + /** * Preloads a sprite - * {} */ preloadCSSSprite(key: string, progressHandler: (progress: number) => void): Promise { return this.internalPreloadImage(key, progressHandler).then(image => { @@ -83,9 +93,9 @@ class LoaderImpl { this.rawImages.push(image); }); } + /** * Preloads an atlas - * {} */ preloadAtlas(atlas: AtlasDefinition, progressHandler: (progress: number) => void): Promise { return this.internalPreloadImage(atlas.getFullSourcePath(), progressHandler).then(image => { @@ -94,11 +104,15 @@ class LoaderImpl { return this.internalParseAtlas(atlas, image); }); } - internalParseAtlas({ meta: { scale }, sourceData }: AtlasDefinition, loadedImage: HTMLImageElement) { + + internalParseAtlas({ meta: { scale }, sourceData }: AtlasDefinition, loadedImage: HTMLImageElement) { this.rawImages.push(loadedImage); + for (const spriteName in sourceData) { const { frame, sourceSize, spriteSourceSize } = sourceData[spriteName]; - let sprite = this.sprites.get(spriteName) as AtlasSprite); + + let sprite = this.sprites.get(spriteName) as AtlasSprite; + if (!sprite) { sprite = new AtlasSprite(spriteName); this.sprites.set(spriteName, sprite); @@ -106,6 +120,7 @@ class LoaderImpl { if (sprite.frozen) { continue; } + const link = new SpriteAtlasLink({ packedX: frame.x, packedY: frame.y, @@ -117,9 +132,11 @@ class LoaderImpl { w: sourceSize.w, h: sourceSize.h, }); + sprite.linksByResolution[scale] = link; } } + /** * Makes the canvas which shows the question mark, shown when a sprite was not found */ @@ -156,4 +173,5 @@ class LoaderImpl { this.spriteNotFoundSprite = sprite; } } + export const Loader = new LoaderImpl(); diff --git a/src/ts/core/logging.ts b/src/ts/core/logging.ts index 3cff42d2..22318f9e 100644 --- a/src/ts/core/logging.ts +++ b/src/ts/core/logging.ts @@ -8,10 +8,7 @@ Logging functions * Base logger class */ class Logger { - public context = context; - - constructor(context) { - } + constructor(public context: string) {} debug(...args) { globalDebug(this.context, ...args); } @@ -25,10 +22,12 @@ class Logger { globalError(this.context, ...args); } } -export function createLogger(context) { + +export function createLogger(context: string) { return new Logger(context); } -function prepareObjectForLogging(obj, maxDepth = 1) { + +function prepareObjectForLogging(obj: object, maxDepth = 1) { if (!window.Sentry) { // Not required without sentry return obj; @@ -39,20 +38,33 @@ function prepareObjectForLogging(obj, maxDepth = 1) { 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 { + } else { result[key] = "[object]"; } - } - else { + } else { result[key] = val; } } return result; } + +type SerializedError = { + type: string; + + message?: string; + name?: string; + stack?: string; + + filename?: string; + lineno?: number; + colno?: number; + error?: string | SerializedError; +}; + /** * Serializes an error */ @@ -60,17 +72,17 @@ export function serializeError(err: Error | ErrorEvent) { if (!err) { return null; } - const result = { + const result: SerializedError = { 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) { + } else if (err instanceof ErrorEvent) { result.filename = err.filename; result.message = err.message; result.lineno = err.lineno; @@ -78,26 +90,26 @@ export function serializeError(err: Error | ErrorEvent) { result.type = "{type.ErrorEvent}"; if (err.error) { result.error = serializeError(err.error); - } - else { + } else { result.error = "{not-provided}"; } - } - else { + } else { result.type = "{unkown-type:" + typeof err + "}"; } + return result; } + /** * Serializes an event */ function serializeEvent(event: Event) { - let result = { + return { type: "{type.Event:" + typeof event + "}", + eventType: event.type, }; - result.eventType = event.type; - return result; } + /** * Prepares a json payload */ @@ -113,25 +125,30 @@ function preparePayload(key: string, value: any) { } return value; } + /** * Stringifies an object containing circular references and errors */ export function stringifyObjectContainingErrors(payload: any) { 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 @@ -143,66 +160,77 @@ export function globalError(context, ...args) { }); } } -function prepareArgsForLogging(args) { + +function prepareArgsForLogging(args: any[]) { let result = []; for (let i = 0; i < args.length; ++i) { result.push(prepareObjectForLogging(args[i])); } return result; } + function internalBuildStringFromArgs(args: Array) { let result = []; + for (let i = 0; i < args.length; ++i) { let arg = args[i]; - if (typeof arg === "string" || + if ( + typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean" || arg === null || - arg === undefined) { + arg === undefined + ) { result.push("" + arg); - } - else if (arg instanceof Error) { + } else if (arg instanceof Error) { result.push(arg.message); - } - else { + } else { result.push("[object]"); } } return result.join(" "); } -export function logSection(name, color) { + +export function logSection(name: string, color: string) { 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 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) { + +function extractHandleContext(handle: string | ({ new (): any; name: string } & Function)) { let context = handle || "unknown"; - - if (handle && handle.constructor && handle.constructor.name) { - context = handle.constructor.name; if (context === "String") { context = handle; } } - if (handle && handle.name) { + + if (handle && typeof handle !== "string" && 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(performance.now()) + "").padEnd(6, " ") + ""; - consoleMethod.call(console, timestamp + " %c" + context, "color: #7f7;", "color: " + labelColor + ";", ...args); - } - else { + 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 { diff --git a/src/ts/core/lzstring.ts b/src/ts/core/lzstring.ts index 0c12535a..d73a6165 100644 --- a/src/ts/core/lzstring.ts +++ b/src/ts/core/lzstring.ts @@ -9,9 +9,11 @@ // 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) { + +function getBaseValue(alphabet: string, character: string) { if (!baseReverseDic[alphabet]) { baseReverseDic[alphabet] = {}; for (let i = 0; i < alphabet.length; i++) { @@ -20,8 +22,9 @@ function getBaseValue(alphabet, character) { } return baseReverseDic[alphabet][character]; } + //compress into uint8array (UCS-2 big endian format) -export function compressU8(uncompressed) { +export function compressU8(uncompressed: string) { 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++) { @@ -31,10 +34,12 @@ export function compressU8(uncompressed) { } return buf; } + // Compreses with header export function compressU8WHeader(uncompressed: string, header: number) { 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++) { @@ -44,6 +49,7 @@ export function compressU8WHeader(uncompressed: string, header: number) { } return buf; } + //decompress from uint8array (UCS-2 big endian format) export function decompressU8WHeader(compressed: Uint8Array) { // let buf = new Array(compressed.length / 2); // 2 bytes per character @@ -61,46 +67,59 @@ export function decompressU8WHeader(compressed: Uint8Array) { } return decompress(result.join("")); } + //compress into a string that is already URI encoded -export function compressX64(input) { - if (input == null) - return ""; +export function compressX64(input: string) { + 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; + 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; + +function _compress(uncompressed: string, bitsPerChar: number, getCharFromInt: (char: number) => string) { + 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 { + } else { if (hasOwnProperty.call(context_dictionaryToCreate, context_w)) { if (context_w.charCodeAt(0) < 256) { for (i = 0; i < context_numBits; i++) { @@ -109,8 +128,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } } @@ -121,14 +139,12 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } value = value >> 1; } - } - else { + } else { value = 1; for (i = 0; i < context_numBits; i++) { context_data_val = (context_data_val << 1) | value; @@ -136,8 +152,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } value = 0; @@ -149,8 +164,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } value = value >> 1; @@ -162,8 +176,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_numBits++; } delete context_dictionaryToCreate[context_w]; - } - else { + } else { value = context_dictionary[context_w]; for (i = 0; i < context_numBits; i++) { context_data_val = (context_data_val << 1) | (value & 1); @@ -171,8 +184,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } value = value >> 1; @@ -198,8 +210,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } } @@ -210,14 +221,12 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } value = value >> 1; } - } - else { + } else { value = 1; for (i = 0; i < context_numBits; i++) { context_data_val = (context_data_val << 1) | value; @@ -225,8 +234,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } value = 0; @@ -238,8 +246,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } value = value >> 1; @@ -251,8 +258,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_numBits++; } delete context_dictionaryToCreate[context_w]; - } - else { + } else { value = context_dictionary[context_w]; for (i = 0; i < context_numBits; i++) { context_data_val = (context_data_val << 1) | (value & 1); @@ -260,8 +266,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } value = value >> 1; @@ -273,6 +278,7 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_numBits++; } } + // Mark the end of the stream value = 2; for (i = 0; i < context_numBits; i++) { @@ -281,12 +287,12 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { context_data_position = 0; context_data.push(getCharFromInt(context_data_val)); context_data_val = 0; - } - else { + } else { context_data_position++; } value = value >> 1; } + // Flush the last char // eslint-disable-next-line no-constant-condition while (true) { @@ -294,26 +300,39 @@ function _compress(uncompressed, bitsPerChar, getCharFromInt) { if (context_data_position == bitsPerChar - 1) { context_data.push(getCharFromInt(context_data_val)); break; - } - else - context_data_position++; + } else context_data_position++; } return context_data.join(""); } -function decompress(compressed) { - if (compressed == null) - return ""; - if (compressed == "") - return null; + +function decompress(compressed: string) { + 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 }; + +function _decompress(length: number, 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; @@ -327,6 +346,7 @@ function _decompress(length, resetValue, getNextValue) { bits |= (resb > 0 ? 1 : 0) * power; power <<= 1; } + switch ((next = bits)) { case 0: bits = 0; @@ -366,6 +386,7 @@ function _decompress(length, resetValue, getNextValue) { dictionary[3] = c; w = c; result.push(c); + // eslint-disable-next-line no-constant-condition while (true) { if (data.index > length) { @@ -424,23 +445,24 @@ function _decompress(length, resetValue, getNextValue) { case 2: return result.join(""); } + if (enlargeIn == 0) { enlargeIn = Math.pow(2, numBits); numBits++; } + if (dictionary[c]) { // @ts-ignore entry = dictionary[c]; - } - else { + } else { if (c === dictSize) { entry = w + w.charAt(0); - } - else { + } else { return null; } } result.push(entry); + // Add w+entry[0] to the dictionary. dictionary[dictSize++] = w + entry.charAt(0); enlargeIn--; diff --git a/src/ts/core/modal_dialog_elements.ts b/src/ts/core/modal_dialog_elements.ts index 220b440b..bcd3e84b 100644 --- a/src/ts/core/modal_dialog_elements.ts +++ b/src/ts/core/modal_dialog_elements.ts @@ -1,9 +1,7 @@ -/* typehints:start */ import type { Application } from "../application"; -/* typehints:end */ import { Signal, STOP_PROPAGATION } from "./signal"; import { arrayDeleteValue, waitNextFrame } from "./utils"; -import { ClickDetector } from "./click_detector"; +import { ClickDetector, ClickDetectorConstructorArgs } from "./click_detector"; import { SOUNDS } from "../platform/sound"; import { InputReceiver } from "./input_receiver"; import { FormElement } from "./modal_dialog_forms"; @@ -11,6 +9,7 @@ import { globalConfig } from "./config"; import { getStringForKeyCode } from "../game/key_action_mapper"; import { createLogger } from "./logging"; import { T } from "../translations"; + /* * *************************************************** * @@ -21,33 +20,74 @@ import { T } from "../translations"; * * *************************************************** */ + const kbEnter = 13; const kbCancel = 27; + const logger = createLogger("dialogs"); + +// Button options +type DialogButtonStyles = ["good", "bad", "misc", "info", "loading"]; +type DialogButtonOptions = ["timeout", "kb_enter", "kb_escape"]; +type DialogButtonOption = DialogButtonOptions[number]; +type DialogButtonOptionArr = `${DialogButtonOption}${ + | `/${DialogButtonOption}${`/${DialogButtonOption}` | ""}` + | ""}`; + /** * Basic text based dialog */ -export class Dialog { - public app = app; - public title = title; - public contentHTML = contentHTML; - public type = type; - public buttonIds = buttons; - public closeButton = closeButton; +export class Dialog { + public app: Application; + public title: string; + public contentHTML: string; + public type: string; + public buttonIds: string[]; + public closeButton: boolean; + public closeRequested = new Signal(); - public buttonSignals = {}; + public buttonSignals: { + [key in Buttons[number]]: Signal; + } = {} as any; + public valueChosen = new Signal(); public timeouts = []; public clickDetectors = []; - public inputReciever = new InputReceiver("dialog-" + this.title); + public inputReciever: InputReceiver; public enterHandler = null; public escapeHandler = null; + + public dialogElem: HTMLDivElement; + public element: HTMLDivElement; + /** * * Constructs a new dialog with the given options */ + constructor({ + app, + title, + contentHTML, + buttons, + type = "info", + closeButton = false, + }: { + app: Application; + title: string; + contentHTML: string; + buttons: `${Buttons[number]}:${DialogButtonStyles[number]}${"" | `:${DialogButtonOptionArr}`}`[]; + type: DialogButtonStyles[number]; + closeButton?: boolean; + }) { + this.app = app; + this.title = title; + this.buttonIds = buttons; + this.contentHTML = contentHTML; + this.type = type; + this.closeButton = closeButton; + + this.inputReciever = new InputReceiver("dialog-" + this.title); - constructor({ app, title, contentHTML, buttons, type = "info", closeButton = false }) { for (let i = 0; i < buttons.length; ++i) { if (G_IS_DEV && globalConfig.debug.disableTimedButtons) { this.buttonIds[i] = this.buttonIds[i].replace(":timeout", ""); @@ -57,76 +97,100 @@ export class Dialog { } this.inputReciever.keydown.add(this.handleKeydown, this); } + /** * Internal keydown handler */ - handleKeydown({ keyCode, shift, alt, ctrl }: { + handleKeydown({ + keyCode, + shift, + alt, + ctrl, + }: { keyCode: number; shift: boolean; alt: boolean; ctrl: boolean; - }) { + }): void | STOP_PROPAGATION { if (keyCode === kbEnter && this.enterHandler) { this.internalButtonHandler(this.enterHandler); return STOP_PROPAGATION; } + if (keyCode === kbCancel && this.escapeHandler) { this.internalButtonHandler(this.escapeHandler); return STOP_PROPAGATION; } } - internalButtonHandler(id, ...payload) { + + internalButtonHandler(id: string, ...payload: any[]) { this.app.inputMgr.popReciever(this.inputReciever); + if (id !== "close-button") { this.buttonSignals[id].dispatch(...payload); } this.closeRequested.dispatch(); } + createElement() { const elem = document.createElement("div"); elem.classList.add("ingameDialog"); + this.dialogElem = document.createElement("div"); this.dialogElem.classList.add("dialogInner"); + if (this.type) { this.dialogElem.classList.add(this.type); } elem.appendChild(this.dialogElem); + const title = document.createElement("h1"); title.innerText = this.title; title.classList.add("title"); this.dialogElem.appendChild(title); + if (this.closeButton) { this.dialogElem.classList.add("hasCloseButton"); + const closeBtn = document.createElement("button"); closeBtn.classList.add("closeButton"); + this.trackClicks(closeBtn, () => this.internalButtonHandler("close-button"), { applyCssClass: "pressedSmallElement", }); + title.appendChild(closeBtn); this.inputReciever.backButton.add(() => this.internalButtonHandler("close-button")); } + const content = document.createElement("div"); content.classList.add("content"); content.innerHTML = this.contentHTML; this.dialogElem.appendChild(content); + if (this.buttonIds.length > 0) { const buttons = document.createElement("div"); buttons.classList.add("buttons"); // Create buttons for (let i = 0; i < this.buttonIds.length; ++i) { const [buttonId, buttonStyle, rawParams] = this.buttonIds[i].split(":"); + const button = document.createElement("button"); button.classList.add("button"); button.classList.add("styledButton"); button.classList.add(buttonStyle); button.innerText = T.dialogs.buttons[buttonId]; + const params = (rawParams || "").split("/"); const useTimeout = params.indexOf("timeout") >= 0; + const isEnter = params.indexOf("enter") >= 0; const isEscape = params.indexOf("escape") >= 0; + if (isEscape && this.closeButton) { logger.warn("Showing dialog with close button, and additional cancel button"); } + if (useTimeout) { button.classList.add("timedButton"); const timeout = setTimeout(() => { @@ -143,6 +207,7 @@ export class Dialog { spacer.innerHTML = getStringForKeyCode(isEnter ? kbEnter : kbCancel); button.appendChild(spacer); // } + if (isEnter) { this.enterHandler = buttonId; } @@ -150,21 +215,26 @@ export class Dialog { this.escapeHandler = buttonId; } } + this.trackClicks(button, () => this.internalButtonHandler(buttonId)); buttons.appendChild(button); } + this.dialogElem.appendChild(buttons); - } - else { + } else { this.dialogElem.classList.add("buttonless"); } + this.element = elem; this.app.inputMgr.pushReciever(this.inputReciever); + return this.element; } + setIndex(index) { this.element.style.zIndex = index; } + destroy() { if (!this.element) { assert(false, "Tried to destroy dialog twice"); @@ -174,39 +244,46 @@ export class Dialog { // dispatched to the modal dialogs, it will not call the internalButtonHandler, // and thus our receiver stays attached the whole time this.app.inputMgr.destroyReceiver(this.inputReciever); + for (let i = 0; i < this.clickDetectors.length; ++i) { this.clickDetectors[i].cleanup(); } + this.clickDetectors = []; + this.element.remove(); this.element = null; + for (let i = 0; i < this.timeouts.length; ++i) { clearTimeout(this.timeouts[i]); } this.timeouts = []; } + hide() { this.element.classList.remove("visible"); } + show() { this.element.classList.add("visible"); } + /** * Helper method to track clicks on an element - * {} */ - trackClicks(elem: Element, handler: function():void, args: import("./click_detector").ClickDetectorConstructorArgs= = {}): ClickDetector { + trackClicks(elem: Element, handler: () => void, args: ClickDetectorConstructorArgs = {}): ClickDetector { const detector = new ClickDetector(elem, args); detector.click.add(handler, this); this.clickDetectors.push(detector); return detector; } } + /** * Dialog which simply shows a loading spinner */ export class DialogLoading extends Dialog { - public text = text; + public text: string; constructor(app, text = "") { super({ @@ -216,46 +293,85 @@ export class DialogLoading extends Dialog { buttons: [], type: "loading", }); + // Loading dialog can not get closed with back button this.inputReciever.backButton.removeAll(); this.inputReciever.context = "dialog-loading"; + + this.text = text; } + createElement() { const elem = document.createElement("div"); elem.classList.add("ingameDialog"); elem.classList.add("loadingDialog"); this.element = elem; + if (this.text) { const text = document.createElement("div"); text.classList.add("text"); text.innerText = this.text; elem.appendChild(text); } + const loader = document.createElement("div"); loader.classList.add("prefab_LoadingTextWithAnim"); loader.classList.add("loadingIndicator"); elem.appendChild(loader); + this.app.inputMgr.pushReciever(this.inputReciever); + return elem; } } -export class DialogOptionChooser extends Dialog { - public options = options; - public initialOption = options.active; - constructor({ app, title, options }) { +interface DialogOptionOptions { + value: string; + text: string; + desc?: string; + iconPrefix?: string; +} + +export class DialogOptionChooser extends Dialog { + public options: { + options: DialogOptionOptions[]; + active: string; + }; + + public declare buttonSignals: { + optionSelected: Signal<[]>; + }; + + public initialOption: string; + + constructor({ + app, + title, + options, + }: { + app: Application; + title: string; + options: { + options: DialogOptionOptions[]; + active: string; + }; + }) { let html = "
"; + options.options.forEach(({ value, text, desc = null, iconPrefix = null }) => { const descHtml = desc ? `${desc}` : ""; let iconHtml = iconPrefix ? `` : ""; html += ` -
+
${iconHtml} ${text} ${descHtml}
`; }); + html += "
"; super({ app, @@ -265,11 +381,16 @@ export class DialogOptionChooser extends Dialog { type: "info", closeButton: true, }); + + this.options = options; + this.initialOption = options.active; + this.buttonSignals.optionSelected = new Signal(); } createElement() { const div = super.createElement(); this.dialogElem.classList.add("optionChooserDialog"); + div.querySelectorAll("[data-optionvalue]").forEach(handle => { const value = handle.getAttribute("data-optionvalue"); if (!handle) { @@ -285,13 +406,13 @@ export class DialogOptionChooser extends Dialog { targetOnly: true, }); this.clickDetectors.push(detector); + if (value !== this.initialOption) { detector.click.add(() => { const selected = div.querySelector(".option.active"); if (selected) { selected.classList.remove("active"); - } - else { + } else { logger.warn("No selected option"); } handle.classList.add("active"); @@ -303,27 +424,51 @@ export class DialogOptionChooser extends Dialog { return div; } } -export class DialogWithForm extends Dialog { - public confirmButtonId = confirmButtonId; - public formElements = formElements; - public enterHandler = confirmButtonId; - constructor({ app, title, desc, formElements, buttons = ["cancel", "ok:good"], confirmButtonId = "ok", closeButton = true, }) { +export class DialogWithForm extends Dialog { + public confirmButtonId: string; + public formElements: FormElement[]; + public enterHandler: string; + + constructor({ + app, + title, + desc, + formElements, + buttons = ["cancel", "ok:good"], + confirmButtonId = "ok", + closeButton = true, + }: { + app: Application; + title: string; + desc: string; + buttons?: string[]; + confirmButtonId?: string; + extraButton?: string; + closeButton?: boolean; + formElements: FormElement[]; + }) { let html = ""; html += desc + "
"; for (let i = 0; i < formElements.length; ++i) { html += formElements[i].getHtml(); } + super({ app, title: title, contentHTML: html, - buttons: buttons, + buttons: buttons as any, type: "info", closeButton, }); + this.confirmButtonId = confirmButtonId; + this.formElements = formElements; + + this.enterHandler = confirmButtonId; } - internalButtonHandler(id, ...payload) { + + internalButtonHandler(id: string, ...payload) { if (id === this.confirmButtonId) { if (this.hasAnyInvalid()) { this.dialogElem.classList.remove("errorShake"); @@ -336,8 +481,10 @@ export class DialogWithForm extends Dialog { return; } } + super.internalButtonHandler(id, payload); } + hasAnyInvalid() { for (let i = 0; i < this.formElements.length; ++i) { if (!this.formElements[i].isValid()) { @@ -346,6 +493,7 @@ export class DialogWithForm extends Dialog { } return false; } + createElement() { const div = super.createElement(); for (let i = 0; i < this.formElements.length; ++i) { diff --git a/src/ts/core/modal_dialog_forms.ts b/src/ts/core/modal_dialog_forms.ts index 81375c58..df65c224 100644 --- a/src/ts/core/modal_dialog_forms.ts +++ b/src/ts/core/modal_dialog_forms.ts @@ -11,42 +11,58 @@ import { Signal } from "./signal"; * * *************************************************** */ -export class FormElement { - public id = id; - public label = label; +export abstract class FormElement { public valueChosen = new Signal(); - constructor(id, label) { + constructor(public id: string, public label: string) {} + + abstract getHtml(); + + getFormElement(parent: Element) { + return parent.querySelector("[data-formId='" + this.id + "']") as HTMLFormElement; } - getHtml() { - abstract; - return ""; - } - getFormElement(parent) { - return parent.querySelector("[data-formId='" + this.id + "']"); - } - bindEvents(parent, clickTrackers) { - abstract; - } - focus() { } + + abstract bindEvents(parent: Element, clickTrackers: ClickDetector[]); + + focus() {} + isValid() { return true; } - /** {} */ - getValue(): any { - abstract; - } -} -export class FormElementInput extends FormElement { - public placeholder = placeholder; - public defaultValue = defaultValue; - public inputType = inputType; - public validator = validator; - public element = null; - constructor({ id, label = null, placeholder, defaultValue = "", inputType = "text", validator = null }) { + abstract getValue(): any; +} + +export class FormElementInput extends FormElement { + public placeholder: string; + public defaultValue: string; + public inputType: string; + public validator: (str: string) => boolean; + public element: HTMLFormElement = null; + + constructor({ + id, + label = null, + placeholder, + defaultValue = "", + inputType = "text", + validator = null, + }: { + id: string; + label?: string; + placeholder: string; + defaultValue?: string; + inputType?: string; + validator: (str: string) => boolean; + }) { super(id, label); + + this.placeholder = placeholder; + this.defaultValue = defaultValue; + this.inputType = inputType; + this.validator = validator; } + getHtml() { let classes = []; let inputType = "text"; @@ -85,37 +101,46 @@ export class FormElementInput extends FormElement {
`; } + bindEvents(parent, clickTrackers) { this.element = this.getFormElement(parent); this.element.addEventListener("input", event => this.updateErrorState()); this.updateErrorState(); } + updateErrorState() { this.element.classList.toggle("errored", !this.isValid()); } + isValid() { return !this.validator || this.validator(this.element.value); } + getValue() { return this.element.value; } + setValue(value) { this.element.value = value; this.updateErrorState(); } + focus() { this.element.focus(); this.element.select(); } } export class FormElementCheckbox extends FormElement { - public defaultValue = defaultValue; - public value = this.defaultValue; - public element = null; + public defaultValue: boolean; + public value: boolean; + public element: Element = null; - constructor({ id, label, defaultValue = true }) { + constructor({ id, label, defaultValue = true }: { id: string; label: string; defaultValue?: boolean }) { super(id, label); + this.defaultValue = defaultValue; + this.value = this.defaultValue; } + getHtml() { return `
@@ -126,7 +151,8 @@ export class FormElementCheckbox extends FormElement {
`; } - bindEvents(parent, clickTrackers) { + + bindEvents(parent: Element, clickTrackers: ClickDetector[]) { this.element = this.getFormElement(parent); const detector = new ClickDetector(this.element, { consumeEvents: false, @@ -135,23 +161,30 @@ export class FormElementCheckbox extends FormElement { clickTrackers.push(detector); detector.click.add(this.toggle, this); } + getValue() { return this.value; } + toggle() { this.value = !this.value; this.element.classList.toggle("checked", this.value); } - focus(parent) { } -} -export class FormElementItemChooser extends FormElement { - public items = items; - public element = null; - public chosenItem: BaseItem = null; - constructor({ id, label, items = [] }) { + // focus(parent) { } +} + +export class FormElementItemChooser extends FormElement { + public element: Element = null; + public chosenItem: BaseItem = null; + public items: any[]; + + constructor({ id, label, items = [] }) { super(id, label); + + this.items = items; } + getHtml() { let classes = []; return ` @@ -161,10 +194,13 @@ export class FormElementItemChooser extends FormElement { `; } - bindEvents(parent: HTMLElement, clickTrackers: Array) { + + bindEvents(parent: HTMLElement, clickTrackers: Array) { this.element = this.getFormElement(parent); + for (let i = 0; i < this.items.length; ++i) { const item = this.items[i]; + const canvas = document.createElement("canvas"); canvas.width = 128; canvas.height = 128; @@ -172,6 +208,7 @@ export class FormElementItemChooser extends FormElement { item.drawFullSizeOnCanvas(context, 128); this.element.appendChild(canvas); const detector = new ClickDetector(canvas, {}); + clickTrackers.push(detector); detector.click.add(() => { this.chosenItem = item; @@ -179,11 +216,14 @@ export class FormElementItemChooser extends FormElement { }); } } + isValid() { return true; } + getValue() { return null; } - focus() { } + + focus() {} } diff --git a/src/ts/core/polyfills.ts b/src/ts/core/polyfills.ts index e59949fe..c5cdaf61 100644 --- a/src/ts/core/polyfills.ts +++ b/src/ts/core/polyfills.ts @@ -17,8 +17,7 @@ function stringPolyfills() { padString = String(typeof padString !== "undefined" ? padString : " "); if (this.length >= targetLength) { return String(this); - } - else { + } 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 @@ -35,8 +34,7 @@ function stringPolyfills() { padString = String(typeof padString !== "undefined" ? padString : " "); if (this.length > targetLength) { return String(this); - } - else { + } 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 @@ -59,13 +57,21 @@ function objectPolyfills() { if (!Object.values) { // @ts-ignore Object.values = function values(O) { - return reduce(keys(O), (v, k) => concat(v, typeof k === "string" && isEnumerable(O, k) ? [O[k]] : []), []); + return reduce( + keys(O), + (v, k) => concat(v, typeof k === "string" && isEnumerable(O, k) ? [O[k]] : []), + [] + ); }; } if (!Object.entries) { // @ts-ignore Object.entries = function entries(O) { - return reduce(keys(O), (e, k) => concat(e, typeof k === "string" && isEnumerable(O, k) ? [[k, O[k]]] : []), []); + return reduce( + keys(O), + (e, k) => concat(e, typeof k === "string" && isEnumerable(O, k) ? [[k, O[k]]] : []), + [] + ); }; } } diff --git a/src/ts/core/query_parameters.ts b/src/ts/core/query_parameters.ts index 3ef20184..b7a05f81 100644 --- a/src/ts/core/query_parameters.ts +++ b/src/ts/core/query_parameters.ts @@ -1,5 +1,6 @@ const queryString = require("query-string"); const options = queryString.parse(location.search); + export let queryParamOptions = { embedProvider: null, abtVariant: null, diff --git a/src/ts/core/read_write_proxy.ts b/src/ts/core/read_write_proxy.ts index a3ff65ab..1d2d6f01 100644 --- a/src/ts/core/read_write_proxy.ts +++ b/src/ts/core/read_write_proxy.ts @@ -1,6 +1,5 @@ -/* typehints:start */ import type { Application } from "../application"; -/* typehints:end */ + import { sha1, CRC_PREFIX, computeCrc } from "./sensitive_utils.encrypt"; import { createLogger } from "./logging"; import { FILE_NOT_FOUND } from "../platform/storage"; @@ -14,55 +13,53 @@ const debounce = require("debounce-promise"); 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 { - public app: Application = app; - public filename = filename; + +export abstract class ReadWriteProxy { public currentData: object = null; public debouncedWrite = debounce(this.doWriteAsync.bind(this), 50); - constructor(app, filename) { + constructor(public app: Application, public filename: string) { // 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); + assert( + this.verify(this.getDefaultData()).result, + "Verify() failed for default data: " + this.verify(this.getDefaultData()).reason + ); }); } } + // -- Methods to override - /** {} */ - verify(data): ExplainedResult { - abstract; - return ExplainedResult.bad(); - } + abstract verify(data): ExplainedResult; + // Should return the default data - getDefaultData() { - abstract; - return {}; - } + abstract getDefaultData(): object; + // Should return the current version as an integer - getCurrentVersion() { - abstract; - return 0; - } + abstract getCurrentVersion(): number; + // Should migrate the data (Modify in place) - /** {} */ - migrate(data): ExplainedResult { - abstract; - return ExplainedResult.bad(); - } + abstract migrate(data: object): ExplainedResult; + // -- / Methods + // Resets whole data, returns promise resetEverythingAsync() { logger.warn("Reset data to default"); this.currentData = this.getDefaultData(); return this.writeAsync(); } - static serializeObject(obj: object) { + + static serializeObject(obj: object) { const jsonString = JSON.stringify(compressObject(obj)); const checksum = computeCrc(jsonString + salt); return compressionPrefix + compressX64(checksum + jsonString); } - static deserializeObject(text: object) { + + // @Bagel: This was an object, but then immediately called substr?? + // Also look at removing the substr + static deserializeObject(text: string) { const decompressed = decompressX64(text.substr(compressionPrefix.length)); if (!decompressed) { // LZ string decompression failure @@ -72,154 +69,185 @@ export class ReadWriteProxy { // String too short throw new Error("bad-content / payload-too-small"); } + // Compare stored checksum with actual checksum const checksum = decompressed.substring(0, 40); const jsonString = decompressed.substr(40); + const desiredChecksum = checksum.startsWith(CRC_PREFIX) ? computeCrc(jsonString + salt) : sha1(jsonString + salt); + if (desiredChecksum !== checksum) { // Checksum mismatch throw new Error("bad-content / checksum-mismatch"); } + const parsed = JSON.parse(jsonString); const decoded = decompressObject(parsed); return decoded; } + /** * Writes the data asychronously, fails if verify() fails. * Debounces the operation by up to 50ms - * {} */ writeAsync(): Promise { 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); } + return this.debouncedWrite(); } + /** * Actually writes the data asychronously - * {} */ doWriteAsync(): Promise { return asyncCompressor .compressObjectAsync(this.currentData) .then(compressed => { - return this.app.storage.writeFileAsync(this.filename, compressed); - }) + return this.app.storage.writeFileAsync(this.filename, compressed); + }) .then(() => { - logger.log("📄 Wrote", this.filename); - }) + logger.log("📄 Wrote", this.filename); + }) .catch(err => { - logger.error("Failed to write", this.filename, ":", err); - throw 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(compressObject(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 = checksum.startsWith(CRC_PREFIX) - ? computeCrc(jsonString + salt) - : sha1(jsonString + salt); - if (desiredChecksum !== checksum) { - // Checksum mismatch - return Promise.reject("bad-content / checksum-mismatch: " + desiredChecksum + " vs " + checksum); - } - return jsonString; - } - else { - if (!G_IS_DEV) { - return Promise.reject("bad-content / missing-compression"); - } - } - return rawData; - }) - // Parse JSON, this could throw but that's 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"); - } - }) - // Decompress - .then(compressed => decompressObject(compressed)) - // 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); - })); + 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(compressObject(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 = checksum.startsWith(CRC_PREFIX) + ? computeCrc(jsonString + salt) + : sha1(jsonString + salt); + if (desiredChecksum !== checksum) { + // Checksum mismatch + return Promise.reject( + "bad-content / checksum-mismatch: " + desiredChecksum + " vs " + checksum + ); + } + return jsonString; + } else { + if (!G_IS_DEV) { + return Promise.reject("bad-content / missing-compression"); + } + } + return rawData; + }) + // Parse JSON, this could throw but that's 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"); + } + }) + // Decompress + .then(compressed => decompressObject(compressed)) + // 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 @@ -235,14 +263,18 @@ export class ReadWriteProxy { 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.bad( + `Data has invalid version: ${data.version} (expected ${this.getCurrentVersion()})` + ); } return ExplainedResult.good(); } /** {} */ internalVerifyEntry(data): ExplainedResult { if (data.version !== this.getCurrentVersion()) { - return ExplainedResult.bad("Version mismatch, got " + data.version + " and expected " + this.getCurrentVersion()); + return ExplainedResult.bad( + "Version mismatch, got " + data.version + " and expected " + this.getCurrentVersion() + ); } const verifyStructureError = this.internalVerifyBasicStructure(data); if (!verifyStructureError.isGood()) { diff --git a/src/ts/core/rectangle.ts b/src/ts/core/rectangle.ts index 7df6a22a..b83f4a1f 100644 --- a/src/ts/core/rectangle.ts +++ b/src/ts/core/rectangle.ts @@ -2,119 +2,109 @@ import { globalConfig } from "./config"; import { epsilonCompare, round2Digits } from "./utils"; import { Vector } from "./vector"; export class Rectangle { - public x = x; - public y = y; - public w = w; - public h = h; + constructor(public x: number = 0, public y: number = 0, public w: number = 0, public h: number = 0) {} - constructor(x = 0, y = 0, w = 0, h = 0) { - } /** * Creates a rectangle from top right bottom and left offsets */ static fromTRBL(top: number, right: number, bottom: number, left: number) { return new Rectangle(left, top, right - left, bottom - top); } + /** * Constructs a new square rectangle */ static fromSquare(x: number, y: number, size: number) { return new Rectangle(x, y, size, size); } - static fromTwoPoints(p1: Vector, p2: Vector) { + static fromTwoPoints(p1: Vector, p2: Vector) { 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); } - static centered(width: number, height: number) { + static centered(width: number, height: number) { return new Rectangle(-Math.ceil(width / 2), -Math.ceil(height / 2), width, height); } + /** * Returns if a intersects b */ static intersects(a: Rectangle, b: Rectangle) { return a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom; } + /** * Copies this instance - * {} */ clone(): Rectangle { return new Rectangle(this.x, this.y, this.w, this.h); } + /** * Returns if this rectangle is empty - * {} */ isEmpty(): boolean { return epsilonCompare(this.w * this.h, 0); } + /** * Returns if this rectangle is equal to the other while taking an epsilon into account */ equalsEpsilon(other: Rectangle, epsilon: number) { - return (epsilonCompare(this.x, other.x, 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)); + epsilonCompare(this.h, other.h, epsilon) + ); } - /** - * {} - */ + left(): number { return this.x; } - /** - * {} - */ + right(): number { return this.x + this.w; } - /** - * {} - */ + top(): number { return this.y; } - /** - * {} - */ + bottom(): number { return this.y + this.h; } + /** * Returns Top, Right, Bottom, Left - * {} */ - trbl(): [ - number, - number, - number, - number - ] { + trbl(): [number, number, number, number] { return [this.y, this.right(), this.bottom(), this.x]; } + /** * Returns the center of the rect - * {} */ getCenter(): Vector { return new Vector(this.x + this.w / 2, this.y + this.h / 2); } + /** * Sets the right side of the rect without moving it */ setRight(right: number) { this.w = right - this.x; } + /** * Sets the bottom side of the rect without moving it */ setBottom(bottom: number) { this.h = bottom - this.y; } + /** * Sets the top side of the rect without scaling it */ @@ -123,6 +113,7 @@ export class Rectangle { this.y = top; this.setBottom(bottom); } + /** * Sets the left side of the rect without scaling it */ @@ -131,6 +122,7 @@ export class Rectangle { this.x = left; this.setRight(right); } + /** * Returns the top left point * {} @@ -138,6 +130,7 @@ export class Rectangle { topLeft(): Vector { return new Vector(this.x, this.y); } + /** * Returns the bottom left point * {} @@ -145,6 +138,7 @@ export class Rectangle { bottomRight(): Vector { return new Vector(this.right(), this.bottom()); } + /** * Moves the rectangle by the given parameters */ @@ -152,6 +146,7 @@ export class Rectangle { this.x += x; this.y += y; } + /** * Moves the rectangle by the given vector */ @@ -159,6 +154,7 @@ export class Rectangle { this.x += vec.x; this.y += vec.y; } + /** * Scales every parameter (w, h, x, y) by the given factor. Useful to transform from world to * tile space and vice versa @@ -166,50 +162,55 @@ export class Rectangle { allScaled(factor: number) { return new Rectangle(this.x * factor, this.y * factor, this.w * factor, this.h * factor); } + /** * Expands the rectangle in all directions - * {} new rectangle + * @returns new rectangle */ expandedInAllDirections(amount: number): Rectangle { return new Rectangle(this.x - amount, this.y - amount, this.w + 2 * amount, this.h + 2 * amount); } + /** * Returns if the given rectangle is contained - * {} */ containsRect(rect: Rectangle): boolean { - return (this.x <= rect.right() && + return ( + this.x <= rect.right() && rect.x <= this.right() && this.y <= rect.bottom() && - rect.y <= this.bottom()); + rect.y <= this.bottom() + ); } + /** * Returns if this rectangle contains the other rectangle specified by the parameters - * {} */ containsRect4Params(x: number, y: number, w: number, h: number): boolean { 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) - * {} */ containsCircle(x: number, y: number, radius: number): boolean { - return (this.x <= x + radius && + return ( + this.x <= x + radius && x - radius <= this.right() && this.y <= y + radius && - y - radius <= this.bottom()); + y - radius <= this.bottom() + ); } + /** * Returns if the rectangle contains the given point - * {} */ containsPoint(x: number, y: number): boolean { 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 - * {} */ getIntersection(rect: Rectangle): Rectangle | null { const left = Math.max(this.x, rect.x); @@ -221,6 +222,7 @@ export class Rectangle { } return Rectangle.fromTRBL(top, right, bottom, left); } + /** * Returns whether the rectangle fully intersects the given rectangle */ @@ -228,6 +230,7 @@ export class Rectangle { const intersection = this.getIntersection(rect); return intersection && Math.abs(intersection.w * intersection.h - rect.w * rect.h) < 0.001; } + /** * Returns the union of this rectangle with another */ @@ -247,23 +250,28 @@ export class Rectangle { const bottom = Math.max(this.bottom(), rect.bottom()); return Rectangle.fromTRBL(top, right, bottom, left); } + /** * Good for caching stuff */ toCompareableString() { - return (round2Digits(this.x) + + return ( + round2Digits(this.x) + "/" + round2Digits(this.y) + "/" + round2Digits(this.w) + "/" + - round2Digits(this.h)); + round2Digits(this.h) + ); } + /** * Good for printing stuff */ toString() { - return ("[x:" + + return ( + "[x:" + round2Digits(this.x) + "| y:" + round2Digits(this.y) + @@ -271,13 +279,19 @@ export class Rectangle { round2Digits(this.w) + "| h:" + round2Digits(this.h) + - "]"); + "]" + ); } + /** * Returns a new rectangle in tile space which includes all tiles which are visible in this rect - * {} */ toTileCullRectangle(): Rectangle { - return new Rectangle(Math.floor(this.x / globalConfig.tileSize), Math.floor(this.y / globalConfig.tileSize), Math.ceil(this.w / globalConfig.tileSize), Math.ceil(this.h / globalConfig.tileSize)); + return new Rectangle( + Math.floor(this.x / globalConfig.tileSize), + Math.floor(this.y / globalConfig.tileSize), + Math.ceil(this.w / globalConfig.tileSize), + Math.ceil(this.h / globalConfig.tileSize) + ); } } diff --git a/src/ts/core/request_channel.ts b/src/ts/core/request_channel.ts index e59f6dc5..f696bb02 100644 --- a/src/ts/core/request_channel.ts +++ b/src/ts/core/request_channel.ts @@ -4,42 +4,40 @@ const logger = createLogger("request_channel"); // Thrown when a request is aborted export const PROMISE_ABORTED = "promise-aborted"; export class RequestChannel { - public pendingPromises: Array = []; + public pendingPromises: Array> = []; - constructor() { - } - /** - * - * {} - */ watch(promise: Promise): 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); + 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); + } } - 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 () { @@ -48,6 +46,7 @@ export class RequestChannel { this.pendingPromises.push(wrappedPromise); return wrappedPromise; } + cancelAll() { if (this.pendingPromises.length > 0) { logger.log("Cancel all pending promises (", this.pendingPromises.length, ")"); diff --git a/src/ts/core/restriction_manager.ts b/src/ts/core/restriction_manager.ts index 9fe9db31..ade22cf1 100644 --- a/src/ts/core/restriction_manager.ts +++ b/src/ts/core/restriction_manager.ts @@ -7,24 +7,24 @@ import { WEB_STEAM_SSO_AUTHENTICATED } from "./steam_sso"; export class RestrictionManager extends ReadWriteProxy { public currentData = this.getDefaultData(); - constructor(app) { + constructor(app) { super(app, "restriction-flags.bin"); } // -- RW Proxy Impl - verify(data: any) { + verify(data: any) { return ExplainedResult.good(); } - + getDefaultData() { return { version: this.getCurrentVersion(), }; } - + getCurrentVersion() { return 1; } - migrate(data: any) { + migrate(data: any) { return ExplainedResult.good(); } initialize() { diff --git a/src/ts/core/rng.ts b/src/ts/core/rng.ts index b9932c14..9687508b 100644 --- a/src/ts/core/rng.ts +++ b/src/ts/core/rng.ts @@ -16,6 +16,7 @@ function Mash() { return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 }; } + function makeNewRng(seed: number | string) { // Johannes Baagøe , 2010 var c = 1; @@ -23,6 +24,7 @@ function makeNewRng(seed: number | string) { let s0 = mash(" "); let s1 = mash(" "); let s2 = mash(" "); + s0 -= mash(seed); if (s0 < 0) { s0 += 1; @@ -36,49 +38,59 @@ function makeNewRng(seed: number | string) { 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 { - public internalRng = makeNewRng(seed || Math.random()); - constructor(seed) { +export class RandomNumberGenerator { + public internalRng: () => number; + + constructor(seed?: number | string) { + this.internalRng = makeNewRng(seed || Math.random()); } + /** * Re-seeds the generator */ reseed(seed: number | string) { this.internalRng = makeNewRng(seed || Math.random()); } + /** - * {} between 0 and 1 + * @returns between 0 and 1 */ next(): number { return this.internalRng(); } + /** * Random choice of an array */ - choice(array: array) { + choice(array: any[]) { const index = this.nextIntRange(0, array.length); return array[index]; } + /** - * {} Integer in range [min, max[ + * @returns Integer in range [min, max[] */ nextIntRange(min: number, max: number): number { assert(Number.isFinite(min), "Minimum is no integer"); @@ -86,13 +98,15 @@ export class RandomNumberGenerator { assert(max > min, "rng: max <= min"); return Math.floor(this.next() * (max - min) + min); } + /** - * {} Number in range [min, max[ + * @returns Number in range [min, max[ */ nextRange(min: number, max: number): number { assert(max > min, "rng: max <= min"); return this.next() * (max - min) + min; } + /** * Updates the seed */ diff --git a/src/ts/core/sensitive_utils.encrypt.ts b/src/ts/core/sensitive_utils.encrypt.ts index 2d0d8915..d7482c15 100644 --- a/src/ts/core/sensitive_utils.encrypt.ts +++ b/src/ts/core/sensitive_utils.encrypt.ts @@ -1,15 +1,19 @@ import { createHash } from "rusha"; import crc32 from "crc/crc32"; import { decompressX64 } from "./lzstring"; + export function sha1(str) { return createHash().update(str).digest("hex"); } + // Window.location.host export function getNameOfProvider() { return window[decompressX64("DYewxghgLgliB2Q")][decompressX64("BYewzgLgdghgtgUyA")]; } + // Distinguish legacy crc prefixes export const CRC_PREFIX = "crc32".padEnd(32, "-"); + /** * Computes the crc for a given string */ diff --git a/src/ts/core/signal.ts b/src/ts/core/signal.ts index 70c05cac..53a6065e 100644 --- a/src/ts/core/signal.ts +++ b/src/ts/core/signal.ts @@ -1,48 +1,55 @@ export const STOP_PROPAGATION = "stop_propagation"; -export class Signal { - public receivers = []; +export type STOP_PROPAGATION = typeof STOP_PROPAGATION; + +export class Signal { + public receivers: { + receiver: (...args: T) => STOP_PROPAGATION | void; + scope: object; + }[] = []; public modifyCount = 0; - constructor() { - } /** * Adds a new signal listener */ - add(receiver: function, scope: object = null) { + add(receiver: (...args: T) => STOP_PROPAGATION | void, scope: object = null) { assert(receiver, "receiver is null"); this.receivers.push({ receiver, scope }); ++this.modifyCount; } + /** * Adds a new signal listener */ - addToTop(receiver: function, scope: object = null) { + addToTop(receiver: (...args: T) => STOP_PROPAGATION | void, scope: object = null) { assert(receiver, "receiver is null"); this.receivers.unshift({ receiver, scope }); ++this.modifyCount; } + /** * Dispatches the signal - * @param {} payload */ - dispatch() { + dispatch(...payload: T): void | STOP_PROPAGATION { 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) { + if (receiver.apply(scope, payload) === STOP_PROPAGATION) { return STOP_PROPAGATION; } + if (modifyState !== this.modifyCount) { // Signal got modified during iteration return STOP_PROPAGATION; } } } + /** * Removes a receiver */ - remove(receiver: function) { + remove(receiver: (...args: any[]) => any) { let index = null; const n = this.receivers.length; for (let i = 0; i < n; ++i) { @@ -55,6 +62,7 @@ export class Signal { this.receivers.splice(index, 1); ++this.modifyCount; } + /** * Removes all receivers */ diff --git a/src/ts/core/singleton_factory.ts b/src/ts/core/singleton_factory.ts index 0b6fc192..096c9934 100644 --- a/src/ts/core/singleton_factory.ts +++ b/src/ts/core/singleton_factory.ts @@ -1,40 +1,46 @@ import { createLogger } from "./logging"; + const logger = createLogger("singleton_factory"); // simple factory pattern -export class SingletonFactory { - public id = id; - public entries = []; - public idToEntry = {}; +export class SingletonFactory { + public entries: T[] = []; + public idToEntry: { + [id: string]: T; + } = {}; + + constructor(public id?: string) {} - constructor(id) { - } getId() { return this.id; } - register(classHandle) { + + register(classHandle: Class) { // 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 - * {} */ hasId(id: string): boolean { return !!this.idToEntry[id]; } + /** * Finds an instance by a given id - * {} */ - findById(id: string): object { + findById(id: string): T { const entry = this.idToEntry[id]; if (!entry) { logger.error("Object with id", id, "is not registered!"); @@ -43,12 +49,11 @@ export class SingletonFactory { } return entry; } - /** + /** * Finds an instance by its constructor (The class handle) - * {} */ - findByClass(classHandle: object): object { + findByClass(classHandle: Class): T { for (let i = 0; i < this.entries.length; ++i) { if (this.entries[i] instanceof classHandle) { return this.entries[i]; @@ -57,23 +62,23 @@ export class SingletonFactory { assert(false, "Factory: Object not found by classHandle (classid: " + classHandle.name + ")"); return null; } + /** * Returns all entries - * {} */ - getEntries(): Array { + getEntries(): Array { return this.entries; } + /** * Returns all registered ids - * {} */ getAllIds(): Array { return Object.keys(this.idToEntry); } + /** * Returns amount of stored entries - * {} */ getNumEntries(): number { return this.entries.length; diff --git a/src/ts/core/sprites.ts b/src/ts/core/sprites.ts index 0f08dfe0..98f2021c 100644 --- a/src/ts/core/sprites.ts +++ b/src/ts/core/sprites.ts @@ -1,181 +1,288 @@ import { DrawParameters } from "./draw_parameters"; import { Rectangle } from "./rectangle"; import { round3Digits } from "./utils"; + export const ORIGINAL_SPRITE_SCALE = "0.75"; export const FULL_CLIP_RECT = new Rectangle(0, 0, 1, 1); + const EXTRUDE = 0.1; -export class BaseSprite { +export abstract class BaseSprite { /** * Returns the raw handle - * {} - * @abstract */ - getRawTexture(): HTMLImageElement | HTMLCanvasElement { - abstract; - return null; - } + abstract getRawTexture(): HTMLImageElement | HTMLCanvasElement; + /** * Draws the sprite */ - draw(context: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { - // eslint-disable-line no-unused-vars - abstract; - } + abstract draw(context: CanvasRenderingContext2D, x: number, y: number, w: number, h: number); } + /** * Position of a sprite within an atlas */ export class SpriteAtlasLink { - public packedX = packedX; - public packedY = packedY; - public packedW = packedW; - public packedH = packedH; - public packOffsetX = packOffsetX; - public packOffsetY = packOffsetY; - public atlas = atlas; - public w = w; - public h = h; + public packedX: number; + public packedY: number; + public packedW: number; + public packedH: number; + public packOffsetX: number; + public packOffsetY: number; + public atlas: HTMLImageElement | HTMLCanvasElement; + public w: number; + public h: number; - constructor({ w, h, packedX, packedY, packOffsetX, packOffsetY, packedW, packedH, atlas }) { + constructor({ + w, + h, + packedX, + packedY, + packOffsetX, + packOffsetY, + packedW, + packedH, + atlas, + }: { + packedX: number; + packedY: number; + packedW: number; + packedH: number; + packOffsetX: number; + packOffsetY: number; + atlas: HTMLImageElement | HTMLCanvasElement; + w: number; + h: number; + }) { + 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 { public linksByResolution: { [idx: string]: SpriteAtlasLink; } = {}; - public spriteName = spriteName; + public frozen = false; - constructor(spriteName = "sprite") { + constructor(public spriteName: string = "sprite") { super(); } + getRawTexture() { return this.linksByResolution[ORIGINAL_SPRITE_SCALE].atlas; } + /** * Draws the sprite onto a regular context using no contexts * @see {BaseSprite.draw} */ - draw(context, x, y, w, h) { + draw(context: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { if (G_IS_DEV) { assert(context instanceof CanvasRenderingContext2D, "Not a valid context"); } + const link = this.linksByResolution[ORIGINAL_SPRITE_SCALE]; + if (!link) { - throw new Error("draw: Link for " + - this.spriteName + - " not known: " + - ORIGINAL_SPRITE_SCALE + - " (having " + - Object.keys(this.linksByResolution) + - ")"); + throw new Error( + "draw: Link for " + + this.spriteName + + " not known: " + + ORIGINAL_SPRITE_SCALE + + " (having " + + Object.keys(this.linksByResolution) + + ")" + ); } + 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); + + 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 + ); } - drawCachedCentered(parameters: DrawParameters, x: number, y: number, size: number, clipping: boolean= = true) { + + drawCachedCentered( + parameters: DrawParameters, + x: number, + y: number, + size: number, + clipping: boolean = true + ) { this.drawCached(parameters, x - size / 2, y - size / 2, size, size, clipping); } - drawCentered(context: CanvasRenderingContext2D, x: number, y: number, size: number) { + + drawCentered(context: CanvasRenderingContext2D, x: number, y: number, size: number) { this.draw(context, x - size / 2, y - size / 2, size, size); } + /** * Draws the sprite */ - drawCached(parameters: DrawParameters, x: number, y: number, w: number = null, h: number = null, clipping: boolean= = true) { + drawCached( + parameters: DrawParameters, + x: number, + y: number, + w: number = null, + h: number = null, + clipping: boolean = true + ) { if (G_IS_DEV) { assert(parameters instanceof DrawParameters, "Not a valid context"); assert(!!w && w > 0, "Not a valid width:" + w); assert(!!h && h > 0, "Not a valid height:" + h); } + const visibleRect = parameters.visibleRect; + const scale = parameters.desiredAtlasScale; const link = this.linksByResolution[scale]; + if (!link) { - throw new Error("drawCached: Link for " + - this.spriteName + - " at scale " + - scale + - " not known (having " + - Object.keys(this.linksByResolution) + - ")"); + throw new Error( + "drawCached: Link for " + + this.spriteName + + " at scale " + + scale + + " not known (having " + + Object.keys(this.linksByResolution) + + ")" + ); } + 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.getIntersection(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; } - parameters.context.drawImage(link.atlas, - // atlas src pos - srcX, srcY, - // atlas src size - srcW, srcH, - // dest pos and size - destX - EXTRUDE, destY - EXTRUDE, destW + 2 * EXTRUDE, destH + 2 * EXTRUDE); + + parameters.context.drawImage( + link.atlas, + // atlas src pos + srcX, + srcY, + // atlas src size + srcW, + srcH, + // dest pos and size + destX - EXTRUDE, + destY - EXTRUDE, + destW + 2 * EXTRUDE, + destH + 2 * EXTRUDE + ); } + /** * Draws a subset of the sprite. Does NO culling */ - drawCachedWithClipRect(parameters: DrawParameters, x: number, y: number, w: number = null, h: number = null, clipRect: Rectangle= = FULL_CLIP_RECT) { + drawCachedWithClipRect( + parameters: DrawParameters, + x: number, + y: number, + w: number = null, + h: number = null, + clipRect: Rectangle = FULL_CLIP_RECT + ) { if (G_IS_DEV) { assert(parameters instanceof DrawParameters, "Not a valid context"); assert(!!w && w > 0, "Not a valid width:" + w); assert(!!h && h > 0, "Not a valid height:" + h); assert(clipRect, "No clip rect given!"); } + const scale = parameters.desiredAtlasScale; const link = this.linksByResolution[scale]; + if (!link) { - throw new Error("drawCachedWithClipRect: Link for " + - this.spriteName + - " at scale " + - scale + - " not known (having " + - Object.keys(this.linksByResolution) + - ")"); + throw new Error( + "drawCachedWithClipRect: Link for " + + this.spriteName + + " at scale " + + scale + + " not known (having " + + Object.keys(this.linksByResolution) + + ")" + ); } + const scaleW = w / link.w; const scaleH = h / link.h; + let destX = x + link.packOffsetX * scaleW + clipRect.x * w; let destY = y + link.packOffsetY * scaleH + clipRect.y * h; let destW = link.packedW * scaleW * clipRect.w; let destH = link.packedH * scaleH * clipRect.h; + let srcX = link.packedX + clipRect.x * link.packedW; let srcY = link.packedY + clipRect.y * link.packedH; let srcW = link.packedW * clipRect.w; let srcH = link.packedH * clipRect.h; - parameters.context.drawImage(link.atlas, - // atlas src pos - srcX, srcY, - // atlas src siize - srcW, srcH, - // dest pos and size - destX - EXTRUDE, destY - EXTRUDE, destW + 2 * EXTRUDE, destH + 2 * EXTRUDE); + + parameters.context.drawImage( + link.atlas, + // atlas src pos + srcX, + srcY, + // atlas src siize + srcW, + srcH, + // dest pos and size + destX - EXTRUDE, + destY - EXTRUDE, + destW + 2 * EXTRUDE, + destH + 2 * EXTRUDE + ); } + /** * Renders into an html element */ @@ -183,44 +290,56 @@ export class AtlasSprite extends BaseSprite { element.style.position = "relative"; element.innerHTML = this.getAsHTML(w, h); } + /** * Returns the html to render as icon */ getAsHTML(w: number, h: number) { const link = this.linksByResolution["0.5"]; if (!link) { - throw new Error("getAsHTML: Link for " + - this.spriteName + - " at scale 0.5" + - " not known (having " + - Object.keys(this.linksByResolution) + - ")"); + throw new Error( + "getAsHTML: Link for " + + this.spriteName + + " at scale 0.5" + + " not known (having " + + Object.keys(this.linksByResolution) + + ")" + ); } + // 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 ` `; } } export class RegularSprite extends BaseSprite { - public w = w; - public h = h; - public sprite = sprite; - - constructor(sprite, w, h) { + constructor(public sprite: HTMLCanvasElement | HTMLImageElement, public w: number, public h: number) { super(); } + getRawTexture() { return this.sprite; } + /** * Draws the sprite, do *not* use this for sprites which are rendered! Only for drawing * images into buffers @@ -258,6 +377,7 @@ export class RegularSprite extends BaseSprite { 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 diff --git a/src/ts/core/stale_area_detector.ts b/src/ts/core/stale_area_detector.ts index 352adffd..bfa863a6 100644 --- a/src/ts/core/stale_area_detector.ts +++ b/src/ts/core/stale_area_detector.ts @@ -3,15 +3,31 @@ import { Entity } from "../game/entity"; import { globalConfig } from "./config"; import { createLogger } from "./logging"; import { Rectangle } from "./rectangle"; + +import type { GameRoot } from "../game/root"; + const logger = createLogger("stale_areas"); + export class StaleAreaDetector { - public root = root; - public name = name; - public recomputeMethod = recomputeMethod; + public root: GameRoot; + public name: string; + public recomputeMethod: (rect: Rectangle) => void; public staleArea: Rectangle = null; - constructor({ root, name, recomputeMethod }) { + constructor({ + root, + name, + recomputeMethod, + }: { + root: GameRoot; + name: string; + recomputeMethod: (rect: Rectangle) => void; + }) { + this.root = root; + this.name = name; + this.recomputeMethod = recomputeMethod; } + /** * Invalidates the given area */ @@ -19,17 +35,18 @@ export class StaleAreaDetector { // logger.log(this.name, "invalidated", area.toString()); if (this.staleArea) { this.staleArea = this.staleArea.getUnion(area); - } - else { + } else { this.staleArea = area.clone(); } } + /** * Makes this detector recompute the area of an entity whenever * it changes in any way */ recomputeOnComponentsChanged(components: Array, tilesAround: number) { const componentIds = components.map(component => component.getId()); + /** * Internal checker method */ @@ -37,22 +54,28 @@ export class StaleAreaDetector { if (!this.root.gameInitialized) { return; } + // Check for all components for (let i = 0; i < componentIds.length; ++i) { if (entity.components[componentIds[i]]) { // Entity is relevant, compute affected area - const area = entity.components.StaticMapEntity.getTileSpaceBounds().expandedInAllDirections(tilesAround); + const area = + entity.components.StaticMapEntity.getTileSpaceBounds().expandedInAllDirections( + tilesAround + ); this.invalidate(area); return; } } }; + this.root.signals.entityAdded.add(checker); this.root.signals.entityChanged.add(checker); this.root.signals.entityComponentRemoved.add(checker); this.root.signals.entityGotNewComponent.add(checker); this.root.signals.entityDestroyed.add(checker); } + /** * Updates the stale area */ diff --git a/src/ts/core/state_manager.ts b/src/ts/core/state_manager.ts index 28a4b60a..37104107 100644 --- a/src/ts/core/state_manager.ts +++ b/src/ts/core/state_manager.ts @@ -1,27 +1,27 @@ -/* typehints:start*/ import type { Application } from "../application"; -/* typehints:end*/ + import { GameState } from "./game_state"; import { createLogger } from "./logging"; import { waitNextFrame, removeAllChildren } from "./utils"; import { MOD_SIGNALS } from "../mods/mod_signals"; + const logger = createLogger("state_manager"); + /** * This is the main state machine which drives the game states. */ export class StateManager { - public app = app; public currentState: GameState = null; public stateClasses: { [idx: string]: new () => GameState; } = {}; - constructor(app) { - } + constructor(public app: Application) {} + /** * Registers a new state class, should be a GameState derived class */ - register(stateClass: object) { + register(stateClass: { new (): GameState }) { // Create a dummy to retrieve the key const dummy = new stateClass(); assert(dummy instanceof GameState, "Not a state!"); @@ -29,6 +29,7 @@ export class StateManager { assert(!this.stateClasses[key], `State '${key}' is already registered!`); this.stateClasses[key] = stateClass; } + /** * Constructs a new state or returns the instance from the cache */ @@ -38,20 +39,23 @@ export class StateManager { } assert(false, `State '${key}' is not known!`); } + /** * Moves to a given state */ - moveToState(key: string, payload = {}) { + moveToState(key: string, payload: object = {}) { if (window.APP_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)) { @@ -60,42 +64,56 @@ export class StateManager { } this.currentState = null; } + this.currentState = this.constructState(key); this.currentState.internalRegisterCallback(this, this.app); + // Clean up old elements if (this.currentState.getRemovePreviousContent()) { removeAllChildren(document.body); } + document.body.className = "gameState " + (this.currentState.getHasFadeIn() ? "" : "arrived"); document.body.id = "state_" + key; + if (this.currentState.getRemovePreviousContent()) { document.body.innerHTML = this.currentState.internalGetFullHtml(); } + const dialogParent = document.createElement("div"); dialogParent.classList.add("modalDialogParent"); document.body.appendChild(dialogParent); try { this.currentState.internalEnterCallback(payload); - } - catch (ex) { + } catch (ex) { console.error(ex); throw ex; } + this.app.sound.playThemeMusic(this.currentState.getThemeMusic()); + this.currentState.onResized(this.app.screenWidth, this.app.screenHeight); + this.app.analytics.trackStateEnter(key); - window.history.pushState({ - key, - }, key); + + window.history.pushState( + { + key, + }, + key + ); + MOD_SIGNALS.stateEntered.dispatch(this.currentState); + waitNextFrame().then(() => { document.body.classList.add("arrived"); }); + return true; } + /** * Returns the current state - * {} */ getCurrentState(): GameState { return this.currentState; diff --git a/src/ts/core/steam_sso.ts b/src/ts/core/steam_sso.ts index 26a1aabb..1a0aba80 100644 --- a/src/ts/core/steam_sso.ts +++ b/src/ts/core/steam_sso.ts @@ -1,23 +1,34 @@ +import { Application } from "../application"; +import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; import { T } from "../translations"; import { openStandaloneLink } from "./config"; + export let WEB_STEAM_SSO_AUTHENTICATED = false; -export async function authorizeViaSSOToken(app, dialogs) { + +export async function authorizeViaSSOToken(app: Application, dialogs: HUDModalDialogs) { if (G_IS_STANDALONE) { return; } + if (window.location.search.includes("sso_logout_silent")) { window.localStorage.setItem("steam_sso_auth_token", ""); window.location.replace("/"); return new Promise(() => null); } + if (window.location.search.includes("sso_logout")) { const { ok } = dialogs.showWarning(T.dialogs.steamSsoError.title, T.dialogs.steamSsoError.desc); window.localStorage.setItem("steam_sso_auth_token", ""); ok.add(() => window.location.replace("/")); return new Promise(() => null); } + if (window.location.search.includes("steam_sso_no_ownership")) { - const { ok, getStandalone } = dialogs.showWarning(T.dialogs.steamSsoNoOwnership.title, T.dialogs.steamSsoNoOwnership.desc, ["ok", "getStandalone:good"]); + const { ok, getStandalone } = dialogs.showWarning( + T.dialogs.steamSsoNoOwnership.title, + T.dialogs.steamSsoNoOwnership.desc, + ["ok", "getStandalone:good"] + ); window.localStorage.setItem("steam_sso_auth_token", ""); getStandalone.add(() => { openStandaloneLink(app, "sso_ownership"); @@ -26,20 +37,24 @@ export async function authorizeViaSSOToken(app, dialogs) { ok.add(() => window.location.replace("/")); return new Promise(() => null); } + const token = window.localStorage.getItem("steam_sso_auth_token"); if (!token) { return Promise.resolve(); } + const apiUrl = app.clientApi.getEndpoint(); console.warn("Authorizing via token:", token); + const verify = async () => { const token = window.localStorage.getItem("steam_sso_auth_token"); if (!token) { window.location.replace("?sso_logout"); return; } + try { - const response = await Promise.race([ + const response = (await Promise.race([ fetch(apiUrl + "/v1/sso/refresh", { method: "POST", body: token, @@ -50,7 +65,8 @@ export async function authorizeViaSSOToken(app, dialogs) { new Promise((resolve, reject) => { setTimeout(() => reject("timeout exceeded"), 20000); }), - ]); + ])) as Response; + const responseText = await response.json(); if (!responseText.token) { console.warn("Failed to register"); @@ -58,17 +74,18 @@ export async function authorizeViaSSOToken(app, dialogs) { window.location.replace("?sso_logout"); return; } + window.localStorage.setItem("steam_sso_auth_token", responseText.token); app.clientApi.token = responseText.token; WEB_STEAM_SSO_AUTHENTICATED = true; - } - catch (ex) { + } catch (ex) { console.warn("Auth failure", ex); window.localStorage.setItem("steam_sso_auth_token", ""); window.location.replace("/"); return new Promise(() => null); } }; + await verify(); setInterval(verify, 120000); } diff --git a/src/ts/core/textual_game_state.ts b/src/ts/core/textual_game_state.ts index 32595b62..b94964ae 100644 --- a/src/ts/core/textual_game_state.ts +++ b/src/ts/core/textual_game_state.ts @@ -2,15 +2,22 @@ import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; import { GameState } from "./game_state"; import { T } from "../translations"; /** - * Baseclass for all game states which are structured similary: A header with back button + some + * Baseclass for all game states which are structured similarly: A header with back button + some * scrollable content. */ export class TextualGameState extends GameState { + public backToStateId: string; + public backToStatePayload: object; + + public containerElement: HTMLDivElement; + public headerElement: HTMLDivElement; + public dialogs: HUDModalDialogs; + ///// INTERFACE //// + /** - * Should return the states inner html. If not overriden, will create a scrollable container + * Should return the states inner html. If not overridden, will create a scrollable container * with the content of getMainContentHTML() - * {} */ getInnerHTML(): string { return ` @@ -19,21 +26,24 @@ export class TextualGameState extends GameState { `; } + /** * Should return the states HTML content. */ getMainContentHTML() { return ""; } + /** * Should return the title of the game state. If null, no title and back button will * get created - * {} */ getStateHeaderTitle(): string | null { return null; } + ///////////// + /** * Back button handler, can be overridden. Per default it goes back to the main menu, * or if coming from the game it moves back to the game again. @@ -41,17 +51,18 @@ export class TextualGameState extends GameState { onBackButton() { if (this.backToStateId) { this.moveToState(this.backToStateId, this.backToStatePayload); - } - else { + } else { this.moveToState(this.getDefaultPreviousState()); } } + /** * Returns the default state to go back to */ getDefaultPreviousState() { return "MainMenuState"; } + /** * Goes to a new state, telling him to go back to this state later */ @@ -64,6 +75,7 @@ export class TextualGameState extends GameState { }, }); } + /** * Removes all click detectors, except the one on the back button. Useful when regenerating * content. @@ -79,6 +91,7 @@ export class TextualGameState extends GameState { i -= 1; } } + /** * Overrides the GameState implementation to provide our own html */ @@ -87,10 +100,11 @@ export class TextualGameState extends GameState { if (this.getStateHeaderTitle()) { headerHtml = `
- +

${this.getStateHeaderTitle()}

`; } + return ` ${headerHtml}
@@ -99,7 +113,9 @@ export class TextualGameState extends GameState {
`; } + //// INTERNALS ///// + /** * Overrides the GameState leave callback to cleanup stuff */ @@ -107,6 +123,7 @@ export class TextualGameState extends GameState { super.internalLeaveCallback(); this.dialogs.cleanup(); } + /** * Overrides the GameState enter callback to setup required stuff */ @@ -116,18 +133,23 @@ export class TextualGameState extends GameState { this.backToStateId = payload.backToStateId; this.backToStatePayload = payload.backToStatePayload; } + this.htmlElement.classList.add("textualState"); if (this.getStateHeaderTitle()) { this.htmlElement.classList.add("hasTitle"); } + this.containerElement = this.htmlElement.querySelector(".widthKeeper .container"); this.headerElement = this.htmlElement.querySelector(".headerBar > h1"); + if (this.headerElement) { this.trackClicks(this.headerElement, this.onBackButton); } + this.dialogs = new HUDModalDialogs(null, this.app); const dialogsElement = document.body.querySelector(".modalDialogParent"); this.dialogs.initializeToElement(dialogsElement); + this.onEnter(payload); } } diff --git a/src/ts/core/tracked_state.ts b/src/ts/core/tracked_state.ts index 7e9cc072..eb7852c7 100644 --- a/src/ts/core/tracked_state.ts +++ b/src/ts/core/tracked_state.ts @@ -1,7 +1,10 @@ -export class TrackedState { - public lastSeenValue = null; +export class TrackedState { + public callback: (value: T) => void; + public callbackScope: object; - constructor(callbackMethod = null, callbackScope = null) { + public lastSeenValue: T = null; + + constructor(callbackMethod: (value: T) => void = null, callbackScope: any = null) { if (callbackMethod) { this.callback = callbackMethod; if (callbackScope) { @@ -9,7 +12,8 @@ export class TrackedState { } } } - set(value, changeHandler = null, changeScope = null) { + + set(value: T, changeHandler: (value: T) => void = null, changeScope: object = null) { if (value !== this.lastSeenValue) { // Copy value since the changeHandler call could actually modify our lastSeenValue const valueCopy = value; @@ -17,23 +21,22 @@ export class TrackedState { if (changeHandler) { if (changeScope) { changeHandler.call(changeScope, valueCopy); - } - else { + } else { changeHandler(valueCopy); } - } - else if (this.callback) { + } else if (this.callback) { this.callback(value); - } - else { + } else { assert(false, "No callback specified"); } } } - setSilent(value) { + + setSilent(value: T) { this.lastSeenValue = value; } - get() { + + get(): T { return this.lastSeenValue; } } diff --git a/src/ts/core/utils.ts b/src/ts/core/utils.ts index 7b27b24d..5e6320cf 100644 --- a/src/ts/core/utils.ts +++ b/src/ts/core/utils.ts @@ -1,23 +1,23 @@ import { T } from "../translations"; import { rando } from "@nastyox/rando.js"; import { WEB_STEAM_SSO_AUTHENTICATED } from "./steam_sso"; + const bigNumberSuffixTranslationKeys = ["thousands", "millions", "billions", "trillions"]; + /** * Returns a platform name - * {} */ export function getPlatformName(): "android" | "browser" | "ios" | "standalone" | "unknown" { if (G_IS_STANDALONE) { return "standalone"; - } - else if (G_IS_BROWSER) { + } else if (G_IS_BROWSER) { return "browser"; } return "unknown"; } + /** * Makes a new 2D array with undefined contents - * {} */ export function make2DUndefinedArray(w: number, h: number): Array> { const result = new Array(w); @@ -26,36 +26,39 @@ export function make2DUndefinedArray(w: number, h: number): Array> { } return result; } + /** * Creates a new map (an empty object without any props) */ -export function newEmptyMap() { +export function newEmptyMap(): object { return Object.create(null); } + /** * Returns a random integer in the range [start,end] */ export function randomInt(start: number, end: number) { return rando(start, end); } + /** * Access an object in a very annoying way, used for obsfuscation. */ -export function accessNestedPropertyReverse(obj: any, keys: Array) { +export function accessNestedPropertyReverse(obj: any, keys: Array): any { 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 - * @template T - * {} */ -export function randomChoice(arr: T[]): T { +export function randomChoice(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } + /** * Deletes from an array by swapping with the last element */ @@ -69,9 +72,11 @@ export function fastArrayDelete(array: Array, index: number) { 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 @@ -87,6 +92,7 @@ export function fastArrayDeleteValue(array: Array, value: any) { } return fastArrayDelete(array, index); } + /** * @see fastArrayDeleteValue */ @@ -100,6 +106,7 @@ export function fastArrayDeleteValueIfContained(array: Array, value: any) { } return fastArrayDelete(array, index); } + /** * Deletes from an array at the given index */ @@ -109,6 +116,7 @@ export function arrayDelete(array: Array, index: number) { } array.splice(index, 1); } + /** * Deletes the given value from an array */ @@ -123,19 +131,21 @@ export function arrayDeleteValue(array: Array, value: any) { } return arrayDelete(array, index); } + /** * Compare two floats for epsilon equality - * {} */ export function epsilonCompare(a: number, b: number, epsilon = 1e-5): boolean { return Math.abs(a - b) < epsilon; } + /** * Interpolates two numbers */ export function lerp(a: number, b: number, x: number) { return a * (1 - x) + b * x; } + /** * Finds a value which is nice to display, e.g. 15669 -> 15000. Also handles fractional stuff */ @@ -149,25 +159,20 @@ export function findNiceValue(num: number) { let roundAmount = 1; if (num > 50000) { roundAmount = 10000; - } - else if (num > 20000) { + } else if (num > 20000) { roundAmount = 5000; - } - else if (num > 5000) { + } else if (num > 5000) { roundAmount = 1000; - } - else if (num > 2000) { + } else if (num > 2000) { roundAmount = 500; - } - else if (num > 1000) { + } else if (num > 1000) { roundAmount = 100; - } - else if (num > 100) { + } else if (num > 100) { roundAmount = 20; - } - else if (num > 20) { + } else if (num > 20) { roundAmount = 5; } + const niceValue = Math.floor(num / roundAmount) * roundAmount; if (num >= 10) { return Math.round(niceValue); @@ -175,8 +180,10 @@ export function findNiceValue(num: number) { if (num >= 1) { return Math.round(niceValue * 10) / 10; } + return Math.round(niceValue * 100) / 100; } + /** * Finds a nice integer value * @see findNiceValue @@ -184,16 +191,18 @@ export function findNiceValue(num: number) { export function findNiceIntegerValue(num: number) { return Math.ceil(findNiceValue(num)); } + /** * Formats a big number - * {} */ -export function formatBigNumber(num: number, separator: string= = T.global.decimalSeparator): string { +export function formatBigNumber(num: number, separator: string = T.global.decimalSeparator): string { const sign = num < 0 ? "-" : ""; num = Math.abs(num); + if (num > 1e54) { return sign + T.global.infinite; } + if (num < 10 && !Number.isInteger(num)) { return sign + num.toFixed(2); } @@ -201,10 +210,10 @@ export function formatBigNumber(num: number, separator: string= = T.global.decim return sign + num.toFixed(1); } num = Math.floor(num); + if (num < 1000) { return sign + "" + num; - } - else { + } else { let leadingDigits = num; let suffix = ""; for (let suffixIndex = 0; suffixIndex < bigNumberSuffixTranslationKeys.length; ++suffixIndex) { @@ -222,11 +231,11 @@ export function formatBigNumber(num: number, separator: string= = T.global.decim return sign + leadingDigitsNoTrailingDecimal + suffix; } } + /** * Formats a big number, but does not add any suffix and instead uses its full representation - * {} */ -export function formatBigNumberFull(num: number, divider: string= = T.global.thousandsDivider): string { +export function formatBigNumberFull(num: number, divider: string = T.global.thousandsDivider): string { if (num < 1000) { return num + ""; } @@ -240,11 +249,12 @@ export function formatBigNumberFull(num: number, divider: string= = T.global.tho rest = Math.floor(rest / 1000); } out = rest + divider + out; + return out.substring(0, out.length - 1); } + /** * Waits two frames so the ui is updated - * {} */ export function waitNextFrame(): Promise { return new Promise(function (resolve) { @@ -255,44 +265,46 @@ export function waitNextFrame(): Promise { }); }); } + /** * Rounds 1 digit - * {} */ export function round1Digit(n: number): number { return Math.floor(n * 10.0) / 10.0; } + /** * Rounds 2 digits - * {} */ export function round2Digits(n: number): number { return Math.floor(n * 100.0) / 100.0; } + /** * Rounds 3 digits - * {} */ export function round3Digits(n: number): number { return Math.floor(n * 1000.0) / 1000.0; } + /** * Rounds 4 digits - * {} */ export function round4Digits(n: number): number { return Math.floor(n * 10000.0) / 10000.0; } + /** * Clamps a value between [min, max] */ -export function clamp(v: number, minimum: number= = 0, maximum: number= = 1) { +export function clamp(v: number, minimum: number = 0, maximum: number = 1) { return Math.max(minimum, Math.min(maximum, v)); } + /** * Helper method to create a new div element */ -export function makeDivElement(id: string= = null, classes: Array= = [], innerHTML: string= = "") { +export function makeDivElement(id: string = null, classes: Array = [], innerHTML: string = "") { const div = document.createElement("div"); if (id) { div.id = id; @@ -303,18 +315,25 @@ export function makeDivElement(id: string= = null, classes: Array= = [], div.innerHTML = innerHTML; return div; } + /** * Helper method to create a new div */ -export function makeDiv(parent: Element, id: string= = null, classes: Array= = [], innerHTML: string= = "") { +export function makeDiv( + parent: Element, + id: string = null, + classes: Array = [], + innerHTML: string = "" +) { const div = makeDivElement(id, classes, innerHTML); parent.appendChild(div); return div; } + /** * Helper method to create a new button element */ -export function makeButtonElement(classes: Array= = [], innerHTML: string= = "") { +export function makeButtonElement(classes: Array = [], innerHTML: string = "") { const element = document.createElement("button"); for (let i = 0; i < classes.length; ++i) { element.classList.add(classes[i]); @@ -323,14 +342,16 @@ export function makeButtonElement(classes: Array= = [], innerHTML: strin element.innerHTML = innerHTML; return element; } + /** * Helper method to create a new button */ -export function makeButton(parent: Element, classes: Array= = [], innerHTML: string= = "") { +export function makeButton(parent: Element, classes: Array = [], innerHTML: string = "") { const element = makeButtonElement(classes, innerHTML); parent.appendChild(element); return element; } + /** * Removes all children of the given element */ @@ -341,6 +362,7 @@ export function removeAllChildren(elem: Element) { range.deleteContents(); } } + /** * Returns if the game supports this browser */ @@ -352,9 +374,11 @@ export function isSupportedBrowser() { // 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_STANDALONE) { return true; } + // @ts-ignore var isChromium = window.chrome; var winNav = window.navigator; @@ -362,93 +386,102 @@ export function isSupportedBrowser() { // @ts-ignore 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 && + } else if ( + isChromium !== null && typeof isChromium !== "undefined" && vendorName === "Google Inc." && - isIEedge === false) { + isIEedge === false + ) { // is Google Chrome return true; - } - else { + } else { // not Google Chrome return false; } } + /** * Formats an amount of seconds into something like "5s ago" - * {} */ export function formatSecondsToTimeAgo(secs: number): string { const seconds = Math.floor(secs); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); + if (seconds < 60) { if (seconds === 1) { return T.global.time.oneSecondAgo; } return T.global.time.xSecondsAgo.replace("", "" + seconds); - } - else if (minutes < 60) { + } else if (minutes < 60) { if (minutes === 1) { return T.global.time.oneMinuteAgo; } return T.global.time.xMinutesAgo.replace("", "" + minutes); - } - else if (hours < 24) { + } else if (hours < 24) { if (hours === 1) { return T.global.time.oneHourAgo; } return T.global.time.xHoursAgo.replace("", "" + hours); - } - else { + } else { if (days === 1) { return T.global.time.oneDayAgo; } return T.global.time.xDaysAgo.replace("", "" + days); } } + /** * Formats seconds into a readable string like "5h 23m" - * {} */ export function formatSeconds(secs: number): string { const trans = T.global.time; secs = Math.ceil(secs); if (secs < 60) { return trans.secondsShort.replace("", "" + secs); - } - else if (secs < 60 * 60) { + } else if (secs < 60 * 60) { const minutes = Math.floor(secs / 60); const seconds = secs % 60; return trans.minutesAndSecondsShort .replace("", "" + seconds) .replace("", "" + minutes); - } - else { + } else { const hours = Math.floor(secs / 3600); const minutes = Math.floor(secs / 60) % 60; return trans.hoursAndMinutesShort.replace("", "" + minutes).replace("", "" + hours); } } + /** * Formats a number like 2.51 to "2.5" */ -export function round1DigitLocalized(speed: number, separator: string= = T.global.decimalSeparator) { +export function round1DigitLocalized(speed: number, separator: string = T.global.decimalSeparator) { return round1Digit(speed).toString().replace(".", separator); } + /** * Formats a number like 2.51 to "2.51 items / s" */ -export function formatItemsPerSecond(speed: number, double: boolean= = false, separator: string= = T.global.decimalSeparator) { - return ((speed === 1.0 - ? T.ingame.buildingPlacement.infoTexts.oneItemPerSecond - : T.ingame.buildingPlacement.infoTexts.itemsPerSecond.replace("", round2Digits(speed).toString().replace(".", separator))) + (double ? " " + T.ingame.buildingPlacement.infoTexts.itemsPerSecondDouble : "")); +export function formatItemsPerSecond( + speed: number, + double: boolean = false, + separator: string = T.global.decimalSeparator +) { + return ( + (speed === 1.0 + ? T.ingame.buildingPlacement.infoTexts.oneItemPerSecond + : T.ingame.buildingPlacement.infoTexts.itemsPerSecond.replace( + "", + round2Digits(speed).toString().replace(".", separator) + )) + (double ? " " + T.ingame.buildingPlacement.infoTexts.itemsPerSecondDouble : "") + ); } + /** * Rotates a flat 3x3 matrix clockwise * Entries: @@ -475,11 +508,11 @@ export function rotateFlatMatrix3x3(flatMatrix: Array) { flatMatrix[2], ]; } + /** * Generates rotated variants of the matrix - * {} */ -export function generateMatrixRotations(originalMatrix: Array): Object> { +export function generateMatrixRotations(originalMatrix: Array): { [idx: number]: Array } { const result = { 0: originalMatrix, }; @@ -492,9 +525,15 @@ export function generateMatrixRotations(originalMatrix: Array): Object tag */ @@ -530,6 +572,7 @@ export function fillInLinkIntoTranslation(translation: string, link: string) { .replace("", "") .replace("", ""); } + /** * Generates a file download */ @@ -537,11 +580,14 @@ export function generateFileDownload(filename: string, text: string) { var element = document.createElement("a"); element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); element.setAttribute("download", filename); + element.style.display = "none"; document.body.appendChild(element); + element.click(); document.body.removeChild(element); } + /** * Starts a file chooser */ @@ -549,25 +595,26 @@ export function startFileChoose(acceptedType: string = ".bin") { var input = document.createElement("input"); input.type = "file"; input.accept = acceptedType; + return new Promise(resolve => { input.onchange = _ => resolve(input.files[0]); input.click(); }); } + const MAX_ROMAN_NUMBER = 49; const romanLiteralsCache = ["0"]; -/** - * - * {} - */ + export function getRomanNumber(number: number): string { number = Math.max(0, Math.round(number)); if (romanLiteralsCache[number]) { return romanLiteralsCache[number]; } + if (number > MAX_ROMAN_NUMBER) { return String(number); } + function formatDigit(digit, unit, quintuple, decuple) { switch (digit) { case 0: @@ -587,22 +634,29 @@ export function getRomanNumber(number: number): string { return quintuple + formatDigit(digit - 5, unit, quintuple, decuple); } } + let thousands = Math.floor(number / 1000); let thousandsPart = ""; while (thousands > 0) { thousandsPart += "M"; thousands -= 1; } + const hundreds = Math.floor((number % 1000) / 100); const hundredsPart = formatDigit(hundreds, "C", "D", "M"); + const tens = Math.floor((number % 100) / 10); const tensPart = formatDigit(tens, "X", "L", "C"); + const units = number % 10; const unitsPart = formatDigit(units, "I", "V", "X"); + const formatted = thousandsPart + hundredsPart + tensPart + unitsPart; + romanLiteralsCache[number] = formatted; return formatted; } + /** * Returns the appropriate logo sprite path */ @@ -615,10 +669,11 @@ export function getLogoSprite() { } return "logo.png"; } + /** * Rejects a promise after X ms */ -export function timeoutPromise(promise: Promise, timeout = 30000) { +export function timeoutPromise(promise: Promise, timeout: number = 30000) { return Promise.race([ new Promise((resolve, reject) => { setTimeout(() => reject("timeout of " + timeout + " ms exceeded"), timeout); diff --git a/src/ts/core/vector.ts b/src/ts/core/vector.ts index 935e0cd5..fdcc62c3 100644 --- a/src/ts/core/vector.ts +++ b/src/ts/core/vector.ts @@ -1,324 +1,315 @@ import { globalConfig } from "./config"; import { safeModulo } from "./utils"; + const tileSize = globalConfig.tileSize; const halfTileSize = globalConfig.halfTileSize; -/** - * @enum {string} - */ -export const enumDirection = { - top: "top", - right: "right", - bottom: "bottom", - left: "left", -}; -/** - * @enum {string} - */ + +export enum enumDirection { + top = "top", + right = "right", + bottom = "bottom", + left = "left", +} + 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 const arrayAllDirections: Array = [ enumDirection.top, enumDirection.right, enumDirection.bottom, enumDirection.left, ]; -export class Vector { - public x = x || 0; - public y = y || 0; - constructor(x, y) { - } +export class Vector { + constructor(public x: number = 0, public y: number = 0) {} + /** * return a copy of the vector - * {} */ copy(): Vector { return new Vector(this.x, this.y); } + /** * Adds a vector and return a new vector - * {} */ add(other: Vector): Vector { return new Vector(this.x + other.x, this.y + other.y); } + /** * Adds a vector - * {} */ addInplace(other: Vector): Vector { this.x += other.x; this.y += other.y; return this; } + /** * Substracts a vector and return a new vector - * {} */ sub(other: Vector): Vector { return new Vector(this.x - other.x, this.y - other.y); } + /** * Subs a vector - * {} */ subInplace(other: Vector): Vector { this.x -= other.x; this.y -= other.y; return this; } + /** * Multiplies with a vector and return a new vector - * {} */ mul(other: Vector): Vector { return new Vector(this.x * other.x, this.y * other.y); } + /** * Adds two scalars and return a new vector - * {} */ addScalars(x: number, y: number): Vector { return new Vector(this.x + x, this.y + y); } + /** * Substracts a scalar and return a new vector - * {} */ subScalar(f: number): Vector { return new Vector(this.x - f, this.y - f); } + /** * Substracts two scalars and return a new vector - * {} */ subScalars(x: number, y: number): Vector { return new Vector(this.x - x, this.y - y); } + /** * Returns the euclidian length - * {} */ length(): number { return Math.hypot(this.x, this.y); } + /** * Returns the square length - * {} */ lengthSquare(): number { return this.x * this.x + this.y * this.y; } + /** * Divides both components by a scalar and return a new vector - * {} */ divideScalar(f: number): Vector { return new Vector(this.x / f, this.y / f); } + /** * Divides both components by the given scalars and return a new vector - * {} */ divideScalars(a: number, b: number): Vector { return new Vector(this.x / a, this.y / b); } + /** * Divides both components by a scalar - * {} */ divideScalarInplace(f: number): Vector { this.x /= f; this.y /= f; return this; } + /** * Multiplies both components with a scalar and return a new vector - * {} */ multiplyScalar(f: number): Vector { return new Vector(this.x * f, this.y * f); } + /** * Multiplies both components with two scalars and returns a new vector - * {} */ multiplyScalars(a: number, b: number): Vector { 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) - * {} */ maxScalar(f: number): Vector { return new Vector(Math.max(f, this.x), Math.max(f, this.y)); } + /** * Adds a scalar to both components and return a new vector - * {} */ addScalar(f: number): Vector { return new Vector(this.x + f, this.y + f); } + /** * Computes the component wise minimum and return a new vector - * {} */ min(v: Vector): Vector { 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 - * {} */ max(v: Vector): Vector { return new Vector(Math.max(v.x, this.x), Math.max(v.y, this.y)); } + /** * Computes the component wise absolute - * {} */ abs(): Vector { return new Vector(Math.abs(this.x), Math.abs(this.y)); } + /** * Computes the scalar product - * {} */ dot(v: Vector): number { return this.x * v.x + this.y * v.y; } + /** * Computes the distance to a given vector - * {} */ distance(v: Vector): number { return Math.hypot(this.x - v.x, this.y - v.y); } + /** * Computes the square distance to a given vectort - * {} */ distanceSquare(v: Vector): number { const dx = this.x - v.x; const dy = this.y - v.y; return dx * dx + dy * dy; } + /** * Returns x % f, y % f - * {} new vector */ modScalar(f: number): Vector { return new Vector(safeModulo(this.x, f), safeModulo(this.y, f)); } + /** * Computes and returns the center between both points - * {} */ centerPoint(v: Vector): Vector { const cx = this.x + v.x; const cy = this.y + v.y; return new Vector(cx / 2, cy / 2); } + /** * Computes componentwise floor and returns a new vector - * {} */ floor(): Vector { return new Vector(Math.floor(this.x), Math.floor(this.y)); } + /** * Computes componentwise ceil and returns a new vector - * {} */ ceil(): Vector { return new Vector(Math.ceil(this.x), Math.ceil(this.y)); } + /** * Computes componentwise round and return a new vector - * {} */ round(): Vector { return new Vector(Math.round(this.x), Math.round(this.y)); } + /** * Converts this vector from world to tile space and return a new vector - * {} */ toTileSpace(): Vector { 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 - * {} */ toStreetSpace(): Vector { 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 - * {} */ toWorldSpace(): Vector { return new Vector(this.x * tileSize, this.y * tileSize); } + /** * Converts this vector to world space and return a new vector - * {} */ toWorldSpaceCenterOfTile(): Vector { return new Vector(this.x * tileSize + halfTileSize, this.y * tileSize + halfTileSize); } + /** * Converts the top left tile position of this vector - * {} */ snapWorldToTile(): Vector { 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 - * {} */ normalize(): Vector { 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 - * {} */ normalizeIfGreaterOne(): Vector { 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 - * {} */ normalizedDirection(v: Vector): Vector { const dx = v.x - this.x; @@ -326,52 +317,55 @@ export class Vector { const len = Math.max(1e-5, Math.hypot(dx, dy)); return new Vector(dx / len, dy / len); } + /** * Returns a perpendicular vector - * {} */ findPerpendicular(): Vector { return new Vector(-this.y, this.x); } + /** * Returns the unnormalized direction to the other point - * {} */ direction(v: Vector): Vector { return new Vector(v.x - this.x, v.y - this.y); } + /** * Returns a string representation of the vector - * {} */ toString(): string { return this.x + "," + this.y; } + /** * Compares both vectors for exact equality. Does not do an epsilon compare - * {} */ equals(v: Vector): Boolean { return this.x === v.x && this.y === v.y; } + /** * Rotates this vector - * {} new vector + * @returns new vector */ rotated(angle: number): Vector { 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 - * {} this vector + * @returns this vector */ rotateInplaceFastMultipleOf90(angle: number): Vector { // 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: { @@ -380,6 +374,7 @@ export class Vector { case 90: { // sin = 1; // cos = 0; + const x = this.x; this.x = -this.y; this.y = x; @@ -388,6 +383,7 @@ export class Vector { case 180: { // sin = 0 // cos = -1 + this.x = -this.x; this.y = -this.y; return this; @@ -395,6 +391,7 @@ export class Vector { case 270: { // sin = -1 // cos = 0 + const x = this.x; this.x = this.y; this.y = -x; @@ -407,12 +404,14 @@ export class Vector { } // return new Vector(this.x * cos - this.y * sin, this.x * sin + this.y * cos); } + /** * Rotates this vector - * {} new vector + * @returns new vector */ rotateFastMultipleOf90(angle: number): Vector { assert(angle >= 0 && angle <= 360, "Invalid angle, please clamp first: " + angle); + switch (angle) { case 360: case 0: { @@ -433,9 +432,9 @@ export class Vector { } } } + /** * Helper method to rotate a direction - * {} */ static transformDirectionFromMultipleOf90(direction: enumDirection, angle: number): enumDirection { if (angle === 0 || angle === 360) { @@ -500,74 +499,72 @@ export class Vector { return; } } + /** * Compares both vectors for epsilon equality - * {} */ equalsEpsilon(v: Vector, epsilon = 1e-5): Boolean { return Math.abs(this.x - v.x) < 1e-5 && Math.abs(this.y - v.y) < epsilon; } + /** * Returns the angle - * {} 0 .. 2 PI + * @returns 0 .. 2 PI */ angle(): number { return Math.atan2(this.y, this.x) + Math.PI / 2; } + /** * Serializes the vector to a string - * {} */ serializeTile(): string { 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 }; } - /** - * {} - */ + serializeTileToInt(): number { return this.x + this.y * 256; } - /** - * - * {} - */ + static deserializeTileFromInt(i: number): Vector { const x = i % 256; const y = Math.floor(i / 256); return new Vector(x, y); } + /** * Deserializes a vector from a string - * {} */ static deserializeTile(s: string): Vector { return new Vector(s.charCodeAt(0) - 33, s.charCodeAt(1) - 33); } + /** * Deserializes a vector from a serialized json object - * {} */ - static fromSerializedObject(obj: object): Vector { + static fromSerializedObject(obj: any): Vector { 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 */ export function mixVector(v1: Vector, v2: Vector, a: number) { 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),