1
0
mirror of https://github.com/tobspr/shapez.io.git synced 2025-06-13 13:04:03 +00:00

Cleaned up /core

This commit is contained in:
Bagel03 2022-11-18 22:53:49 -05:00
parent 4dd57c1605
commit c399e0d7f1
43 changed files with 2183 additions and 1152 deletions

View File

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

View File

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

View File

@ -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<any> {
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();

View File

@ -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<string, SpriteDefinition>;
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)

View File

@ -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<void> {
if (this.mainMenuPromise) {
return this.mainMenuPromise;
}
logger.log("⏰ Loading main menu assets");
return (this.mainMenuPromise = this.loadAssets(MAIN_MENU_ASSETS));
}
getIngamePromise() {
getIngamePromise(): Promise<void> {
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<void>)[] = [];
let promiseFunctions: ((progressHandler: (progress: number) => void) => Promise<void>)[] = [];
// 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 + "<br>" + err, ["retry"])
.showWarning(
T.dialogs.resourceLoadFailed.title,
T.dialogs.resourceLoadFailed.descSteamDemo + "<br>" + err,
["retry"]
)
.retry.add(() => window.location.reload());
}
else {
} else {
dialogs
.showWarning(T.dialogs.resourceLoadFailed.title, T.dialogs.resourceLoadFailed.descWeb.replace("<demoOnSteamLinkText>", `<a href="https://get.shapez.io/resource_timeout" target="_blank">${T.dialogs.resourceLoadFailed.demoLinkText}</a>`) +
"<br>" +
err, ["retry"])
.showWarning(
T.dialogs.resourceLoadFailed.title,
T.dialogs.resourceLoadFailed.descWeb.replace(
"<demoOnSteamLinkText>",
`<a href="https://get.shapez.io/resource_timeout" target="_blank">${T.dialogs.resourceLoadFailed.demoLinkText}</a>`
) +
"<br>" +
err,
["retry"]
)
.retry.add(() => window.location.reload());
}
}
preloadWithProgress(src, progressHandler) {
preloadWithProgress(src: string, progressHandler: (percent: number) => void): Promise<string> {
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;

View File

@ -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<string, Map<string, CacheEntry>> = 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;

View File

@ -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<CanvasCacheEntry> = [];
/**
* Buckets for each width * height combination
*/
const freeCanvasBuckets: Map<number, Array<CanvasCacheEntry>> = 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;

View File

@ -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<ClickDetector> = [];
// 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);

View File

@ -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 */
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<T extends { getId: () => 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<object> {
getEntries(): Array<T> {
return this.entries;
}
/**
* Returns all registered ids
* {}
*/
getAllIds(): Array<string> {
return this.entryIds;
}
/**
* Returns amount of stored entries
* {}
*/
getNumEntries(): number {
return this.entries.length;

View File

@ -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<ClickDetector> = [];
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) {

View File

@ -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<MetaBuilding> = 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<MetaBuilding>();
export let gBuildingsByCategory: {
[idx: string]: Array<Class<MetaBuilding>>;
} = null;
export let gComponentRegistry: FactoryTemplate<Component> = new Factory("component");
export let gGameModeRegistry: FactoryTemplate<GameMode> = new Factory("gameMode");
export let gGameSpeedRegistry: FactoryTemplate<BaseGameSpeed> = new Factory("gamespeed");
export let gItemRegistry: FactoryTemplate<BaseItem> = new Factory("item");
export let gComponentRegistry = new Factory<Component>("component");
export let gGameModeRegistry = new Factory<GameMode>("gameMode");
export let gGameSpeedRegistry = new Factory<BaseGameSpeed>("gamespeed");
export let gItemRegistry = new Factory<BaseItem>("item");
// Helpers
export function initBuildingsByCategory(buildings: {
[idx: string]: Array<Class<MetaBuilding>>;
}) {
export function initBuildingsByCategory(buildings: { [idx: string]: Array<Class<MetaBuilding>> }) {
gBuildingsByCategory = buildings;
}

View File

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

View File

@ -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<InputReceiver> = [];
public filters: Array<function(: boolean):boolean> = [];
public app: Application;
public receiverStack: Array<InputReceiver> = [];
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,

View File

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

View File

@ -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<string, BaseSprite> = 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<HTMLImageElement | null> {
internalPreloadImage(
key: string,
progressHandler: (progress: number) => void
): Promise<HTMLImageElement | null> {
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<void> {
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<void> {
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();

View File

@ -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<any>) {
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 {

View File

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

View File

@ -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<Buttons extends string[] = []> {
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<any[]>;
} = {} 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 = "<div class='optionParent'>";
options.options.forEach(({ value, text, desc = null, iconPrefix = null }) => {
const descHtml = desc ? `<span class="desc">${desc}</span>` : "";
let iconHtml = iconPrefix ? `<span class="icon icon-${iconPrefix}-${value}"></span>` : "";
html += `
<div class='option ${value === options.active ? "active" : ""} ${iconPrefix ? "hasIcon" : ""}' data-optionvalue='${value}'>
<div class='option ${value === options.active ? "active" : ""} ${
iconPrefix ? "hasIcon" : ""
}' data-optionvalue='${value}'>
${iconHtml}
<span class='title'>${text}</span>
${descHtml}
</div>
`;
});
html += "</div>";
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 + "<br>";
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) {

View File

@ -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 {
</div>
`;
}
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 `
<div class="formElement checkBoxFormElem">
@ -126,7 +151,8 @@ export class FormElementCheckbox extends FormElement {
</div>
`;
}
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 {
</div>
`;
}
bindEvents(parent: HTMLElement, clickTrackers: Array<ClickDetector>) {
bindEvents(parent: HTMLElement, clickTrackers: Array<ClickDetector>) {
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() {}
}

View File

@ -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]]] : []),
[]
);
};
}
}

View File

@ -1,5 +1,6 @@
const queryString = require("query-string");
const options = queryString.parse(location.search);
export let queryParamOptions = {
embedProvider: null,
abtVariant: null,

View File

@ -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<void> {
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<void> {
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()) {

View File

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

View File

@ -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<Promise> = [];
public pendingPromises: Array<Promise<any>> = [];
constructor() {
}
/**
*
* {}
*/
watch(promise: Promise<any>): Promise<any> {
// 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, ")");

View File

@ -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() {

View File

@ -16,6 +16,7 @@ function Mash() {
return (n >>> 0) * 2.3283064365386963e-10; // 2^-32
};
}
function makeNewRng(seed: number | string) {
// Johannes Baagøe <baagoe@baagoe.com>, 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
*/

View File

@ -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
*/

View File

@ -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<T extends any[]> {
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
*/

View File

@ -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<T extends { getId(): string }> {
public entries: T[] = [];
public idToEntry: {
[id: string]: T;
} = {};
constructor(public id?: string) {}
constructor(id) {
}
getId() {
return this.id;
}
register(classHandle) {
register(classHandle: Class<T>) {
// 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>): 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<object> {
getEntries(): Array<T> {
return this.entries;
}
/**
* Returns all registered ids
* {}
*/
getAllIds(): Array<string> {
return Object.keys(this.idToEntry);
}
/**
* Returns amount of stored entries
* {}
*/
getNumEntries(): number {
return this.entries.length;

View File

@ -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 `
<span class="spritesheetImage" style="
background-image: url('${srcSafe}');
@ -229,23 +348,23 @@ export class AtlasSprite extends BaseSprite {
width: ${round3Digits(widthRelative * 100.0)}%;
height: ${round3Digits(heightRelative * 100.0)}%;
background-repeat: repeat;
background-position: ${round3Digits(bgXRelative * 100.0)}% ${round3Digits(bgYRelative * 100.0)}%;
background-position: ${round3Digits(bgXRelative * 100.0)}% ${round3Digits(
bgYRelative * 100.0
)}%;
background-size: ${round3Digits(bgW * 100.0)}% ${round3Digits(bgH * 100.0)}%;
"></span>
`;
}
}
export class RegularSprite extends BaseSprite {
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

View File

@ -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<typeof Component>, 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
*/

View File

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

View File

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

View File

@ -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 {
</div>
`;
}
/**
* 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 = `
<div class="headerBar">
<h1><button class="backButton"></button> ${this.getStateHeaderTitle()}</h1>
</div>`;
}
return `
${headerHtml}
<div class="container">
@ -99,7 +113,9 @@ export class TextualGameState extends GameState {
</div>
`;
}
//// 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);
}
}

View File

@ -1,7 +1,10 @@
export class TrackedState {
public lastSeenValue = null;
export class TrackedState<T> {
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;
}
}

View File

@ -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<Array<any>> {
const result = new Array(w);
@ -26,36 +26,39 @@ export function make2DUndefinedArray(w: number, h: number): Array<Array<any>> {
}
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<string>) {
export function accessNestedPropertyReverse(obj: any, keys: Array<string>): 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<T>(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<any>, 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<any>, value: any) {
}
return fastArrayDelete(array, index);
}
/**
* @see fastArrayDeleteValue
*/
@ -100,6 +106,7 @@ export function fastArrayDeleteValueIfContained(array: Array<any>, value: any) {
}
return fastArrayDelete(array, index);
}
/**
* Deletes from an array at the given index
*/
@ -109,6 +116,7 @@ export function arrayDelete(array: Array<any>, index: number) {
}
array.splice(index, 1);
}
/**
* Deletes the given value from an array
*/
@ -123,19 +131,21 @@ export function arrayDeleteValue(array: Array<any>, 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<void> {
return new Promise(function (resolve) {
@ -255,44 +265,46 @@ export function waitNextFrame(): Promise<void> {
});
});
}
/**
* 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<string>= = [], innerHTML: string= = "") {
export function makeDivElement(id: string = null, classes: Array<string> = [], innerHTML: string = "") {
const div = document.createElement("div");
if (id) {
div.id = id;
@ -303,18 +315,25 @@ export function makeDivElement(id: string= = null, classes: Array<string>= = [],
div.innerHTML = innerHTML;
return div;
}
/**
* Helper method to create a new div
*/
export function makeDiv(parent: Element, id: string= = null, classes: Array<string>= = [], innerHTML: string= = "") {
export function makeDiv(
parent: Element,
id: string = null,
classes: Array<string> = [],
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<string>= = [], innerHTML: string= = "") {
export function makeButtonElement(classes: Array<string> = [], 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<string>= = [], innerHTML: strin
element.innerHTML = innerHTML;
return element;
}
/**
* Helper method to create a new button
*/
export function makeButton(parent: Element, classes: Array<string>= = [], innerHTML: string= = "") {
export function makeButton(parent: Element, classes: Array<string> = [], 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("<x>", "" + seconds);
}
else if (minutes < 60) {
} else if (minutes < 60) {
if (minutes === 1) {
return T.global.time.oneMinuteAgo;
}
return T.global.time.xMinutesAgo.replace("<x>", "" + minutes);
}
else if (hours < 24) {
} else if (hours < 24) {
if (hours === 1) {
return T.global.time.oneHourAgo;
}
return T.global.time.xHoursAgo.replace("<x>", "" + hours);
}
else {
} else {
if (days === 1) {
return T.global.time.oneDayAgo;
}
return T.global.time.xDaysAgo.replace("<x>", "" + 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("<seconds>", "" + 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>", "" + seconds)
.replace("<minutes>", "" + minutes);
}
else {
} else {
const hours = Math.floor(secs / 3600);
const minutes = Math.floor(secs / 60) % 60;
return trans.hoursAndMinutesShort.replace("<minutes>", "" + minutes).replace("<hours>", "" + 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("<x>", 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(
"<x>",
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<number>) {
flatMatrix[2],
];
}
/**
* Generates rotated variants of the matrix
* {}
*/
export function generateMatrixRotations(originalMatrix: Array<number>): Object<number, Array<number>> {
export function generateMatrixRotations(originalMatrix: Array<number>): { [idx: number]: Array<number> } {
const result = {
0: originalMatrix,
};
@ -492,9 +525,15 @@ export function generateMatrixRotations(originalMatrix: Array<number>): Object<n
return result;
}
export type DirectionalObject = {
top: any;
right: any;
bottom: any;
left: any;
};
/**
* Rotates a directional object
* {}
*/
export function rotateDirectionalObject(obj: DirectionalObject, rotation): DirectionalObject {
const queue = [obj.top, obj.right, obj.bottom, obj.left];
@ -502,6 +541,7 @@ export function rotateDirectionalObject(obj: DirectionalObject, rotation): Direc
rotation -= 90;
queue.push(queue.shift());
}
return {
top: queue[0],
right: queue[1],
@ -509,19 +549,21 @@ export function rotateDirectionalObject(obj: DirectionalObject, rotation): Direc
left: queue[3],
};
}
/**
* Modulo which works for negative numbers
*/
export function safeModulo(n: number, m: number) {
return ((n % m) + m) % m;
}
/**
* Returns a smooth pulse between 0 and 1
* {}
*/
export function smoothPulse(time: number): number {
return Math.sin(time * 4) * 0.5 + 0.5;
}
/**
* Fills in a <link> tag
*/
@ -530,6 +572,7 @@ export function fillInLinkIntoTranslation(translation: string, link: string) {
.replace("<link>", "<a href='" + link + "' target='_blank'>")
.replace("</link>", "</a>");
}
/**
* 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<any>, timeout: number = 30000) {
return Promise.race([
new Promise((resolve, reject) => {
setTimeout(() => reject("timeout of " + timeout + " ms exceeded"), timeout);

View File

@ -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> = [
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),