mirror of
https://github.com/tobspr/shapez.io.git
synced 2026-03-02 03:39:21 +00:00
Initial commit
This commit is contained in:
362
src/js/application.js
Normal file
362
src/js/application.js
Normal file
@@ -0,0 +1,362 @@
|
||||
import { AnimationFrame } from "./core/animation_frame";
|
||||
import { performanceNow } from "./core/builtins";
|
||||
import { GameState } from "./core/game_state";
|
||||
import { GLOBAL_APP, setGlobalApp } from "./core/globals";
|
||||
import { InputDistributor } from "./core/input_distributor";
|
||||
import { StateManager } from "./core/state_manager";
|
||||
import { getPlatformName, waitNextFrame } from "./core/utils";
|
||||
import { SavegameManager } from "./savegame/savegame_manager";
|
||||
import { AdProviderInterface } from "./platform/ad_provider";
|
||||
import { NoAdProvider } from "./platform/ad_providers/no_ad_provider";
|
||||
import { SoundImplBrowser } from "./platform/browser/sound";
|
||||
import { StorageImplBrowser } from "./platform/browser/storage";
|
||||
import { PlatformWrapperImplBrowser } from "./platform/browser/wrapper";
|
||||
import { SoundInterface } from "./platform/sound";
|
||||
import { StorageInterface } from "./platform/storage";
|
||||
import { PlatformWrapperInterface } from "./platform/wrapper";
|
||||
import { ApplicationSettings } from "./profile/application_settings";
|
||||
import { Vector } from "./core/vector";
|
||||
import { createLogger, logSection } from "./core/logging";
|
||||
import { TrackedState } from "./core/tracked_state";
|
||||
import { IS_MOBILE } from "./core/config";
|
||||
import { BackgroundResourcesLoader } from "./core/background_resources_loader";
|
||||
import { PreloadState } from "./states/preload";
|
||||
import { MainMenuState } from "./states/main_menu";
|
||||
import { InGameState } from "./states/ingame";
|
||||
import { AnalyticsInterface } from "./platform/analytics";
|
||||
import { GoogleAnalyticsImpl } from "./platform/browser/google_analytics";
|
||||
import { Loader } from "./core/loader";
|
||||
import { GameAnalyticsInterface } from "./platform/game_analytics";
|
||||
import { GameAnalyticsDotCom } from "./platform/browser/game_analytics";
|
||||
|
||||
const logger = createLogger("application");
|
||||
|
||||
// Set the name of the hidden property and the change event for visibility
|
||||
let pageHiddenPropName, pageVisibilityEventName;
|
||||
if (typeof document.hidden !== "undefined") {
|
||||
// Opera 12.10 and Firefox 18 and later support
|
||||
pageHiddenPropName = "hidden";
|
||||
pageVisibilityEventName = "visibilitychange";
|
||||
// @ts-ignore
|
||||
} else if (typeof document.msHidden !== "undefined") {
|
||||
pageHiddenPropName = "msHidden";
|
||||
pageVisibilityEventName = "msvisibilitychange";
|
||||
// @ts-ignore
|
||||
} else if (typeof document.webkitHidden !== "undefined") {
|
||||
pageHiddenPropName = "webkitHidden";
|
||||
pageVisibilityEventName = "webkitvisibilitychange";
|
||||
}
|
||||
|
||||
export class Application {
|
||||
constructor() {
|
||||
assert(!GLOBAL_APP, "Tried to construct application twice");
|
||||
logger.log("Creating application, platform =", getPlatformName());
|
||||
setGlobalApp(this);
|
||||
|
||||
this.unloaded = false;
|
||||
|
||||
// Global stuff
|
||||
this.settings = new ApplicationSettings(this);
|
||||
this.ticker = new AnimationFrame();
|
||||
this.stateMgr = new StateManager(this);
|
||||
this.savegameMgr = new SavegameManager(this);
|
||||
this.inputMgr = new InputDistributor(this);
|
||||
this.backgroundResourceLoader = new BackgroundResourcesLoader(this);
|
||||
|
||||
// Platform dependent stuff
|
||||
|
||||
/** @type {StorageInterface} */
|
||||
this.storage = null;
|
||||
|
||||
/** @type {SoundInterface} */
|
||||
this.sound = null;
|
||||
|
||||
/** @type {PlatformWrapperInterface} */
|
||||
this.platformWrapper = null;
|
||||
|
||||
/** @type {AdProviderInterface} */
|
||||
this.adProvider = null;
|
||||
|
||||
/** @type {AnalyticsInterface} */
|
||||
this.analytics = null;
|
||||
|
||||
/** @type {GameAnalyticsInterface} */
|
||||
this.gameAnalytics = null;
|
||||
|
||||
this.initPlatformDependentInstances();
|
||||
|
||||
// Track if the window is focused (only relevant for browser)
|
||||
this.focused = true;
|
||||
|
||||
// Track if the window is visible
|
||||
this.pageVisible = true;
|
||||
|
||||
// Track if the app is paused (cordova)
|
||||
this.applicationPaused = false;
|
||||
|
||||
/** @type {TypedTrackedState<boolean>} */
|
||||
this.trackedIsRenderable = new TrackedState(this.onAppRenderableStateChanged, this);
|
||||
|
||||
// Dimensions
|
||||
this.screenWidth = 0;
|
||||
this.screenHeight = 0;
|
||||
|
||||
// Store the timestamp where we last checked for a screen resize, since orientationchange is unreliable with cordova
|
||||
this.lastResizeCheck = null;
|
||||
|
||||
// Store the mouse position, or null if not available
|
||||
/** @type {Vector|null} */
|
||||
this.mousePosition = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all platform instances
|
||||
*/
|
||||
initPlatformDependentInstances() {
|
||||
logger.log("Creating platform dependent instances");
|
||||
|
||||
// Start with empty ad provider
|
||||
this.adProvider = new NoAdProvider(this);
|
||||
this.storage = new StorageImplBrowser(this);
|
||||
this.sound = new SoundImplBrowser(this);
|
||||
this.platformWrapper = new PlatformWrapperImplBrowser(this);
|
||||
this.analytics = new GoogleAnalyticsImpl(this);
|
||||
this.gameAnalytics = new GameAnalyticsDotCom(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers all game states
|
||||
*/
|
||||
registerStates() {
|
||||
/** @type {Array<typeof GameState>} */
|
||||
const states = [PreloadState, MainMenuState, InGameState];
|
||||
|
||||
for (let i = 0; i < states.length; ++i) {
|
||||
this.stateMgr.register(states[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers all event listeners
|
||||
*/
|
||||
registerEventListeners() {
|
||||
window.addEventListener("focus", this.onFocus.bind(this));
|
||||
window.addEventListener("blur", this.onBlur.bind(this));
|
||||
|
||||
window.addEventListener("resize", () => this.checkResize(), true);
|
||||
window.addEventListener("orientationchange", () => this.checkResize(), true);
|
||||
|
||||
if (!G_IS_MOBILE_APP && !IS_MOBILE) {
|
||||
window.addEventListener("mousemove", this.handleMousemove.bind(this));
|
||||
}
|
||||
|
||||
// Unload events
|
||||
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this), true);
|
||||
window.addEventListener("unload", this.onUnload.bind(this), true);
|
||||
|
||||
document.addEventListener(pageVisibilityEventName, this.handleVisibilityChange.bind(this), false);
|
||||
|
||||
// Track touches so we can update the focus appropriately
|
||||
document.addEventListener("touchstart", this.updateFocusAfterUserInteraction.bind(this), true);
|
||||
document.addEventListener("touchend", this.updateFocusAfterUserInteraction.bind(this), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the focus after a touch
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
updateFocusAfterUserInteraction(event) {
|
||||
const target = /** @type {HTMLElement} */ (event.target);
|
||||
if (!target || !target.tagName) {
|
||||
// Safety check
|
||||
logger.warn("Invalid touchstart/touchend event:", event);
|
||||
return;
|
||||
}
|
||||
|
||||
// When clicking an element which is not the currently focused one, defocus it
|
||||
if (target !== document.activeElement) {
|
||||
// @ts-ignore
|
||||
if (document.activeElement.blur) {
|
||||
// @ts-ignore
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
|
||||
// If we click an input field, focus it now
|
||||
if (target.tagName.toLowerCase() === "input") {
|
||||
// We *really* need the focus
|
||||
waitNextFrame().then(() => target.focus());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a page visibility change event
|
||||
* @param {Event} event
|
||||
*/
|
||||
handleVisibilityChange(event) {
|
||||
const pageVisible = !document[pageHiddenPropName];
|
||||
if (pageVisible !== this.pageVisible) {
|
||||
this.pageVisible = pageVisible;
|
||||
logger.log("Visibility changed:", this.pageVisible);
|
||||
this.trackedIsRenderable.set(this.isRenderable());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a mouse move event
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
handleMousemove(event) {
|
||||
this.mousePosition = new Vector(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal on focus handler
|
||||
*/
|
||||
onFocus() {
|
||||
this.focused = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal blur handler
|
||||
*/
|
||||
onBlur() {
|
||||
this.focused = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the app is currently visible
|
||||
*/
|
||||
isRenderable() {
|
||||
return !this.applicationPaused && this.pageVisible;
|
||||
}
|
||||
|
||||
onAppRenderableStateChanged(renderable) {
|
||||
logger.log("Application renderable:", renderable);
|
||||
if (!renderable) {
|
||||
this.stateMgr.getCurrentState().onAppPause();
|
||||
} else {
|
||||
// Got resume
|
||||
this.stateMgr.getCurrentState().onAppResume();
|
||||
this.checkResize();
|
||||
}
|
||||
|
||||
this.sound.onPageRenderableStateChanged(renderable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal unload handler
|
||||
*/
|
||||
onUnload(event) {
|
||||
if (!this.unloaded) {
|
||||
logSection("UNLOAD HANDLER", "#f77");
|
||||
this.unloaded = true;
|
||||
this.stateMgr.getCurrentState().onBeforeExit();
|
||||
this.deinitialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal before-unload handler
|
||||
*/
|
||||
onBeforeUnload(event) {
|
||||
logSection("BEFORE UNLOAD HANDLER", "#f77");
|
||||
|
||||
if (!G_IS_DEV && this.stateMgr.getCurrentState().getHasUnloadConfirmation()) {
|
||||
if (G_IS_STANDALONE) {
|
||||
} else {
|
||||
// Need to show a "Are you sure you want to exit"
|
||||
event.preventDefault();
|
||||
event.returnValue = "Are you sure you want to exit?";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the application
|
||||
*/
|
||||
boot() {
|
||||
this.registerStates();
|
||||
this.registerEventListeners();
|
||||
|
||||
Loader.linkAppAfterBoot(this);
|
||||
|
||||
this.stateMgr.moveToState("PreloadState");
|
||||
|
||||
// Starting rendering
|
||||
this.ticker.frameEmitted.add(this.onFrameEmitted, this);
|
||||
this.ticker.bgFrameEmitted.add(this.onBackgroundFrame, this);
|
||||
this.ticker.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deinitializes the application
|
||||
*/
|
||||
deinitialize() {
|
||||
return this.sound.deinitialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Background frame update callback
|
||||
* @param {number} dt
|
||||
*/
|
||||
onBackgroundFrame(dt) {
|
||||
if (this.isRenderable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateMgr.getCurrentState().onBackgroundTick(dt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame update callback
|
||||
* @param {number} dt
|
||||
*/
|
||||
onFrameEmitted(dt) {
|
||||
if (!this.isRenderable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = performanceNow();
|
||||
|
||||
// Periodically check for resizes, this is expensive (takes 2-3ms so only do it once in a while!)
|
||||
if (!this.lastResizeCheck || time - this.lastResizeCheck > 1000) {
|
||||
this.checkResize();
|
||||
this.lastResizeCheck = time;
|
||||
}
|
||||
|
||||
this.stateMgr.getCurrentState().onRender(dt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the app resized. Only does this once in a while
|
||||
* @param {boolean} forceUpdate Forced update of the dimensions
|
||||
*/
|
||||
checkResize(forceUpdate = false) {
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
if (this.screenWidth !== w || this.screenHeight !== h || forceUpdate) {
|
||||
this.screenWidth = w;
|
||||
this.screenHeight = h;
|
||||
this.stateMgr.getCurrentState().onResized(this.screenWidth, this.screenHeight);
|
||||
|
||||
const scale = this.getEffectiveUiScale();
|
||||
waitNextFrame().then(() => document.documentElement.style.setProperty("--ui-scale", scale));
|
||||
window.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective ui sclae
|
||||
*/
|
||||
getEffectiveUiScale() {
|
||||
return this.platformWrapper.getUiScale() * this.settings.getInterfaceScaleValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback after ui scale has changed
|
||||
*/
|
||||
updateAfterUiScaleChanged() {
|
||||
this.checkResize(true);
|
||||
}
|
||||
}
|
||||
71
src/js/core/animation_frame.js
Normal file
71
src/js/core/animation_frame.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Signal } from "./signal";
|
||||
|
||||
// @ts-ignore
|
||||
import BackgroundAnimationFrameEmitterWorker from "../webworkers/background_animation_frame_emittter.worker";
|
||||
|
||||
import { createLogger } from "./logging";
|
||||
import { performanceNow } from "./builtins";
|
||||
|
||||
const logger = createLogger("animation_frame");
|
||||
|
||||
const maxDtMs = 1000;
|
||||
const resetDtMs = 16;
|
||||
|
||||
export class AnimationFrame {
|
||||
constructor() {
|
||||
this.frameEmitted = new Signal();
|
||||
this.bgFrameEmitted = new Signal();
|
||||
|
||||
this.lastTime = null;
|
||||
this.bgLastTime = null;
|
||||
|
||||
this.boundMethod = this.handleAnimationFrame.bind(this);
|
||||
|
||||
/** @type {Worker} */
|
||||
this.backgroundWorker = new BackgroundAnimationFrameEmitterWorker();
|
||||
this.backgroundWorker.addEventListener("error", err => {
|
||||
logger.error("Error in background fps worker:", err);
|
||||
});
|
||||
this.backgroundWorker.addEventListener("message", this.handleBackgroundTick.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MessageEvent} event
|
||||
*/
|
||||
handleBackgroundTick(event) {
|
||||
const time = performanceNow();
|
||||
if (!this.bgLastTime) {
|
||||
// First update, first delta is always 16ms
|
||||
this.bgFrameEmitted.dispatch(1000 / 60);
|
||||
} else {
|
||||
let dt = time - this.bgLastTime;
|
||||
if (dt > maxDtMs) {
|
||||
dt = resetDtMs;
|
||||
}
|
||||
this.bgFrameEmitted.dispatch(dt);
|
||||
}
|
||||
this.bgLastTime = time;
|
||||
}
|
||||
|
||||
start() {
|
||||
assertAlways(window.requestAnimationFrame, "requestAnimationFrame is not supported!");
|
||||
this.handleAnimationFrame();
|
||||
}
|
||||
|
||||
handleAnimationFrame(time) {
|
||||
if (!this.lastTime) {
|
||||
// First update, first delta is always 16ms
|
||||
this.frameEmitted.dispatch(1000 / 60);
|
||||
} else {
|
||||
let dt = time - this.lastTime;
|
||||
if (dt > maxDtMs) {
|
||||
// warn(this, "Clamping", dt, "to", resetDtMs);
|
||||
dt = resetDtMs;
|
||||
}
|
||||
this.frameEmitted.dispatch(dt);
|
||||
}
|
||||
this.lastTime = time;
|
||||
window.requestAnimationFrame(this.boundMethod);
|
||||
}
|
||||
}
|
||||
26
src/js/core/assert.js
Normal file
26
src/js/core/assert.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createLogger } from "./logging";
|
||||
|
||||
const logger = createLogger("assert");
|
||||
|
||||
let assertionErrorShown = false;
|
||||
|
||||
function initAssert() {
|
||||
/**
|
||||
* Expects a given condition to be true
|
||||
* @param {Boolean} condition
|
||||
* @param {...String} failureMessage
|
||||
*/
|
||||
// @ts-ignore
|
||||
window.assert = function (condition, ...failureMessage) {
|
||||
if (!condition) {
|
||||
logger.error("assertion failed:", ...failureMessage);
|
||||
if (!assertionErrorShown) {
|
||||
// alert("Assertion failed (the game will try to continue to run): \n\n" + failureMessage);
|
||||
assertionErrorShown = true;
|
||||
}
|
||||
throw new Error("AssertionError: " + failureMessage.join(" "));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
initAssert();
|
||||
143
src/js/core/async_compression.js
Normal file
143
src/js/core/async_compression.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// @ts-ignore
|
||||
import CompressionWorker from "../webworkers/compression.worker";
|
||||
import { createLogger } from "./logging";
|
||||
import { compressX64 } from "./lzstring";
|
||||
import { performanceNow, JSON_stringify } from "./builtins";
|
||||
|
||||
const logger = createLogger("async_compression");
|
||||
|
||||
export let compressionPrefix = String.fromCodePoint(1);
|
||||
|
||||
function checkCryptPrefix(prefix) {
|
||||
try {
|
||||
window.localStorage.setItem("prefix_test", prefix);
|
||||
window.localStorage.removeItem("prefix_test");
|
||||
return true;
|
||||
} catch (ex) {
|
||||
logger.warn("Prefix '" + prefix + "' not available");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkCryptPrefix(compressionPrefix)) {
|
||||
logger.warn("Switching to basic prefix");
|
||||
compressionPrefix = " ";
|
||||
if (!checkCryptPrefix(compressionPrefix)) {
|
||||
logger.warn("Prefix not available, ls seems to be unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* errorHandler: function(any) : void,
|
||||
* resolver: function(any) : void,
|
||||
* startTime: number
|
||||
* }} JobEntry
|
||||
*/
|
||||
|
||||
class AsynCompression {
|
||||
constructor() {
|
||||
/** @type {Worker} */
|
||||
this.worker = new CompressionWorker();
|
||||
|
||||
this.currentJobId = 1000;
|
||||
|
||||
/** @type {Object.<number, JobEntry>} */
|
||||
this.currentJobs = {};
|
||||
|
||||
this.worker.addEventListener("message", event => {
|
||||
const { jobId, result } = event.data;
|
||||
const jobData = this.currentJobs[jobId];
|
||||
if (!jobData) {
|
||||
logger.error("Failed to resolve job result, job id", jobId, "is not known");
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = performanceNow() - jobData.startTime;
|
||||
// log(this, "Got response from worker within", duration.toFixed(2), "ms");
|
||||
const resolver = jobData.resolver;
|
||||
delete this.currentJobs[jobId];
|
||||
resolver(result);
|
||||
});
|
||||
|
||||
this.worker.addEventListener("error", err => {
|
||||
logger.error("Got error from webworker:", err, "aborting all jobs");
|
||||
const failureCalls = [];
|
||||
for (const jobId in this.currentJobs) {
|
||||
failureCalls.push(this.currentJobs[jobId].errorHandler);
|
||||
}
|
||||
this.currentJobs = {};
|
||||
for (let i = 0; i < failureCalls.length; ++i) {
|
||||
failureCalls[i](err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses file
|
||||
* @param {string} text
|
||||
*/
|
||||
compressFileAsync(text) {
|
||||
return this.internalQueueJob("compressFile", {
|
||||
text,
|
||||
compressionPrefix,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses regulary
|
||||
* @param {string} text
|
||||
*/
|
||||
compressX64Async(text) {
|
||||
if (text.length < 1024) {
|
||||
// Ok so this is not worth it
|
||||
return Promise.resolve(compressX64(text));
|
||||
}
|
||||
return this.internalQueueJob("compressX64", text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses with checksum
|
||||
* @param {any} obj
|
||||
*/
|
||||
compressWithChecksum(obj) {
|
||||
const stringified = JSON_stringify(obj);
|
||||
return this.internalQueueJob("compressWithChecksum", stringified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses with checksum
|
||||
* @param {any} data The packets data
|
||||
* @param {number} packetId The numeric packet id
|
||||
*/
|
||||
compressPacket(data, packetId) {
|
||||
return this.internalQueueJob("compressPacket", {
|
||||
data,
|
||||
packetId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a new job
|
||||
* @param {string} job
|
||||
* @param {any} data
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
internalQueueJob(job, data) {
|
||||
const jobId = ++this.currentJobId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const errorHandler = err => {
|
||||
logger.error("Failed to compress job", jobId, ":", err);
|
||||
reject(err);
|
||||
};
|
||||
this.currentJobs[jobId] = {
|
||||
errorHandler,
|
||||
resolver: resolve,
|
||||
startTime: performanceNow(),
|
||||
};
|
||||
this.worker.postMessage({ jobId, job, data });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const asyncCompressor = new AsynCompression();
|
||||
38
src/js/core/atlas_definitions.js
Normal file
38
src/js/core/atlas_definitions.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @typedef {{
|
||||
* frame: { x: number, y: number, w: number, h: number },
|
||||
* rotated: false,
|
||||
* spriteSourceSize: { x: number, y: number, w: number, h: number },
|
||||
* sourceSize: { w: number, h: number},
|
||||
* trimmed: true
|
||||
* }} SpriteDefinition
|
||||
*/
|
||||
|
||||
export class AtlasDefinition {
|
||||
constructor(sourceData) {
|
||||
this.sourceFileName = sourceData.meta.image;
|
||||
this.meta = sourceData.meta;
|
||||
|
||||
/** @type {Object.<string, SpriteDefinition>} */
|
||||
this.sourceData = sourceData.frames;
|
||||
}
|
||||
|
||||
getFullSourcePath() {
|
||||
return this.sourceFileName;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export const atlasFiles = require
|
||||
.context("../../../res_built/atlas/", false, /.*\.json/i)
|
||||
.keys()
|
||||
.map(f => f.replace(/^\.\//gi, ""))
|
||||
.map(f => require("../../../res_built/atlas/" + f))
|
||||
.map(data => new AtlasDefinition(data));
|
||||
|
||||
// export const atlasDefinitions = {
|
||||
// qualityPreload: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_preload") >= 0),
|
||||
// qualityLow: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_low") >= 0),
|
||||
// qualityMedium: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_medium") >= 0),
|
||||
// qualityHigh: atlasFiles.filter((atlas) => atlas.meta.image.indexOf("_high") >= 0),
|
||||
// };
|
||||
216
src/js/core/background_resources_loader.js
Normal file
216
src/js/core/background_resources_loader.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { Loader } from "./loader";
|
||||
import { createLogger } from "./logging";
|
||||
import { Signal } from "./signal";
|
||||
import { SOUNDS, MUSIC } from "../platform/sound";
|
||||
import { AtlasDefinition, atlasFiles } from "./atlas_definitions";
|
||||
|
||||
const logger = createLogger("background_loader");
|
||||
|
||||
const essentialMainMenuSprites = ["logo.png", ...G_ALL_UI_IMAGES.filter(src => src.startsWith("ui/"))];
|
||||
const essentialMainMenuSounds = [
|
||||
SOUNDS.uiClick,
|
||||
SOUNDS.uiError,
|
||||
SOUNDS.dialogError,
|
||||
SOUNDS.dialogOk,
|
||||
SOUNDS.swishShow,
|
||||
SOUNDS.swishHide,
|
||||
];
|
||||
|
||||
const essentialBareGameAtlases = atlasFiles;
|
||||
const essentialBareGameSprites = G_ALL_UI_IMAGES;
|
||||
const essentialBareGameSounds = [MUSIC.gameBg];
|
||||
|
||||
const additionalGameSprites = [];
|
||||
const additionalGameSounds = [];
|
||||
for (const key in SOUNDS) {
|
||||
additionalGameSounds.push(SOUNDS[key]);
|
||||
}
|
||||
for (const key in MUSIC) {
|
||||
additionalGameSounds.push(MUSIC[key]);
|
||||
}
|
||||
|
||||
export class BackgroundResourcesLoader {
|
||||
/**
|
||||
*
|
||||
* @param {Application} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
this.registerReady = false;
|
||||
this.mainMenuReady = false;
|
||||
this.bareGameReady = false;
|
||||
this.additionalReady = false;
|
||||
|
||||
this.signalMainMenuLoaded = new Signal();
|
||||
this.signalBareGameLoaded = new Signal();
|
||||
this.signalAdditionalLoaded = new Signal();
|
||||
|
||||
this.numAssetsLoaded = 0;
|
||||
this.numAssetsToLoadTotal = 0;
|
||||
|
||||
// Avoid loading stuff twice
|
||||
this.spritesLoaded = [];
|
||||
this.soundsLoaded = [];
|
||||
}
|
||||
|
||||
getNumAssetsLoaded() {
|
||||
return this.numAssetsLoaded;
|
||||
}
|
||||
|
||||
getNumAssetsTotal() {
|
||||
return this.numAssetsToLoadTotal;
|
||||
}
|
||||
|
||||
getPromiseForMainMenu() {
|
||||
if (this.mainMenuReady) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
this.signalMainMenuLoaded.add(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
getPromiseForBareGame() {
|
||||
if (this.bareGameReady) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
this.signalBareGameLoaded.add(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
startLoading() {
|
||||
this.internalStartLoadingEssentialsForMainMenu();
|
||||
}
|
||||
|
||||
internalStartLoadingEssentialsForMainMenu() {
|
||||
logger.log("⏰ Start load: main menu");
|
||||
this.internalLoadSpritesAndSounds(essentialMainMenuSprites, essentialMainMenuSounds)
|
||||
.catch(err => {
|
||||
logger.warn("⏰ Failed to load essentials for main menu:", err);
|
||||
})
|
||||
.then(() => {
|
||||
logger.log("⏰ Finish load: main menu");
|
||||
this.mainMenuReady = true;
|
||||
this.signalMainMenuLoaded.dispatch();
|
||||
this.internalStartLoadingEssentialsForBareGame();
|
||||
});
|
||||
}
|
||||
|
||||
internalStartLoadingEssentialsForBareGame() {
|
||||
logger.log("⏰ Start load: bare game");
|
||||
this.internalLoadSpritesAndSounds(
|
||||
essentialBareGameSprites,
|
||||
essentialBareGameSounds,
|
||||
essentialBareGameAtlases
|
||||
)
|
||||
.catch(err => {
|
||||
logger.warn("⏰ Failed to load essentials for bare game:", err);
|
||||
})
|
||||
.then(() => {
|
||||
logger.log("⏰ Finish load: bare game");
|
||||
Loader.createAtlasLinks();
|
||||
this.bareGameReady = true;
|
||||
this.signalBareGameLoaded.dispatch();
|
||||
this.internalStartLoadingAdditionalGameAssets();
|
||||
});
|
||||
}
|
||||
|
||||
internalStartLoadingAdditionalGameAssets() {
|
||||
const additionalAtlases = [];
|
||||
logger.log("⏰ Start load: additional assets (", additionalAtlases.length, "images)");
|
||||
this.internalLoadSpritesAndSounds(additionalGameSprites, additionalGameSounds, additionalAtlases)
|
||||
.catch(err => {
|
||||
logger.warn("⏰ Failed to load additional assets:", err);
|
||||
})
|
||||
.then(() => {
|
||||
logger.log("⏰ Finish load: additional assets");
|
||||
this.additionalReady = true;
|
||||
this.signalAdditionalLoaded.dispatch();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<string>} sprites
|
||||
* @param {Array<string>} sounds
|
||||
* @param {Array<AtlasDefinition>} atlases
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
internalLoadSpritesAndSounds(sprites, sounds, atlases = []) {
|
||||
this.numAssetsToLoadTotal = sprites.length + sounds.length + atlases.length;
|
||||
this.numAssetsLoaded = 0;
|
||||
|
||||
let promises = [];
|
||||
|
||||
for (let i = 0; i < sounds.length; ++i) {
|
||||
if (this.soundsLoaded.indexOf(sounds[i]) >= 0) {
|
||||
// Already loaded
|
||||
continue;
|
||||
}
|
||||
|
||||
this.soundsLoaded.push(sounds[i]);
|
||||
promises.push(
|
||||
this.app.sound
|
||||
.loadSound(sounds[i])
|
||||
.catch(err => {
|
||||
logger.warn("Failed to load sound:", sounds[i]);
|
||||
})
|
||||
.then(() => {
|
||||
this.numAssetsLoaded++;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < sprites.length; ++i) {
|
||||
if (this.spritesLoaded.indexOf(sprites[i]) >= 0) {
|
||||
// Already loaded
|
||||
continue;
|
||||
}
|
||||
this.spritesLoaded.push(sprites[i]);
|
||||
promises.push(
|
||||
Loader.preloadCSSSprite(sprites[i])
|
||||
.catch(err => {
|
||||
logger.warn("Failed to load css sprite:", sprites[i]);
|
||||
})
|
||||
.then(() => {
|
||||
this.numAssetsLoaded++;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < atlases.length; ++i) {
|
||||
const atlas = atlases[i];
|
||||
promises.push(
|
||||
Loader.preloadAtlas(atlas)
|
||||
.catch(err => {
|
||||
logger.warn("Failed to load atlas:", atlas.sourceFileName);
|
||||
})
|
||||
.then(() => {
|
||||
this.numAssetsLoaded++;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
Promise.all(promises)
|
||||
|
||||
// // Remove some pressure by waiting a bit
|
||||
// .then(() => {
|
||||
// return new Promise(resolve => {
|
||||
// setTimeout(resolve, 200);
|
||||
// });
|
||||
// })
|
||||
.then(() => {
|
||||
this.numAssetsToLoadTotal = 0;
|
||||
this.numAssetsLoaded = 0;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
146
src/js/core/buffer_maintainer.js
Normal file
146
src/js/core/buffer_maintainer.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import { GameRoot } from "../game/root";
|
||||
import {
|
||||
makeOffscreenBuffer,
|
||||
freeCanvas,
|
||||
getBufferVramUsageBytes,
|
||||
getBufferStats,
|
||||
clearBufferBacklog,
|
||||
} from "./buffer_utils";
|
||||
import { createLogger } from "./logging";
|
||||
import { round2Digits, round1Digit } from "./utils";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* canvas: HTMLCanvasElement,
|
||||
* context: CanvasRenderingContext2D,
|
||||
* lastUse: number,
|
||||
* }} CacheEntry
|
||||
*/
|
||||
|
||||
const logger = createLogger("buffers");
|
||||
|
||||
const bufferGcDurationSeconds = 3;
|
||||
|
||||
export class BufferMaintainer {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
/** @type {Map<string, Map<string, CacheEntry>>} */
|
||||
this.cache = new Map();
|
||||
|
||||
this.iterationIndex = 1;
|
||||
this.lastIteration = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes to the next buffer iteration, clearing all buffers which were not used
|
||||
* for a few iterations
|
||||
*/
|
||||
garbargeCollect() {
|
||||
let totalKeys = 0;
|
||||
let deletedKeys = 0;
|
||||
const minIteration = this.iterationIndex;
|
||||
|
||||
this.cache.forEach((subCache, key) => {
|
||||
let unusedSubKeys = [];
|
||||
|
||||
// Filter sub cache
|
||||
subCache.forEach((cacheEntry, subKey) => {
|
||||
if (cacheEntry.lastUse < minIteration) {
|
||||
unusedSubKeys.push(subKey);
|
||||
freeCanvas(cacheEntry.canvas);
|
||||
++deletedKeys;
|
||||
} else {
|
||||
++totalKeys;
|
||||
}
|
||||
});
|
||||
|
||||
// Delete unused sub keys
|
||||
for (let i = 0; i < unusedSubKeys.length; ++i) {
|
||||
subCache.delete(unusedSubKeys[i]);
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure our backlog never gets too big
|
||||
clearBufferBacklog();
|
||||
|
||||
const bufferStats = getBufferStats();
|
||||
const mbUsed = round1Digit(bufferStats.vramUsage / (1024 * 1024));
|
||||
logger.log(
|
||||
"GC: Remove",
|
||||
(deletedKeys + "").padStart(4),
|
||||
", Remain",
|
||||
(totalKeys + "").padStart(4),
|
||||
"(",
|
||||
(bufferStats.bufferCount + "").padStart(4),
|
||||
"total",
|
||||
")",
|
||||
|
||||
"(",
|
||||
(bufferStats.backlog + "").padStart(4),
|
||||
"backlog",
|
||||
")",
|
||||
|
||||
"VRAM:",
|
||||
mbUsed,
|
||||
"MB"
|
||||
);
|
||||
|
||||
++this.iterationIndex;
|
||||
}
|
||||
|
||||
update() {
|
||||
const now = this.root.time.realtimeNow();
|
||||
if (now - this.lastIteration > bufferGcDurationSeconds) {
|
||||
this.lastIteration = now;
|
||||
this.garbargeCollect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {string} subKey
|
||||
* @param {function(HTMLCanvasElement, CanvasRenderingContext2D, number, number, number, object?) : void} redrawMethod
|
||||
* @param {object=} additionalParams
|
||||
* @returns {HTMLCanvasElement}
|
||||
*
|
||||
*/
|
||||
getForKey(key, subKey, w, h, dpi, redrawMethod, additionalParams) {
|
||||
// First, create parent key
|
||||
let parent = this.cache.get(key);
|
||||
if (!parent) {
|
||||
parent = new Map();
|
||||
this.cache.set(key, parent);
|
||||
}
|
||||
|
||||
// Now search for sub key
|
||||
const cacheHit = parent.get(subKey);
|
||||
if (cacheHit) {
|
||||
cacheHit.lastUse = this.iterationIndex;
|
||||
return cacheHit.canvas;
|
||||
}
|
||||
|
||||
// Need to generate new buffer
|
||||
const effectiveWidth = w * dpi;
|
||||
const effectiveHeight = h * dpi;
|
||||
|
||||
const [canvas, context] = makeOffscreenBuffer(effectiveWidth, effectiveHeight, {
|
||||
reusable: true,
|
||||
label: "buffer-" + key + "/" + subKey,
|
||||
smooth: true,
|
||||
});
|
||||
|
||||
redrawMethod(canvas, context, w, h, dpi, additionalParams);
|
||||
|
||||
parent.set(subKey, {
|
||||
canvas,
|
||||
context,
|
||||
lastUse: this.iterationIndex,
|
||||
});
|
||||
return canvas;
|
||||
}
|
||||
}
|
||||
201
src/js/core/buffer_utils.js
Normal file
201
src/js/core/buffer_utils.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import { globalConfig } from "./config";
|
||||
import { Math_max, Math_floor, Math_abs } from "./builtins";
|
||||
import { fastArrayDelete } from "./utils";
|
||||
import { createLogger } from "./logging";
|
||||
|
||||
const logger = createLogger("buffer_utils");
|
||||
|
||||
/**
|
||||
* Enables images smoothing on a context
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
export function enableImageSmoothing(context) {
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.webkitImageSmoothingEnabled = true;
|
||||
|
||||
// @ts-ignore
|
||||
context.imageSmoothingQuality = globalConfig.smoothing.quality;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables image smoothing on a context
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
export function disableImageSmoothing(context) {
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.webkitImageSmoothingEnabled = false;
|
||||
}
|
||||
|
||||
const registeredCanvas = [];
|
||||
const freeCanvasList = [];
|
||||
|
||||
let vramUsage = 0;
|
||||
let bufferCount = 0;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
*/
|
||||
export function getBufferVramUsageBytes(canvas) {
|
||||
return canvas.width * canvas.height * 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stats on the allocated buffers
|
||||
*/
|
||||
export function getBufferStats() {
|
||||
return {
|
||||
vramUsage,
|
||||
bufferCount,
|
||||
backlog: freeCanvasList.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearBufferBacklog() {
|
||||
while (freeCanvasList.length > 50) {
|
||||
freeCanvasList.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new offscreen buffer
|
||||
* @param {Number} w
|
||||
* @param {Number} h
|
||||
* @returns {[HTMLCanvasElement, CanvasRenderingContext2D]}
|
||||
*/
|
||||
export function makeOffscreenBuffer(w, h, { smooth = true, reusable = true, label = "buffer" }) {
|
||||
assert(w > 0 && h > 0, "W or H < 0");
|
||||
if (w % 1 !== 0 || h % 1 !== 0) {
|
||||
// console.warn("Subpixel offscreen buffer size:", w, h);
|
||||
}
|
||||
if (w < 1 || h < 1) {
|
||||
logger.error("Offscreen buffer size < 0:", w, "x", h);
|
||||
w = Math_max(1, w);
|
||||
h = Math_max(1, h);
|
||||
}
|
||||
|
||||
const recommendedSize = 1024 * 1024;
|
||||
if (w * h > recommendedSize) {
|
||||
logger.warn("Creating huge buffer:", w, "x", h, "with label", label);
|
||||
}
|
||||
|
||||
w = Math_floor(w);
|
||||
h = Math_floor(h);
|
||||
|
||||
let canvas = null;
|
||||
let context = null;
|
||||
|
||||
let bestMatchingOne = null;
|
||||
let bestMatchingPixelsDiff = 1e50;
|
||||
|
||||
const currentPixels = w * h;
|
||||
|
||||
// Ok, search in cache first
|
||||
for (let i = 0; i < freeCanvasList.length; ++i) {
|
||||
const { canvas: useableCanvas, context: useableContext } = freeCanvasList[i];
|
||||
if (useableCanvas.width === w && useableCanvas.height === h) {
|
||||
// Ok we found one
|
||||
canvas = useableCanvas;
|
||||
context = useableContext;
|
||||
|
||||
fastArrayDelete(freeCanvasList, i);
|
||||
break;
|
||||
}
|
||||
|
||||
const otherPixels = useableCanvas.width * useableCanvas.height;
|
||||
const diff = Math_abs(otherPixels - currentPixels);
|
||||
if (diff < bestMatchingPixelsDiff) {
|
||||
bestMatchingPixelsDiff = diff;
|
||||
bestMatchingOne = {
|
||||
canvas: useableCanvas,
|
||||
context: useableContext,
|
||||
index: i,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Ok none matching, reuse one though
|
||||
if (!canvas && bestMatchingOne) {
|
||||
canvas = bestMatchingOne.canvas;
|
||||
context = bestMatchingOne.context;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
fastArrayDelete(freeCanvasList, bestMatchingOne.index);
|
||||
}
|
||||
|
||||
// Reset context
|
||||
if (context) {
|
||||
// Restore past state
|
||||
context.restore();
|
||||
context.save();
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
delete canvas.style.width;
|
||||
delete canvas.style.height;
|
||||
}
|
||||
|
||||
// None found , create new one
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
context = canvas.getContext("2d" /*, { alpha } */);
|
||||
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
|
||||
// Initial state
|
||||
context.save();
|
||||
}
|
||||
|
||||
canvas.label = label;
|
||||
|
||||
if (smooth) {
|
||||
enableImageSmoothing(context);
|
||||
} else {
|
||||
disableImageSmoothing(context);
|
||||
}
|
||||
|
||||
if (reusable) {
|
||||
registerCanvas(canvas, context);
|
||||
}
|
||||
|
||||
return [canvas, context];
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees a canvas
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
*/
|
||||
export function registerCanvas(canvas, context) {
|
||||
registeredCanvas.push({ canvas, context });
|
||||
|
||||
bufferCount += 1;
|
||||
vramUsage += getBufferVramUsageBytes(canvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees a canvas
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
*/
|
||||
export function freeCanvas(canvas) {
|
||||
assert(canvas, "Canvas is empty");
|
||||
|
||||
let index = -1;
|
||||
let data = null;
|
||||
for (let i = 0; i < registeredCanvas.length; ++i) {
|
||||
if (registeredCanvas[i].canvas === canvas) {
|
||||
index = i;
|
||||
data = registeredCanvas[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index < 0) {
|
||||
logger.error("Tried to free unregistered canvas of size", canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
fastArrayDelete(registeredCanvas, index);
|
||||
freeCanvasList.push(data);
|
||||
|
||||
bufferCount -= 1;
|
||||
vramUsage -= getBufferVramUsageBytes(canvas);
|
||||
}
|
||||
34
src/js/core/builtins.js
Normal file
34
src/js/core/builtins.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// Store the original version of all builtins to prevent modification
|
||||
|
||||
export const JSON_stringify = JSON.stringify.bind(JSON);
|
||||
export const JSON_parse = JSON.parse.bind(JSON);
|
||||
|
||||
export function Math_radians(degrees) {
|
||||
return (degrees * Math_PI) / 180.0;
|
||||
}
|
||||
|
||||
export function Math_degrees(radians) {
|
||||
return (radians * 180.0) / Math_PI;
|
||||
}
|
||||
|
||||
export const performanceNow = performance.now.bind(performance);
|
||||
|
||||
export const Math_abs = Math.abs.bind(Math);
|
||||
export const Math_ceil = Math.ceil.bind(Math);
|
||||
export const Math_floor = Math.floor.bind(Math);
|
||||
export const Math_round = Math.round.bind(Math);
|
||||
export const Math_sign = Math.sign.bind(Math);
|
||||
export const Math_sqrt = Math.sqrt.bind(Math);
|
||||
export const Math_min = Math.min.bind(Math);
|
||||
export const Math_max = Math.max.bind(Math);
|
||||
export const Math_sin = Math.sin.bind(Math);
|
||||
export const Math_cos = Math.cos.bind(Math);
|
||||
export const Math_tan = Math.tan.bind(Math);
|
||||
export const Math_hypot = Math.hypot.bind(Math);
|
||||
export const Math_atan2 = Math.atan2.bind(Math);
|
||||
export const Math_pow = Math.pow.bind(Math);
|
||||
export const Math_random = Math.random.bind(Math);
|
||||
export const Math_exp = Math.exp.bind(Math);
|
||||
export const Math_log10 = Math.log10.bind(Math);
|
||||
|
||||
export const Math_PI = 3.1415926;
|
||||
10
src/js/core/cachebust.js
Normal file
10
src/js/core/cachebust.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generates a cachebuster string. This only modifies the path in the browser version
|
||||
* @param {string} path
|
||||
*/
|
||||
export function cachebust(path) {
|
||||
if (G_IS_BROWSER && !G_IS_STANDALONE && !G_IS_DEV) {
|
||||
return "/v/" + G_BUILD_COMMIT_HASH + "/" + path;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
431
src/js/core/click_detector.js
Normal file
431
src/js/core/click_detector.js
Normal file
@@ -0,0 +1,431 @@
|
||||
import { performanceNow } from "../core/builtins";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { Signal } from "../core/signal";
|
||||
import { fastArrayDelete, fastArrayDeleteValueIfContained } from "./utils";
|
||||
import { Vector } from "./vector";
|
||||
import { IS_MOBILE } from "./config";
|
||||
|
||||
const logger = createLogger("click_detector");
|
||||
|
||||
export const MAX_MOVE_DISTANCE_PX = IS_MOBILE ? 20 : 40;
|
||||
|
||||
// For debugging
|
||||
const registerClickDetectors = G_IS_DEV && true;
|
||||
if (registerClickDetectors) {
|
||||
/** @type {Array<ClickDetector>} */
|
||||
window.activeClickDetectors = [];
|
||||
}
|
||||
|
||||
// Store active click detectors so we can cancel them
|
||||
/** @type {Array<ClickDetector>} */
|
||||
const ongoingClickDetectors = [];
|
||||
|
||||
// Store when the last touch event was registered, to avoid accepting a touch *and* a click event
|
||||
|
||||
export let clickDetectorGlobals = {
|
||||
lastTouchTime: -1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Click detector creation payload typehints
|
||||
* @typedef {{
|
||||
* consumeEvents?: boolean,
|
||||
* preventDefault?: boolean,
|
||||
* applyCssClass?: string,
|
||||
* captureTouchmove?: boolean,
|
||||
* targetOnly?: boolean,
|
||||
* maxDistance?: number,
|
||||
* clickSound?: string,
|
||||
* }} ClickDetectorConstructorArgs
|
||||
*/
|
||||
|
||||
// Detects clicks
|
||||
export class ClickDetector {
|
||||
/**
|
||||
*
|
||||
* @param {Element} element
|
||||
* @param {object} param1
|
||||
* @param {boolean=} param1.consumeEvents Whether to call stopPropagation
|
||||
* (Useful for nested elements where the parent has a click handler as wel)
|
||||
* @param {boolean=} param1.preventDefault Whether to call preventDefault (Usually makes the handler faster)
|
||||
* @param {string=} param1.applyCssClass The css class to add while the element is pressed
|
||||
* @param {boolean=} param1.captureTouchmove Whether to capture touchmove events as well
|
||||
* @param {boolean=} param1.targetOnly Whether to also accept clicks on child elements (e.target !== element)
|
||||
* @param {number=} param1.maxDistance The maximum distance in pixels to accept clicks
|
||||
* @param {string=} param1.clickSound Sound key to play on touchdown
|
||||
*/
|
||||
constructor(
|
||||
element,
|
||||
{
|
||||
consumeEvents = false,
|
||||
preventDefault = true,
|
||||
applyCssClass = "pressed",
|
||||
captureTouchmove = false,
|
||||
targetOnly = false,
|
||||
maxDistance = MAX_MOVE_DISTANCE_PX,
|
||||
clickSound = null,
|
||||
}
|
||||
) {
|
||||
assert(element, "No element given!");
|
||||
this.clickDownPosition = null;
|
||||
|
||||
this.consumeEvents = consumeEvents;
|
||||
this.preventDefault = preventDefault;
|
||||
this.applyCssClass = applyCssClass;
|
||||
this.captureTouchmove = captureTouchmove;
|
||||
this.targetOnly = targetOnly;
|
||||
this.clickSound = clickSound;
|
||||
this.maxDistance = maxDistance;
|
||||
|
||||
// Signals
|
||||
this.click = new Signal();
|
||||
this.rightClick = new Signal();
|
||||
this.touchstart = new Signal();
|
||||
this.touchmove = new Signal();
|
||||
this.touchend = new Signal();
|
||||
this.touchcancel = new Signal();
|
||||
|
||||
// Simple signals which just receive the touch position
|
||||
this.touchstartSimple = new Signal();
|
||||
this.touchmoveSimple = new Signal();
|
||||
this.touchendSimple = new Signal();
|
||||
|
||||
// Store time of touch start
|
||||
this.clickStartTime = null;
|
||||
|
||||
// A click can be cancelled if another detector registers a click
|
||||
this.cancelled = false;
|
||||
|
||||
this.internalBindTo(/** @type {HTMLElement} */ (element));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all event listeners of this detector
|
||||
*/
|
||||
cleanup() {
|
||||
if (this.element) {
|
||||
if (registerClickDetectors) {
|
||||
const index = window.activeClickDetectors.indexOf(this);
|
||||
if (index < 0) {
|
||||
logger.error("Click detector cleanup but is not active");
|
||||
} else {
|
||||
window.activeClickDetectors.splice(index, 1);
|
||||
}
|
||||
}
|
||||
const options = this.internalGetEventListenerOptions();
|
||||
this.element.removeEventListener("touchstart", this.handlerTouchStart, options);
|
||||
this.element.removeEventListener("touchend", this.handlerTouchEnd, options);
|
||||
this.element.removeEventListener("touchcancel", this.handlerTouchCancel, options);
|
||||
|
||||
this.element.removeEventListener("mouseup", this.handlerTouchStart, options);
|
||||
this.element.removeEventListener("mousedown", this.handlerTouchEnd, options);
|
||||
this.element.removeEventListener("mouseout", this.handlerTouchCancel, options);
|
||||
|
||||
if (this.captureTouchmove) {
|
||||
this.element.removeEventListener("touchmove", this.handlerTouchMove, options);
|
||||
this.element.removeEventListener("mousemove", this.handlerTouchMove, options);
|
||||
}
|
||||
|
||||
this.click.removeAll();
|
||||
this.touchstart.removeAll();
|
||||
this.touchmove.removeAll();
|
||||
this.touchend.removeAll();
|
||||
this.touchcancel.removeAll();
|
||||
|
||||
// TODO: Remove pointer captures
|
||||
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
|
||||
// INTERNAL METHODS
|
||||
|
||||
/**
|
||||
* Internal method to get the options to pass to an event listener
|
||||
*/
|
||||
internalGetEventListenerOptions() {
|
||||
return {
|
||||
capture: this.consumeEvents,
|
||||
passive: !this.preventDefault,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the click detector to an element
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
internalBindTo(element) {
|
||||
const options = this.internalGetEventListenerOptions();
|
||||
|
||||
this.handlerTouchStart = this.internalOnPointerDown.bind(this);
|
||||
this.handlerTouchEnd = this.internalOnPointerEnd.bind(this);
|
||||
this.handlerTouchMove = this.internalOnPointerMove.bind(this);
|
||||
this.handlerTouchCancel = this.internalOnTouchCancel.bind(this);
|
||||
|
||||
element.addEventListener("touchstart", this.handlerTouchStart, options);
|
||||
element.addEventListener("touchend", this.handlerTouchEnd, options);
|
||||
element.addEventListener("touchcancel", this.handlerTouchCancel, options);
|
||||
|
||||
element.addEventListener("mousedown", this.handlerTouchStart, options);
|
||||
element.addEventListener("mouseup", this.handlerTouchEnd, options);
|
||||
element.addEventListener("mouseout", this.handlerTouchCancel, options);
|
||||
|
||||
if (this.captureTouchmove) {
|
||||
element.addEventListener("touchmove", this.handlerTouchMove, options);
|
||||
element.addEventListener("mousemove", this.handlerTouchMove, options);
|
||||
}
|
||||
|
||||
if (registerClickDetectors) {
|
||||
window.activeClickDetectors.push(this);
|
||||
}
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the bound element is currently in the DOM.
|
||||
*/
|
||||
internalIsDomElementAttached() {
|
||||
return this.element && document.documentElement.contains(this.element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given event is relevant for this detector
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
*/
|
||||
internalEventPreHandler(event, expectedRemainingTouches = 1) {
|
||||
if (!this.element) {
|
||||
// Already cleaned up
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.targetOnly && event.target !== this.element) {
|
||||
// Clicked a child element
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop any propagation and defaults if configured
|
||||
if (this.consumeEvents && event.cancelable) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (this.preventDefault && event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (window.TouchEvent && event instanceof TouchEvent) {
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
|
||||
// console.log("Got touches", event.targetTouches.length, "vs", expectedRemainingTouches);
|
||||
if (event.targetTouches.length !== expectedRemainingTouches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (event instanceof MouseEvent) {
|
||||
if (performanceNow() - clickDetectorGlobals.lastTouchTime < 1000.0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the mous position from an event
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
* @returns {Vector} The client space position
|
||||
*/
|
||||
static extractPointerPosition(event) {
|
||||
if (window.TouchEvent && event instanceof TouchEvent) {
|
||||
if (event.changedTouches.length !== 1) {
|
||||
logger.warn(
|
||||
"Got unexpected target touches:",
|
||||
event.targetTouches.length,
|
||||
"->",
|
||||
event.targetTouches
|
||||
);
|
||||
return new Vector(0, 0);
|
||||
}
|
||||
|
||||
const touch = event.changedTouches[0];
|
||||
return new Vector(touch.clientX, touch.clientY);
|
||||
}
|
||||
|
||||
if (event instanceof MouseEvent) {
|
||||
return new Vector(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
assertAlways(false, "Got unknown event: " + event);
|
||||
|
||||
return new Vector(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cacnels all ongoing events on this detector
|
||||
*/
|
||||
cancelOngoingEvents() {
|
||||
if (this.applyCssClass && this.element) {
|
||||
this.element.classList.remove(this.applyCssClass);
|
||||
}
|
||||
this.clickDownPosition = null;
|
||||
this.clickStartTime = null;
|
||||
this.cancelled = true;
|
||||
fastArrayDeleteValueIfContained(ongoingClickDetectors, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pointer down handler
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
*/
|
||||
internalOnPointerDown(event) {
|
||||
if (!this.internalEventPreHandler(event, 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const position = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event);
|
||||
|
||||
if (event instanceof MouseEvent) {
|
||||
const isRightClick = event.which == 3;
|
||||
if (isRightClick) {
|
||||
// Ignore right clicks
|
||||
this.rightClick.dispatch(position, event);
|
||||
this.cancelled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.clickDownPosition) {
|
||||
logger.warn("Ignoring double click");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.cancelled = false;
|
||||
this.touchstart.dispatch(event);
|
||||
|
||||
// Store where the touch started
|
||||
this.clickDownPosition = position;
|
||||
this.clickStartTime = performanceNow();
|
||||
this.touchstartSimple.dispatch(this.clickDownPosition.x, this.clickDownPosition.y);
|
||||
|
||||
// If we are not currently within a click, register it
|
||||
if (ongoingClickDetectors.indexOf(this) < 0) {
|
||||
ongoingClickDetectors.push(this);
|
||||
} else {
|
||||
logger.warn("Click detector got pointer down of active pointer twice");
|
||||
}
|
||||
|
||||
// If we should apply any classes, do this now
|
||||
if (this.applyCssClass) {
|
||||
this.element.classList.add(this.applyCssClass);
|
||||
}
|
||||
|
||||
// If we should play any sound, do this
|
||||
if (this.clickSound) {
|
||||
throw new Error("TODO: Play sounds on click");
|
||||
// GLOBAL_APP.sound.playUiSound(this.clickSound);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pointer move handler
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
*/
|
||||
internalOnPointerMove(event) {
|
||||
if (!this.internalEventPreHandler(event, 1)) {
|
||||
return false;
|
||||
}
|
||||
this.touchmove.dispatch(event);
|
||||
const pos = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event);
|
||||
this.touchmoveSimple.dispatch(pos.x, pos.y);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pointer end handler
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
*/
|
||||
internalOnPointerEnd(event) {
|
||||
if (!this.internalEventPreHandler(event, 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.cancelled) {
|
||||
// warn(this, "Not dispatching touchend on cancelled listener");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event instanceof MouseEvent) {
|
||||
const isRightClick = event.which == 3;
|
||||
if (isRightClick) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const index = ongoingClickDetectors.indexOf(this);
|
||||
if (index < 0) {
|
||||
logger.warn("Got pointer end but click detector is not in pressed state");
|
||||
} else {
|
||||
fastArrayDelete(ongoingClickDetectors, index);
|
||||
}
|
||||
|
||||
let dispatchClick = false;
|
||||
let dispatchClickPos = null;
|
||||
|
||||
// Check for correct down position, otherwise must have pinched or so
|
||||
if (this.clickDownPosition) {
|
||||
const pos = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event);
|
||||
const distance = pos.distance(this.clickDownPosition);
|
||||
if (distance <= this.maxDistance) {
|
||||
dispatchClick = true;
|
||||
dispatchClickPos = pos;
|
||||
} else {
|
||||
// console.warn("[ClickDetector] Touch does not count as click: ms=", timeSinceStart, "-> tolerance:", tolerance, "(was", distance, ")");
|
||||
}
|
||||
}
|
||||
|
||||
this.clickDownPosition = null;
|
||||
this.clickStartTime = null;
|
||||
|
||||
if (this.applyCssClass) {
|
||||
this.element.classList.remove(this.applyCssClass);
|
||||
}
|
||||
|
||||
// Dispatch in the end to avoid the element getting invalidated
|
||||
// Also make sure that the element is still in the dom
|
||||
if (this.internalIsDomElementAttached()) {
|
||||
this.touchend.dispatch(event);
|
||||
this.touchendSimple.dispatch();
|
||||
|
||||
if (dispatchClick) {
|
||||
const detectors = ongoingClickDetectors.slice();
|
||||
for (let i = 0; i < detectors.length; ++i) {
|
||||
detectors[i].cancelOngoingEvents();
|
||||
}
|
||||
this.click.dispatch(dispatchClickPos, event);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch cancel handler
|
||||
* @param {TouchEvent|MouseEvent} event
|
||||
*/
|
||||
internalOnTouchCancel(event) {
|
||||
if (!this.internalEventPreHandler(event, 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.cancelled) {
|
||||
// warn(this, "Not dispatching touchcancel on cancelled listener");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.cancelOngoingEvents();
|
||||
this.touchcancel.dispatch(event);
|
||||
this.touchendSimple.dispatch(event);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
104
src/js/core/config.js
Normal file
104
src/js/core/config.js
Normal file
@@ -0,0 +1,104 @@
|
||||
export const IS_DEBUG =
|
||||
G_IS_DEV &&
|
||||
typeof window !== "undefined" &&
|
||||
window.location.port === "3005" &&
|
||||
(window.location.host.indexOf("localhost:") >= 0 || window.location.host.indexOf("192.168.0.") >= 0) &&
|
||||
window.location.search.indexOf("nodebug") < 0;
|
||||
|
||||
const smoothCanvas = true;
|
||||
|
||||
export const globalConfig = {
|
||||
// Size of a single tile in Pixels.
|
||||
// NOTICE: Update webpack.production.config too!
|
||||
tileSize: 32,
|
||||
halfTileSize: 16,
|
||||
|
||||
// Which dpi the assets have
|
||||
assetsDpi: 192 / 32,
|
||||
assetsSharpness: 1.2,
|
||||
shapesSharpness: 1.4,
|
||||
|
||||
// [Calculated] physics step size
|
||||
physicsDeltaMs: 0,
|
||||
physicsDeltaSeconds: 0,
|
||||
|
||||
// Update physics at N fps, independent of rendering
|
||||
physicsUpdateRate: 60,
|
||||
|
||||
// Map
|
||||
mapChunkSize: 32,
|
||||
mapChunkPrerenderMinZoom: 0.7,
|
||||
mapChunkOverviewMinZoom: 0.7,
|
||||
|
||||
// Belt speeds
|
||||
// NOTICE: Update webpack.production.config too!
|
||||
beltSpeedItemsPerSecond: 1,
|
||||
itemSpacingOnBelts: 0.63,
|
||||
minerSpeedItemsPerSecond: 0, // COMPUTED
|
||||
|
||||
undergroundBeltMaxTiles: 5,
|
||||
|
||||
buildingSpeeds: {
|
||||
cutter: 1 / 6,
|
||||
rotater: 1 / 2,
|
||||
painter: 1 / 3,
|
||||
mixer: 1 / 2,
|
||||
stacker: 1 / 5,
|
||||
},
|
||||
|
||||
// Zooming
|
||||
initialZoom: 1.9,
|
||||
minZoomLevel: 0.1,
|
||||
maxZoomLevel: 3,
|
||||
|
||||
// Global game speed
|
||||
gameSpeed: 1,
|
||||
|
||||
warmupTimeSecondsFast: 0.1,
|
||||
warmupTimeSecondsRegular: 1,
|
||||
|
||||
smoothing: {
|
||||
smoothMainCanvas: smoothCanvas && true,
|
||||
quality: "low", // Low is CRUCIAL for mobile performance!
|
||||
},
|
||||
|
||||
rendering: {},
|
||||
|
||||
debug: {
|
||||
/* dev:start */
|
||||
fastGameEnter: true,
|
||||
noArtificialDelays: true,
|
||||
disableSavegameWrite: false,
|
||||
showEntityBounds: false,
|
||||
showAcceptorEjectors: false,
|
||||
usePlainShapeIds: true,
|
||||
disableMusic: true,
|
||||
doNotRenderStatics: false,
|
||||
disableZoomLimits: false,
|
||||
showChunkBorders: false,
|
||||
rewardsInstant: false,
|
||||
allBuildingsUnlocked: true,
|
||||
upgradesNoCost: true,
|
||||
disableUnlockDialog: true,
|
||||
/* dev:end */
|
||||
},
|
||||
|
||||
// Secret vars
|
||||
info: {
|
||||
// Binary file salt
|
||||
file: "Ec'])@^+*9zMevK3uMV4432x9%iK'=",
|
||||
|
||||
// Savegame salt
|
||||
sgSalt: "}95Q3%8/.837Lqym_BJx%q7)pAHJbF",
|
||||
},
|
||||
};
|
||||
|
||||
export const IS_MOBILE = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
|
||||
// Automatic calculations
|
||||
|
||||
globalConfig.physicsDeltaMs = 1000.0 / globalConfig.physicsUpdateRate;
|
||||
globalConfig.physicsDeltaSeconds = 1.0 / globalConfig.physicsUpdateRate;
|
||||
|
||||
globalConfig.minerSpeedItemsPerSecond =
|
||||
globalConfig.beltSpeedItemsPerSecond / globalConfig.itemSpacingOnBelts / 6;
|
||||
117
src/js/core/dpi_manager.js
Normal file
117
src/js/core/dpi_manager.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Math_ceil, Math_floor, Math_round } from "./builtins";
|
||||
import { round1Digit, round2Digits } from "./utils";
|
||||
|
||||
/**
|
||||
* Returns the current dpi
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getDeviceDPI() {
|
||||
return window.devicePixelRatio || 1;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} dpi
|
||||
* @returns {number} Smoothed dpi
|
||||
*/
|
||||
export function smoothenDpi(dpi) {
|
||||
if (dpi < 0.05) {
|
||||
return 0.05;
|
||||
} else if (dpi < 0.1) {
|
||||
return round2Digits(dpi);
|
||||
} else if (dpi < 1) {
|
||||
return round1Digit(dpi);
|
||||
} else {
|
||||
return round1Digit(Math_round(dpi / 0.5) * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial dpi
|
||||
// setDPIMultiplicator(1);
|
||||
|
||||
/**
|
||||
* Prepares a context for hihg dpi rendering
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
export function prepareHighDPIContext(context, smooth = true) {
|
||||
const dpi = getDeviceDPI();
|
||||
context.scale(dpi, dpi);
|
||||
|
||||
if (smooth) {
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.webkitImageSmoothingEnabled = true;
|
||||
|
||||
// @ts-ignore
|
||||
context.imageSmoothingQuality = globalConfig.smoothing.quality;
|
||||
} else {
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.webkitImageSmoothingEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes a high dpi canvas
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
export function resizeHighDPICanvas(canvas, w, h, smooth = true) {
|
||||
const dpi = getDeviceDPI();
|
||||
|
||||
const wNumber = Math_floor(w);
|
||||
const hNumber = Math_floor(h);
|
||||
|
||||
const targetW = Math_floor(wNumber * dpi);
|
||||
const targetH = Math_floor(hNumber * dpi);
|
||||
|
||||
if (targetW !== canvas.width || targetH !== canvas.height) {
|
||||
// console.log("Resize Canvas from", canvas.width, canvas.height, "to", targetW, targetH)
|
||||
canvas.width = targetW;
|
||||
canvas.height = targetH;
|
||||
canvas.style.width = wNumber + "px";
|
||||
canvas.style.height = hNumber + "px";
|
||||
prepareHighDPIContext(canvas.getContext("2d"), smooth);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes a canvas
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
export function resizeCanvas(canvas, w, h, setStyle = true) {
|
||||
const actualW = Math_ceil(w);
|
||||
const actualH = Math_ceil(h);
|
||||
if (actualW !== canvas.width || actualH !== canvas.height) {
|
||||
canvas.width = actualW;
|
||||
canvas.height = actualH;
|
||||
if (setStyle) {
|
||||
canvas.style.width = actualW + "px";
|
||||
canvas.style.height = actualH + "px";
|
||||
}
|
||||
// console.log("Resizing canvas to", actualW, "x", actualH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes a canvas and makes sure its cleared
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
export function resizeCanvasAndClear(canvas, context, w, h) {
|
||||
const actualW = Math_ceil(w);
|
||||
const actualH = Math_ceil(h);
|
||||
if (actualW !== canvas.width || actualH !== canvas.height) {
|
||||
canvas.width = actualW;
|
||||
canvas.height = actualH;
|
||||
canvas.style.width = actualW + "px";
|
||||
canvas.style.height = actualH + "px";
|
||||
// console.log("Resizing canvas to", actualW, "x", actualH);
|
||||
} else {
|
||||
context.clearRect(0, 0, actualW, actualH);
|
||||
}
|
||||
}
|
||||
25
src/js/core/draw_parameters.js
Normal file
25
src/js/core/draw_parameters.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Rectangle } from "./rectangle";
|
||||
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "../game/root";
|
||||
/* typehints:end */
|
||||
|
||||
export class DrawParameters {
|
||||
constructor({ context, visibleRect, desiredAtlasScale, zoomLevel, root }) {
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
this.context = context;
|
||||
|
||||
/** @type {Rectangle} */
|
||||
this.visibleRect = visibleRect;
|
||||
|
||||
/** @type {number} */
|
||||
this.desiredAtlasScale = desiredAtlasScale;
|
||||
|
||||
/** @type {number} */
|
||||
this.zoomLevel = zoomLevel;
|
||||
|
||||
// FIXME: Not really nice
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
}
|
||||
}
|
||||
321
src/js/core/draw_utils.js
Normal file
321
src/js/core/draw_utils.js
Normal file
@@ -0,0 +1,321 @@
|
||||
/* typehints:start */
|
||||
import { AtlasSprite } from "./sprites";
|
||||
import { DrawParameters } from "./draw_parameters";
|
||||
/* typehints:end */
|
||||
|
||||
import { Math_PI, Math_round, Math_atan2, Math_hypot, Math_floor } from "./builtins";
|
||||
import { Vector } from "./vector";
|
||||
import { Rectangle } from "./rectangle";
|
||||
import { createLogger } from "./logging";
|
||||
|
||||
const logger = createLogger("draw_utils");
|
||||
|
||||
export function initDrawUtils() {
|
||||
CanvasRenderingContext2D.prototype.beginRoundedRect = function (x, y, w, h, r) {
|
||||
if (r < 0.05) {
|
||||
this.beginPath();
|
||||
this.rect(x, y, w, h);
|
||||
return;
|
||||
}
|
||||
|
||||
if (w < 2 * r) {
|
||||
r = w / 2;
|
||||
}
|
||||
if (h < 2 * r) {
|
||||
r = h / 2;
|
||||
}
|
||||
this.beginPath();
|
||||
this.moveTo(x + r, y);
|
||||
this.arcTo(x + w, y, x + w, y + h, r);
|
||||
this.arcTo(x + w, y + h, x, y + h, r);
|
||||
this.arcTo(x, y + h, x, y, r);
|
||||
this.arcTo(x, y, x + w, y, r);
|
||||
// this.closePath();
|
||||
};
|
||||
|
||||
CanvasRenderingContext2D.prototype.beginCircle = function (x, y, r) {
|
||||
if (r < 0.05) {
|
||||
this.beginPath();
|
||||
this.rect(x, y, 1, 1);
|
||||
return;
|
||||
}
|
||||
this.beginPath();
|
||||
this.arc(x, y, r, 0, 2.0 * Math_PI);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {DrawParameters} param0.parameters
|
||||
* @param {AtlasSprite} param0.sprite
|
||||
* @param {number} param0.x
|
||||
* @param {number} param0.y
|
||||
* @param {number} param0.angle
|
||||
* @param {number} param0.size
|
||||
* @param {number=} param0.offsetX
|
||||
* @param {number=} param0.offsetY
|
||||
*/
|
||||
export function drawRotatedSprite({ parameters, sprite, x, y, angle, size, offsetX = 0, offsetY = 0 }) {
|
||||
parameters.context.translate(x, y);
|
||||
parameters.context.rotate(angle);
|
||||
sprite.drawCachedCentered(parameters, offsetX, offsetY, size, false);
|
||||
parameters.context.rotate(-angle);
|
||||
parameters.context.translate(-x, -y);
|
||||
}
|
||||
|
||||
export function drawLineFast(context, { x1, x2, y1, y2, color = null, lineSize = 1 }) {
|
||||
const dX = x2 - x1;
|
||||
const dY = y2 - y1;
|
||||
|
||||
const angle = Math_atan2(dY, dX) + 0.0 * Math_PI;
|
||||
const len = Math_hypot(dX, dY);
|
||||
|
||||
context.translate(x1, y1);
|
||||
context.rotate(angle);
|
||||
|
||||
if (color) {
|
||||
context.fillStyle = color;
|
||||
}
|
||||
|
||||
context.fillRect(0, -lineSize / 2, len, lineSize);
|
||||
|
||||
context.rotate(-angle);
|
||||
context.translate(-x1, -y1);
|
||||
}
|
||||
|
||||
const INSIDE = 0;
|
||||
const LEFT = 1;
|
||||
const RIGHT = 2;
|
||||
const BOTTOM = 4;
|
||||
const TOP = 8;
|
||||
|
||||
// https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm
|
||||
|
||||
function computeOutCode(x, y, xmin, xmax, ymin, ymax) {
|
||||
let code = INSIDE;
|
||||
|
||||
if (x < xmin)
|
||||
// to the left of clip window
|
||||
code |= LEFT;
|
||||
else if (x > xmax)
|
||||
// to the right of clip window
|
||||
code |= RIGHT;
|
||||
if (y < ymin)
|
||||
// below the clip window
|
||||
code |= BOTTOM;
|
||||
else if (y > ymax)
|
||||
// above the clip window
|
||||
code |= TOP;
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
// Cohen–Sutherland clipping algorithm clips a line from
|
||||
// P0 = (x0, y0) to P1 = (x1, y1) against a rectangle with
|
||||
// diagonal from (xmin, ymin) to (xmax, ymax).
|
||||
/**
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
export function drawLineFastClipped(context, rect, { x0, y0, x1, y1, color = null, lineSize = 1 }) {
|
||||
const xmin = rect.x;
|
||||
const ymin = rect.y;
|
||||
const xmax = rect.right();
|
||||
const ymax = rect.bottom();
|
||||
|
||||
// compute outcodes for P0, P1, and whatever point lies outside the clip rectangle
|
||||
let outcode0 = computeOutCode(x0, y0, xmin, xmax, ymin, ymax);
|
||||
let outcode1 = computeOutCode(x1, y1, xmin, xmax, ymin, ymax);
|
||||
let accept = false;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (!(outcode0 | outcode1)) {
|
||||
// bitwise OR is 0: both points inside window; trivially accept and exit loop
|
||||
accept = true;
|
||||
break;
|
||||
} else if (outcode0 & outcode1) {
|
||||
// bitwise AND is not 0: both points share an outside zone (LEFT, RIGHT, TOP,
|
||||
// or BOTTOM), so both must be outside window; exit loop (accept is false)
|
||||
break;
|
||||
} else {
|
||||
// failed both tests, so calculate the line segment to clip
|
||||
// from an outside point to an intersection with clip edge
|
||||
let x, y;
|
||||
|
||||
// At least one endpoint is outside the clip rectangle; pick it.
|
||||
let outcodeOut = outcode0 ? outcode0 : outcode1;
|
||||
|
||||
// Now find the intersection point;
|
||||
// use formulas:
|
||||
// slope = (y1 - y0) / (x1 - x0)
|
||||
// x = x0 + (1 / slope) * (ym - y0), where ym is ymin or ymax
|
||||
// y = y0 + slope * (xm - x0), where xm is xmin or xmax
|
||||
// No need to worry about divide-by-zero because, in each case, the
|
||||
// outcode bit being tested guarantees the denominator is non-zero
|
||||
if (outcodeOut & TOP) {
|
||||
// point is above the clip window
|
||||
x = x0 + ((x1 - x0) * (ymax - y0)) / (y1 - y0);
|
||||
y = ymax;
|
||||
} else if (outcodeOut & BOTTOM) {
|
||||
// point is below the clip window
|
||||
x = x0 + ((x1 - x0) * (ymin - y0)) / (y1 - y0);
|
||||
y = ymin;
|
||||
} else if (outcodeOut & RIGHT) {
|
||||
// point is to the right of clip window
|
||||
y = y0 + ((y1 - y0) * (xmax - x0)) / (x1 - x0);
|
||||
x = xmax;
|
||||
} else if (outcodeOut & LEFT) {
|
||||
// point is to the left of clip window
|
||||
y = y0 + ((y1 - y0) * (xmin - x0)) / (x1 - x0);
|
||||
x = xmin;
|
||||
}
|
||||
|
||||
// Now we move outside point to intersection point to clip
|
||||
// and get ready for next pass.
|
||||
if (outcodeOut == outcode0) {
|
||||
x0 = x;
|
||||
y0 = y;
|
||||
outcode0 = computeOutCode(x0, y0, xmin, xmax, ymin, ymax);
|
||||
} else {
|
||||
x1 = x;
|
||||
y1 = y;
|
||||
outcode1 = computeOutCode(x1, y1, xmin, xmax, ymin, ymax);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (accept) {
|
||||
// Following functions are left for implementation by user based on
|
||||
// their platform (OpenGL/graphics.h etc.)
|
||||
// DrawRectangle(xmin, ymin, xmax, ymax);
|
||||
// LineSegment(x0, y0, x1, y1);
|
||||
drawLineFast(context, {
|
||||
x1: x0,
|
||||
y1: y0,
|
||||
x2: x1,
|
||||
y2: y1,
|
||||
color,
|
||||
lineSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an HSL color value to RGB. Conversion formula
|
||||
* adapted from http://en.wikipedia.org/wiki/HSL_color_space.
|
||||
* Assumes h, s, and l are contained in the set [0, 1] and
|
||||
* returns r, g, and b in the set [0, 255].
|
||||
*
|
||||
* @param {number} h The hue
|
||||
* @param {number} s The saturation
|
||||
* @param {number} l The lightness
|
||||
* @return {Array} The RGB representation
|
||||
*/
|
||||
export function hslToRgb(h, s, l) {
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l; // achromatic
|
||||
} else {
|
||||
// tslint:disable-next-line:no-shadowed-variable
|
||||
const hue2rgb = function (p, q, t) {
|
||||
if (t < 0) {
|
||||
t += 1;
|
||||
}
|
||||
if (t > 1) {
|
||||
t -= 1;
|
||||
}
|
||||
if (t < 1 / 6) {
|
||||
return p + (q - p) * 6 * t;
|
||||
}
|
||||
if (t < 1 / 2) {
|
||||
return q;
|
||||
}
|
||||
if (t < 2 / 3) {
|
||||
return p + (q - p) * (2 / 3 - t) * 6;
|
||||
}
|
||||
return p;
|
||||
};
|
||||
|
||||
let q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
let p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
return [Math_round(r * 255), Math_round(g * 255), Math_round(b * 255)];
|
||||
}
|
||||
|
||||
export function wrapText(context, text, x, y, maxWidth, lineHeight, stroke = false) {
|
||||
var words = text.split(" ");
|
||||
var line = "";
|
||||
|
||||
for (var n = 0; n < words.length; n++) {
|
||||
var testLine = line + words[n] + " ";
|
||||
var metrics = context.measureText(testLine);
|
||||
var testWidth = metrics.width;
|
||||
if (testWidth > maxWidth && n > 0) {
|
||||
if (stroke) {
|
||||
context.strokeText(line, x, y);
|
||||
} else {
|
||||
context.fillText(line, x, y);
|
||||
}
|
||||
line = words[n] + " ";
|
||||
y += lineHeight;
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
|
||||
if (stroke) {
|
||||
context.strokeText(line, x, y);
|
||||
} else {
|
||||
context.fillText(line, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rotated trapez, used for spotlight culling
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {number} leftHeight
|
||||
* @param {number} angle
|
||||
*/
|
||||
export function rotateTrapezRightFaced(x, y, w, h, leftHeight, angle) {
|
||||
const halfY = y + h / 2;
|
||||
const points = [
|
||||
new Vector(x, halfY - leftHeight / 2),
|
||||
new Vector(x + w, y),
|
||||
new Vector(x, halfY + leftHeight / 2),
|
||||
new Vector(x + w, y + h),
|
||||
];
|
||||
|
||||
return Rectangle.getAroundPointsRotated(points, angle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts values from 0 .. 255 to values like 07, 7f, 5d etc
|
||||
* @param {number} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mapClampedColorValueToHex(value) {
|
||||
const hex = "0123456789abcdef";
|
||||
return hex[Math_floor(value / 16)] + hex[value % 16];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts rgb to a hex string
|
||||
* @param {number} r
|
||||
* @param {number} g
|
||||
* @param {number} b
|
||||
* @returns {string}
|
||||
*/
|
||||
export function rgbToHex(r, g, b) {
|
||||
return mapClampedColorValueToHex(r) + mapClampedColorValueToHex(g) + mapClampedColorValueToHex(b);
|
||||
}
|
||||
126
src/js/core/error_handler.js
Normal file
126
src/js/core/error_handler.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { logSection } from "./logging";
|
||||
import { stringifyObjectContainingErrors } from "./logging";
|
||||
import { removeAllChildren } from "./utils";
|
||||
|
||||
export let APPLICATION_ERROR_OCCURED = false;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Event|string} message
|
||||
* @param {string} source
|
||||
* @param {number} lineno
|
||||
* @param {number} colno
|
||||
* @param {Error} source
|
||||
*/
|
||||
function catchErrors(message, source, lineno, colno, error) {
|
||||
let fullPayload = JSON.parse(
|
||||
stringifyObjectContainingErrors({
|
||||
message,
|
||||
source,
|
||||
lineno,
|
||||
colno,
|
||||
error,
|
||||
})
|
||||
);
|
||||
|
||||
if (("" + message).indexOf("Script error.") >= 0) {
|
||||
console.warn("Thirdparty script error:", message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (("" + message).indexOf("NS_ERROR_FAILURE") >= 0) {
|
||||
console.warn("Firefox NS_ERROR_FAILURE error:", message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (("" + message).indexOf("Cannot read property 'postMessage' of null") >= 0) {
|
||||
console.warn("Safari can not read post message error:", message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!G_IS_DEV && G_IS_BROWSER && ("" + source).indexOf("shapez.io") < 0) {
|
||||
console.warn("Thirdparty error:", message);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n\n\n⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️\n\n\n");
|
||||
console.log(" APPLICATION CRASHED ");
|
||||
console.log("\n\n⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️\n\n\n");
|
||||
|
||||
logSection("APPLICATION CRASH", "#e53935");
|
||||
console.log("Error:", message, "->", error);
|
||||
console.log("Payload:", fullPayload);
|
||||
|
||||
if (window.Sentry && !window.anyModLoaded) {
|
||||
window.Sentry.withScope(scope => {
|
||||
window.Sentry.setTag("message", message);
|
||||
window.Sentry.setTag("source", source);
|
||||
|
||||
window.Sentry.setExtra("message", message);
|
||||
window.Sentry.setExtra("source", source);
|
||||
window.Sentry.setExtra("lineno", lineno);
|
||||
window.Sentry.setExtra("colno", colno);
|
||||
window.Sentry.setExtra("error", error);
|
||||
window.Sentry.setExtra("fullPayload", fullPayload);
|
||||
|
||||
try {
|
||||
const userName = window.localStorage.getItem("tracking_context") || null;
|
||||
window.Sentry.setTag("username", userName);
|
||||
} catch (ex) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
window.Sentry.captureException(error || source);
|
||||
});
|
||||
}
|
||||
|
||||
if (APPLICATION_ERROR_OCCURED) {
|
||||
console.warn("ERROR: Only showing and submitting first error");
|
||||
return;
|
||||
}
|
||||
|
||||
APPLICATION_ERROR_OCCURED = true;
|
||||
const element = document.createElement("div");
|
||||
element.id = "applicationError";
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.innerText = "Whoops!";
|
||||
element.appendChild(title);
|
||||
|
||||
const desc = document.createElement("div");
|
||||
desc.classList.add("desc");
|
||||
desc.innerHTML = `
|
||||
It seems the application crashed - I am sorry for that!<br /><br />
|
||||
An anonymized crash report has been sent, and I will have a look as soon as possible.<br /><br />
|
||||
If you have additional information how I can reproduce this error, please E-Mail me:
|
||||
<a href="mailto:bugs@shapez.io?title=Application+Crash">bugs@shapez.io</a>`;
|
||||
element.appendChild(desc);
|
||||
|
||||
const details = document.createElement("pre");
|
||||
details.classList.add("details");
|
||||
details.innerText = (error && error.stack) || message;
|
||||
element.appendChild(details);
|
||||
|
||||
const inject = function () {
|
||||
if (!G_IS_DEV) {
|
||||
removeAllChildren(document.body);
|
||||
}
|
||||
if (document.body.parentElement) {
|
||||
document.body.parentElement.appendChild(element);
|
||||
} else {
|
||||
document.body.appendChild(element);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.body) {
|
||||
inject();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
inject();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
window.onerror = catchErrors;
|
||||
40
src/js/core/explained_result.js
Normal file
40
src/js/core/explained_result.js
Normal file
@@ -0,0 +1,40 @@
|
||||
export class ExplainedResult {
|
||||
constructor(result = true, reason = null, additionalProps = {}) {
|
||||
/** @type {boolean} */
|
||||
this.result = result;
|
||||
|
||||
/** @type {string} */
|
||||
this.reason = reason;
|
||||
|
||||
// Copy additional props
|
||||
for (const key in additionalProps) {
|
||||
this[key] = additionalProps[key];
|
||||
}
|
||||
}
|
||||
|
||||
isGood() {
|
||||
return !!this.result;
|
||||
}
|
||||
|
||||
isBad() {
|
||||
return !this.result;
|
||||
}
|
||||
|
||||
static good() {
|
||||
return new ExplainedResult(true);
|
||||
}
|
||||
|
||||
static bad(reason, additionalProps) {
|
||||
return new ExplainedResult(false, reason, additionalProps);
|
||||
}
|
||||
|
||||
static requireAll(...args) {
|
||||
for (let i = 0; i < args.length; ++i) {
|
||||
const subResult = args[i].call();
|
||||
if (!subResult.isGood()) {
|
||||
return subResult;
|
||||
}
|
||||
}
|
||||
return this.good();
|
||||
}
|
||||
}
|
||||
81
src/js/core/factory.js
Normal file
81
src/js/core/factory.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { createLogger } from "./logging";
|
||||
|
||||
const logger = createLogger("factory");
|
||||
|
||||
// simple factory pattern
|
||||
export class Factory {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
|
||||
// Store array as well as dictionary, to speed up lookups
|
||||
this.entries = [];
|
||||
this.entryIds = [];
|
||||
this.idToEntry = {};
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
register(entry) {
|
||||
// Extract id
|
||||
const id = entry.getId();
|
||||
assert(id, "Factory: Invalid id for class: " + entry);
|
||||
|
||||
// Check duplicates
|
||||
assert(!this.idToEntry[id], "Duplicate factory entry for " + id);
|
||||
|
||||
// Insert
|
||||
this.entries.push(entry);
|
||||
this.entryIds.push(id);
|
||||
this.idToEntry[id] = entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given id is registered
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasId(id) {
|
||||
return !!this.idToEntry[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an instance by a given id
|
||||
* @param {string} id
|
||||
* @returns {object}
|
||||
*/
|
||||
findById(id) {
|
||||
const entry = this.idToEntry[id];
|
||||
if (!entry) {
|
||||
logger.error("Object with id", id, "is not registered on factory", this.id, "!");
|
||||
assert(false, "Factory: Object with id '" + id + "' is not registered!");
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all entries
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getEntries() {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all registered ids
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
getAllIds() {
|
||||
return this.entryIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns amount of stored entries
|
||||
* @returns {number}
|
||||
*/
|
||||
getNumEntries() {
|
||||
return this.entries.length;
|
||||
}
|
||||
}
|
||||
365
src/js/core/game_state.js
Normal file
365
src/js/core/game_state.js
Normal file
@@ -0,0 +1,365 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
import { StateManager } from "./state_manager";
|
||||
/* typehints:end */
|
||||
|
||||
import { globalConfig } from "./config";
|
||||
import { ClickDetector } from "./click_detector";
|
||||
import { logSection, createLogger } from "./logging";
|
||||
import { InputReceiver } from "./input_receiver";
|
||||
import { waitNextFrame } from "./utils";
|
||||
import { RequestChannel } from "./request_channel";
|
||||
import { MUSIC } from "../platform/sound";
|
||||
|
||||
const logger = createLogger("game_state");
|
||||
|
||||
/**
|
||||
* Basic state of the game state machine. This is the base of the whole game
|
||||
*/
|
||||
export class GameState {
|
||||
/**
|
||||
* Constructs a new state with the given id
|
||||
* @param {string} key The id of the state. We use ids to refer to states because otherwise we get
|
||||
* circular references
|
||||
*/
|
||||
constructor(key) {
|
||||
this.key = key;
|
||||
|
||||
/** @type {StateManager} */
|
||||
this.stateManager = null;
|
||||
|
||||
/** @type {Application} */
|
||||
this.app = null;
|
||||
|
||||
// Store if we are currently fading out
|
||||
this.fadingOut = false;
|
||||
|
||||
/** @type {Array<ClickDetector>} */
|
||||
this.clickDetectors = [];
|
||||
|
||||
// Every state captures keyboard events by default
|
||||
this.inputReciever = new InputReceiver("state-" + key);
|
||||
this.inputReciever.backButton.add(this.onBackButton, this);
|
||||
|
||||
// A channel we can use to perform async ops
|
||||
this.asyncChannel = new RequestChannel();
|
||||
}
|
||||
|
||||
//// GETTERS / HELPER METHODS ////
|
||||
|
||||
/**
|
||||
* Returns the states key
|
||||
* @returns {string}
|
||||
*/
|
||||
getKey() {
|
||||
return this.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the html element of the state
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
getDivElement() {
|
||||
return document.getElementById("state_" + this.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers to a new state
|
||||
* @param {string} stateKey The id of the new state
|
||||
*/
|
||||
moveToState(stateKey, payload = {}, skipFadeOut = false) {
|
||||
if (this.fadingOut) {
|
||||
logger.warn("Skipping move to '" + stateKey + "' since already fading out");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up event listeners
|
||||
this.internalCleanUpClickDetectors();
|
||||
|
||||
// Fading
|
||||
const fadeTime = this.internalGetFadeInOutTime();
|
||||
const doFade = !skipFadeOut && this.getHasFadeOut() && fadeTime !== 0;
|
||||
logger.log("Moving to", stateKey, "(fading=", doFade, ")");
|
||||
if (doFade) {
|
||||
this.htmlElement.classList.remove("arrived");
|
||||
this.fadingOut = true;
|
||||
setTimeout(() => {
|
||||
this.stateManager.moveToState(stateKey, payload);
|
||||
}, fadeTime);
|
||||
} else {
|
||||
this.stateManager.moveToState(stateKey, payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} nextStateId
|
||||
* @param {object=} nextStatePayload
|
||||
*/
|
||||
watchAdAndMoveToState(nextStateId, nextStatePayload = {}) {
|
||||
if (this.app.adProvider.getCanShowVideoAd() && this.app.isRenderable()) {
|
||||
this.moveToState(
|
||||
"WatchAdState",
|
||||
{
|
||||
nextStateId,
|
||||
nextStatePayload,
|
||||
},
|
||||
true
|
||||
);
|
||||
} else {
|
||||
this.moveToState(nextStateId, nextStatePayload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks clicks on a given element and calls the given callback *on this state*.
|
||||
* If you want to call another function wrap it inside a lambda.
|
||||
* @param {Element} element The element to track clicks on
|
||||
* @param {function():void} handler The handler to call
|
||||
* @param {import("./click_detector").ClickDetectorConstructorArgs=} args Click detector arguments
|
||||
*/
|
||||
trackClicks(element, handler, args = {}) {
|
||||
const detector = new ClickDetector(element, args);
|
||||
detector.click.add(handler, this);
|
||||
if (G_IS_DEV) {
|
||||
// Append a source so we can check where the click detector is from
|
||||
// @ts-ignore
|
||||
detector._src = "state-" + this.key;
|
||||
}
|
||||
this.clickDetectors.push(detector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all promises on the api as well as our async channel
|
||||
*/
|
||||
cancelAllAsyncOperations() {
|
||||
this.asyncChannel.cancelAll();
|
||||
// TODO
|
||||
// this.app.api.cancelRequests();
|
||||
}
|
||||
|
||||
//// CALLBACKS ////
|
||||
|
||||
/**
|
||||
* Callback when entering the state, to be overriddemn
|
||||
* @param {any} payload Arbitrary data passed from the state which we are transferring from
|
||||
*/
|
||||
onEnter(payload) {}
|
||||
|
||||
/**
|
||||
* Callback when leaving the state
|
||||
*/
|
||||
onLeave() {}
|
||||
|
||||
/**
|
||||
* Callback before leaving the game state or when the page is unloaded
|
||||
*/
|
||||
onBeforeExit() {}
|
||||
|
||||
/**
|
||||
* Callback when the app got paused (on android, this means in background)
|
||||
*/
|
||||
onAppPause() {}
|
||||
|
||||
/**
|
||||
* Callback when the app got resumed (on android, this means in foreground again)
|
||||
*/
|
||||
onAppResume() {}
|
||||
|
||||
/**
|
||||
* Render callback
|
||||
* @param {number} dt Delta time in ms since last render
|
||||
*/
|
||||
onRender(dt) {}
|
||||
|
||||
/**
|
||||
* Background tick callback, called while the game is inactiev
|
||||
* @param {number} dt Delta time in ms since last tick
|
||||
*/
|
||||
onBackgroundTick(dt) {}
|
||||
|
||||
/**
|
||||
* Called when the screen resized
|
||||
* @param {number} w window/screen width
|
||||
* @param {number} h window/screen height
|
||||
*/
|
||||
onResized(w, h) {}
|
||||
|
||||
/**
|
||||
* Internal backbutton handler, called when the hardware back button is pressed or
|
||||
* the escape key is pressed
|
||||
*/
|
||||
onBackButton() {}
|
||||
|
||||
//// INTERFACE ////
|
||||
|
||||
/**
|
||||
* Should return how many mulliseconds to fade in / out the state. Not recommended to override!
|
||||
* @returns {number} Time in milliseconds to fade out
|
||||
*/
|
||||
getInOutFadeTime() {
|
||||
if (globalConfig.debug.noArtificialDelays) {
|
||||
return 0;
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return whether to fade in the game state. This will then apply the right css classes
|
||||
* for the fadein.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getHasFadeIn() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return whether to fade out the game state. This will then apply the right css classes
|
||||
* for the fadeout and wait the delay before moving states
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getHasFadeOut() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this state should get paused if it does not have focus
|
||||
* @returns {boolean} true to pause the updating of the game
|
||||
*/
|
||||
getPauseOnFocusLost() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the html code of the state.
|
||||
* @returns {string}
|
||||
*/
|
||||
getInnerHTML() {
|
||||
abstract;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the state has an unload confirmation, this is the
|
||||
* "Are you sure you want to leave the page" message.
|
||||
*/
|
||||
getHasUnloadConfirmation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the theme music for this state
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getThemeMusic() {
|
||||
return MUSIC.mainMenu;
|
||||
}
|
||||
|
||||
////////////////////
|
||||
|
||||
//// INTERNAL ////
|
||||
|
||||
/**
|
||||
* Internal callback from the manager. Do not override!
|
||||
* @param {StateManager} stateManager
|
||||
*/
|
||||
internalRegisterCallback(stateManager, app) {
|
||||
assert(stateManager, "No state manager");
|
||||
assert(app, "No app");
|
||||
this.stateManager = stateManager;
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal callback when entering the state. Do not override!
|
||||
* @param {any} payload Arbitrary data passed from the state which we are transferring from
|
||||
* @param {boolean} callCallback Whether to call the onEnter callback
|
||||
*/
|
||||
internalEnterCallback(payload, callCallback = true) {
|
||||
logSection(this.key, "#26a69a");
|
||||
this.app.inputMgr.pushReciever(this.inputReciever);
|
||||
|
||||
this.htmlElement = this.getDivElement();
|
||||
this.htmlElement.classList.add("active");
|
||||
|
||||
// Apply classes in the next frame so the css transition keeps up
|
||||
waitNextFrame().then(() => {
|
||||
if (this.htmlElement) {
|
||||
this.htmlElement.classList.remove("fadingOut");
|
||||
this.htmlElement.classList.remove("fadingIn");
|
||||
}
|
||||
});
|
||||
|
||||
// Call handler
|
||||
if (callCallback) {
|
||||
this.onEnter(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal callback when the state is left. Do not override!
|
||||
*/
|
||||
internalLeaveCallback() {
|
||||
this.onLeave();
|
||||
|
||||
this.htmlElement.classList.remove("active");
|
||||
this.app.inputMgr.popReciever(this.inputReciever);
|
||||
this.internalCleanUpClickDetectors();
|
||||
this.asyncChannel.cancelAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal callback *before* the state is left. Also is called on page unload
|
||||
*/
|
||||
internalOnBeforeExitCallback() {
|
||||
this.onBeforeExit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal app pause callback
|
||||
*/
|
||||
internalOnAppPauseCallback() {
|
||||
this.onAppPause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal app resume callback
|
||||
*/
|
||||
internalOnAppResumeCallback() {
|
||||
this.onAppResume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all click detectors
|
||||
*/
|
||||
internalCleanUpClickDetectors() {
|
||||
if (this.clickDetectors) {
|
||||
for (let i = 0; i < this.clickDetectors.length; ++i) {
|
||||
this.clickDetectors[i].cleanup();
|
||||
}
|
||||
this.clickDetectors = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to get the HTML of the game state.
|
||||
* @returns {string}
|
||||
*/
|
||||
internalGetFullHtml() {
|
||||
return this.getInnerHTML();
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to compute the time to fade in / out
|
||||
* @returns {number} time to fade in / out in ms
|
||||
*/
|
||||
internalGetFadeInOutTime() {
|
||||
if (G_IS_DEV && globalConfig.debug.fastGameEnter) {
|
||||
return 1;
|
||||
}
|
||||
if (G_IS_DEV && globalConfig.debug.noArtificialDelays) {
|
||||
return 1;
|
||||
}
|
||||
return this.getInOutFadeTime();
|
||||
}
|
||||
}
|
||||
35
src/js/core/global_registries.js
Normal file
35
src/js/core/global_registries.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { SingletonFactory } from "./singleton_factory";
|
||||
import { Factory } from "./factory";
|
||||
|
||||
/* typehints:start */
|
||||
import { BaseGameSpeed } from "../game/time/base_game_speed";
|
||||
import { Component } from "../game/component";
|
||||
import { BaseItem } from "../game/base_item";
|
||||
import { MetaBuilding } from "../game/meta_building";
|
||||
/* typehints:end */
|
||||
|
||||
// These factories are here to remove circular dependencies
|
||||
|
||||
/** @type {SingletonFactoryTemplate<MetaBuilding>} */
|
||||
export let gMetaBuildingRegistry = new SingletonFactory();
|
||||
|
||||
/** @type {Object.<string, Array<typeof MetaBuilding>>} */
|
||||
export let gBuildingsByCategory = null;
|
||||
|
||||
/** @type {FactoryTemplate<Component>} */
|
||||
export let gComponentRegistry = new Factory("component");
|
||||
|
||||
/** @type {FactoryTemplate<BaseGameSpeed>} */
|
||||
export let gGameSpeedRegistry = new Factory("gamespeed");
|
||||
|
||||
/** @type {FactoryTemplate<BaseItem>} */
|
||||
export let gItemRegistry = new Factory("item");
|
||||
|
||||
// Helpers
|
||||
|
||||
/**
|
||||
* @param {Object.<string, Array<typeof MetaBuilding>>} buildings
|
||||
*/
|
||||
export function initBuildingsByCategory(buildings) {
|
||||
gBuildingsByCategory = buildings;
|
||||
}
|
||||
17
src/js/core/globals.js
Normal file
17
src/js/core/globals.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
/**
|
||||
* Used for the bug reporter, and the click detector which both have no handles to this.
|
||||
* It would be nicer to have no globals, but this is the only one. I promise!
|
||||
* @type {Application} */
|
||||
export let GLOBAL_APP = null;
|
||||
|
||||
/**
|
||||
* @param {Application} app
|
||||
*/
|
||||
export function setGlobalApp(app) {
|
||||
assert(!GLOBAL_APP, "Create application twice!");
|
||||
GLOBAL_APP = app;
|
||||
}
|
||||
235
src/js/core/input_distributor.js
Normal file
235
src/js/core/input_distributor.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
import { InputReceiver } from "./input_receiver";
|
||||
/* typehints:end */
|
||||
|
||||
import { Signal, STOP_PROPAGATION } from "./signal";
|
||||
import { createLogger } from "./logging";
|
||||
import { arrayDeleteValue, fastArrayDeleteValue } from "./utils";
|
||||
|
||||
const logger = createLogger("input_distributor");
|
||||
|
||||
export class InputDistributor {
|
||||
/**
|
||||
*
|
||||
* @param {Application} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/** @type {Array<InputReceiver>} */
|
||||
this.recieverStack = [];
|
||||
|
||||
/** @type {Array<function(any) : boolean>} */
|
||||
this.filters = [];
|
||||
|
||||
this.shiftIsDown = false;
|
||||
this.altIsDown = false;
|
||||
|
||||
this.bindToEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a new filter which can filter and reject events
|
||||
* @param {function(any): boolean} filter
|
||||
*/
|
||||
installFilter(filter) {
|
||||
this.filters.push(filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an attached filter
|
||||
* @param {function(any) : boolean} filter
|
||||
*/
|
||||
dismountFilter(filter) {
|
||||
fastArrayDeleteValue(this.filters, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
pushReciever(reciever) {
|
||||
if (this.isRecieverAttached(reciever)) {
|
||||
assert(false, "Can not add reciever " + reciever.context + " twice");
|
||||
logger.error("Can not add reciever", reciever.context, "twice");
|
||||
return;
|
||||
}
|
||||
this.recieverStack.push(reciever);
|
||||
|
||||
if (this.recieverStack.length > 10) {
|
||||
logger.error(
|
||||
"Reciever stack is huge, probably some dead receivers arround:",
|
||||
this.recieverStack.map(x => x.context)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
popReciever(reciever) {
|
||||
if (this.recieverStack.indexOf(reciever) < 0) {
|
||||
assert(false, "Can not pop reciever " + reciever.context + " since its not contained");
|
||||
logger.error("Can not pop reciever", reciever.context, "since its not contained");
|
||||
return;
|
||||
}
|
||||
if (this.recieverStack[this.recieverStack.length - 1] !== reciever) {
|
||||
logger.warn(
|
||||
"Popping reciever",
|
||||
reciever.context,
|
||||
"which is not on top of the stack. Stack is: ",
|
||||
this.recieverStack.map(x => x.context)
|
||||
);
|
||||
}
|
||||
arrayDeleteValue(this.recieverStack, reciever);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
isRecieverAttached(reciever) {
|
||||
return this.recieverStack.indexOf(reciever) >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
isRecieverOnTop(reciever) {
|
||||
return (
|
||||
this.isRecieverAttached(reciever) &&
|
||||
this.recieverStack[this.recieverStack.length - 1] === reciever
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
makeSureAttachedAndOnTop(reciever) {
|
||||
this.makeSureDetached(reciever);
|
||||
this.pushReciever(reciever);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
makeSureDetached(reciever) {
|
||||
if (this.isRecieverAttached(reciever)) {
|
||||
arrayDeleteValue(this.recieverStack, reciever);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {InputReceiver} reciever
|
||||
*/
|
||||
destroyReceiver(reciever) {
|
||||
this.makeSureDetached(reciever);
|
||||
reciever.cleanup();
|
||||
}
|
||||
|
||||
// Internal
|
||||
|
||||
getTopReciever() {
|
||||
if (this.recieverStack.length > 0) {
|
||||
return this.recieverStack[this.recieverStack.length - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bindToEvents() {
|
||||
window.addEventListener("popstate", this.handleBackButton.bind(this), false);
|
||||
document.addEventListener("backbutton", this.handleBackButton.bind(this), false);
|
||||
window.addEventListener("keydown", this.handleKeydown.bind(this));
|
||||
window.addEventListener("keyup", this.handleKeyup.bind(this));
|
||||
window.addEventListener("blur", this.handleBlur.bind(this));
|
||||
}
|
||||
|
||||
forwardToReceiver(eventId, payload = null) {
|
||||
// Check filters
|
||||
for (let i = 0; i < this.filters.length; ++i) {
|
||||
if (!this.filters[i](eventId)) {
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
}
|
||||
|
||||
const reciever = this.getTopReciever();
|
||||
if (!reciever) {
|
||||
logger.warn("Dismissing event because not reciever was found:", eventId);
|
||||
return;
|
||||
}
|
||||
const signal = reciever[eventId];
|
||||
assert(signal instanceof Signal, "Not a valid event id");
|
||||
return signal.dispatch(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
handleBackButton(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.forwardToReceiver("backButton");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles when the page got blurred
|
||||
*/
|
||||
handleBlur() {
|
||||
this.shiftIsDown = false;
|
||||
this.forwardToReceiver("pageBlur", {});
|
||||
this.forwardToReceiver("shiftUp", {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
handleKeydown(event) {
|
||||
if (event.keyCode === 16) {
|
||||
this.shiftIsDown = true;
|
||||
}
|
||||
|
||||
if (
|
||||
// TAB
|
||||
event.keyCode === 9 ||
|
||||
// F1 - F10
|
||||
(event.keyCode >= 112 && event.keyCode < 122 && !G_IS_DEV)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (
|
||||
this.forwardToReceiver("keydown", {
|
||||
keyCode: event.keyCode,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
event,
|
||||
}) === STOP_PROPAGATION
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = event.keyCode;
|
||||
if (code === 27) {
|
||||
// Escape key
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return this.forwardToReceiver("backButton");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
handleKeyup(event) {
|
||||
if (event.keyCode === 16) {
|
||||
this.shiftIsDown = false;
|
||||
this.forwardToReceiver("shiftUp", {});
|
||||
}
|
||||
|
||||
this.forwardToReceiver("keyup", {
|
||||
keyCode: event.keyCode,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
25
src/js/core/input_receiver.js
Normal file
25
src/js/core/input_receiver.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Signal } from "./signal";
|
||||
|
||||
export class InputReceiver {
|
||||
constructor(context = "unknown") {
|
||||
this.context = context;
|
||||
|
||||
this.backButton = new Signal();
|
||||
|
||||
this.keydown = new Signal();
|
||||
this.keyup = new Signal();
|
||||
this.pageBlur = new Signal();
|
||||
this.shiftUp = new Signal();
|
||||
|
||||
// Dispatched on destroy
|
||||
this.destroyed = new Signal();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.backButton.removeAll();
|
||||
this.keydown.removeAll();
|
||||
this.keyup.removeAll();
|
||||
|
||||
this.destroyed.dispatch();
|
||||
}
|
||||
}
|
||||
243
src/js/core/loader.js
Normal file
243
src/js/core/loader.js
Normal file
@@ -0,0 +1,243 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { AtlasDefinition } from "./atlas_definitions";
|
||||
import { makeOffscreenBuffer } from "./buffer_utils";
|
||||
import { AtlasSprite, BaseSprite, RegularSprite, SpriteAtlasLink } from "./sprites";
|
||||
import { cachebust } from "./cachebust";
|
||||
import { createLogger } from "./logging";
|
||||
|
||||
const logger = createLogger("loader");
|
||||
|
||||
const missingSpriteIds = {};
|
||||
|
||||
class LoaderImpl {
|
||||
constructor() {
|
||||
/** @type {Application} */
|
||||
this.app = null;
|
||||
|
||||
/** @type {Map<string, BaseSprite>} */
|
||||
this.sprites = new Map();
|
||||
|
||||
this.rawImages = [];
|
||||
}
|
||||
|
||||
linkAppAfterBoot(app) {
|
||||
this.app = app;
|
||||
this.makeSpriteNotFoundCanvas();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a given sprite from the cache
|
||||
* @param {string} key
|
||||
* @returns {BaseSprite}
|
||||
*/
|
||||
getSpriteInternal(key) {
|
||||
const sprite = this.sprites.get(key);
|
||||
if (!sprite) {
|
||||
if (!missingSpriteIds[key]) {
|
||||
// Only show error once
|
||||
missingSpriteIds[key] = true;
|
||||
logger.error("Sprite '" + key + "' not found!");
|
||||
}
|
||||
return this.spriteNotFoundSprite;
|
||||
}
|
||||
return sprite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an atlas sprite from the cache
|
||||
* @param {string} key
|
||||
* @returns {AtlasSprite}
|
||||
*/
|
||||
getSprite(key) {
|
||||
const sprite = this.getSpriteInternal(key);
|
||||
assert(sprite instanceof AtlasSprite || sprite === this.spriteNotFoundSprite, "Not an atlas sprite");
|
||||
return /** @type {AtlasSprite} */ (sprite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retursn a regular sprite from the cache
|
||||
* @param {string} key
|
||||
* @returns {RegularSprite}
|
||||
*/
|
||||
getRegularSprite(key) {
|
||||
const sprite = this.getSpriteInternal(key);
|
||||
assert(
|
||||
sprite instanceof RegularSprite || sprite === this.spriteNotFoundSprite,
|
||||
"Not a regular sprite"
|
||||
);
|
||||
return /** @type {RegularSprite} */ (sprite);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} key
|
||||
* @returns {Promise<HTMLImageElement|null>}
|
||||
*/
|
||||
internalPreloadImage(key) {
|
||||
const url = cachebust("res/" + key);
|
||||
const image = new Image();
|
||||
|
||||
let triesSoFar = 0;
|
||||
|
||||
return Promise.race([
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(reject, G_IS_DEV ? 3000 : 60000);
|
||||
}),
|
||||
|
||||
new Promise(resolve => {
|
||||
image.onload = () => {
|
||||
image.onerror = null;
|
||||
image.onload = null;
|
||||
|
||||
if (typeof image.decode === "function") {
|
||||
// SAFARI: Image.decode() fails on safari with svgs -> we dont want to fail
|
||||
// on that
|
||||
// FIREFOX: Decode never returns if the image is in cache, so call it in background
|
||||
image.decode().then(
|
||||
() => null,
|
||||
() => null
|
||||
);
|
||||
}
|
||||
resolve(image);
|
||||
};
|
||||
|
||||
image.onerror = reason => {
|
||||
logger.warn("Failed to load '" + url + "':", reason);
|
||||
if (++triesSoFar < 4) {
|
||||
logger.log("Retrying to load image from", url);
|
||||
image.src = url + "?try=" + triesSoFar;
|
||||
} else {
|
||||
logger.warn("Failed to load", url, "after", triesSoFar, "tries with reason", reason);
|
||||
image.onerror = null;
|
||||
image.onload = null;
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
image.src = url;
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads a sprite
|
||||
* @param {string} key
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
preloadCSSSprite(key) {
|
||||
return this.internalPreloadImage(key).then(image => {
|
||||
if (key.indexOf("game_misc") >= 0) {
|
||||
// Allow access to regular sprites
|
||||
this.sprites.set(key, new RegularSprite(image, image.width, image.height));
|
||||
}
|
||||
this.rawImages.push(image);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads an atlas
|
||||
* @param {AtlasDefinition} atlas
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
preloadAtlas(atlas) {
|
||||
return this.internalPreloadImage(atlas.getFullSourcePath()).then(image => {
|
||||
// @ts-ignore
|
||||
image.label = atlas.sourceFileName;
|
||||
return this.internalParseAtlas(atlas, image);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {AtlasDefinition} atlas
|
||||
* @param {HTMLImageElement} loadedImage
|
||||
*/
|
||||
internalParseAtlas(atlas, loadedImage) {
|
||||
this.rawImages.push(loadedImage);
|
||||
|
||||
for (const spriteKey in atlas.sourceData) {
|
||||
const spriteData = atlas.sourceData[spriteKey];
|
||||
|
||||
let sprite = /** @type {AtlasSprite} */ (this.sprites.get(spriteKey));
|
||||
|
||||
if (!sprite) {
|
||||
sprite = new AtlasSprite({
|
||||
spriteName: spriteKey,
|
||||
});
|
||||
this.sprites.set(spriteKey, sprite);
|
||||
}
|
||||
|
||||
const link = new SpriteAtlasLink({
|
||||
packedX: spriteData.frame.x,
|
||||
packedY: spriteData.frame.y,
|
||||
packedW: spriteData.frame.w,
|
||||
packedH: spriteData.frame.h,
|
||||
packOffsetX: spriteData.spriteSourceSize.x,
|
||||
packOffsetY: spriteData.spriteSourceSize.y,
|
||||
atlas: loadedImage,
|
||||
w: spriteData.sourceSize.w,
|
||||
h: spriteData.sourceSize.h,
|
||||
});
|
||||
sprite.linksByResolution[atlas.meta.scale] = link;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the links for the sprites after the atlas has been loaded. Used so we
|
||||
* don't have to store duplicate sprites.
|
||||
*/
|
||||
createAtlasLinks() {
|
||||
// NOT USED
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the canvas which shows the question mark, shown when a sprite was not found
|
||||
*/
|
||||
makeSpriteNotFoundCanvas() {
|
||||
const dims = 128;
|
||||
|
||||
const [canvas, context] = makeOffscreenBuffer(dims, dims, {
|
||||
smooth: false,
|
||||
label: "not-found-sprite",
|
||||
});
|
||||
context.fillStyle = "#f77";
|
||||
context.fillRect(0, 0, dims, dims);
|
||||
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillStyle = "#eee";
|
||||
context.font = "25px Arial";
|
||||
context.fillText("???", dims / 2, dims / 2);
|
||||
|
||||
// TODO: Not sure why this is set here
|
||||
// @ts-ignore
|
||||
canvas.src = "not-found";
|
||||
|
||||
const resolutions = ["0.1", "0.25", "0.5", "0.75", "1"];
|
||||
const sprite = new AtlasSprite({
|
||||
spriteName: "not-found",
|
||||
});
|
||||
|
||||
for (let i = 0; i < resolutions.length; ++i) {
|
||||
const res = resolutions[i];
|
||||
const link = new SpriteAtlasLink({
|
||||
packedX: 0,
|
||||
packedY: 0,
|
||||
w: dims,
|
||||
h: dims,
|
||||
packOffsetX: 0,
|
||||
packOffsetY: 0,
|
||||
packedW: dims,
|
||||
packedH: dims,
|
||||
atlas: canvas,
|
||||
});
|
||||
sprite.linksByResolution[res] = link;
|
||||
}
|
||||
this.spriteNotFoundSprite = sprite;
|
||||
}
|
||||
}
|
||||
|
||||
export const Loader = new LoaderImpl();
|
||||
249
src/js/core/logging.js
Normal file
249
src/js/core/logging.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Math_floor, performanceNow } from "./builtins";
|
||||
|
||||
const circularJson = require("circular-json");
|
||||
|
||||
/*
|
||||
Logging functions
|
||||
- To be extended
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base logger class
|
||||
*/
|
||||
class Logger {
|
||||
constructor(context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
debug(...args) {
|
||||
globalDebug(this.context, ...args);
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
globalLog(this.context, ...args);
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
globalWarn(this.context, ...args);
|
||||
}
|
||||
|
||||
error(...args) {
|
||||
globalError(this.context, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(context) {
|
||||
return new Logger(context);
|
||||
}
|
||||
|
||||
function prepareObjectForLogging(obj, maxDepth = 1) {
|
||||
if (!window.Sentry) {
|
||||
// Not required without sentry
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (typeof obj !== "object" && !Array.isArray(obj)) {
|
||||
return obj;
|
||||
}
|
||||
const result = {};
|
||||
for (const key in obj) {
|
||||
const val = obj[key];
|
||||
|
||||
if (typeof val === "object") {
|
||||
if (maxDepth > 0) {
|
||||
result[key] = prepareObjectForLogging(val, maxDepth - 1);
|
||||
} else {
|
||||
result[key] = "[object]";
|
||||
}
|
||||
} else {
|
||||
result[key] = val;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an error
|
||||
* @param {Error|ErrorEvent} err
|
||||
*/
|
||||
export function serializeError(err) {
|
||||
if (!err) {
|
||||
return null;
|
||||
}
|
||||
const result = {
|
||||
type: err.constructor.name,
|
||||
};
|
||||
|
||||
if (err instanceof Error) {
|
||||
result.message = err.message;
|
||||
result.name = err.name;
|
||||
result.stack = err.stack;
|
||||
result.type = "{type.Error}";
|
||||
} else if (err instanceof ErrorEvent) {
|
||||
result.filename = err.filename;
|
||||
result.message = err.message;
|
||||
result.lineno = err.lineno;
|
||||
result.colno = err.colno;
|
||||
result.type = "{type.ErrorEvent}";
|
||||
|
||||
if (err.error) {
|
||||
result.error = serializeError(err.error);
|
||||
} else {
|
||||
result.error = "{not-provided}";
|
||||
}
|
||||
} else {
|
||||
result.type = "{unkown-type:" + typeof err + "}";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an event
|
||||
* @param {Event} event
|
||||
*/
|
||||
function serializeEvent(event) {
|
||||
let result = {
|
||||
type: "{type.Event:" + typeof event + "}",
|
||||
};
|
||||
result.eventType = event.type;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a json payload
|
||||
* @param {string} key
|
||||
* @param {any} value
|
||||
*/
|
||||
function preparePayload(key, value) {
|
||||
if (value instanceof Error || value instanceof ErrorEvent) {
|
||||
return serializeError(value);
|
||||
}
|
||||
if (value instanceof Event) {
|
||||
return serializeEvent(value);
|
||||
}
|
||||
if (typeof value === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringifies an object containing circular references and errors
|
||||
* @param {any} payload
|
||||
*/
|
||||
export function stringifyObjectContainingErrors(payload) {
|
||||
return circularJson.stringify(payload, preparePayload);
|
||||
}
|
||||
|
||||
export function globalDebug(context, ...args) {
|
||||
if (G_IS_DEV) {
|
||||
logInternal(context, console.log, prepareArgsForLogging(args));
|
||||
}
|
||||
}
|
||||
|
||||
export function globalLog(context, ...args) {
|
||||
// eslint-disable-next-line no-console
|
||||
logInternal(context, console.log, prepareArgsForLogging(args));
|
||||
}
|
||||
|
||||
export function globalWarn(context, ...args) {
|
||||
// eslint-disable-next-line no-console
|
||||
logInternal(context, console.warn, prepareArgsForLogging(args));
|
||||
}
|
||||
|
||||
export function globalError(context, ...args) {
|
||||
args = prepareArgsForLogging(args);
|
||||
// eslint-disable-next-line no-console
|
||||
logInternal(context, console.error, args);
|
||||
|
||||
if (window.Sentry) {
|
||||
window.Sentry.withScope(scope => {
|
||||
scope.setExtra("args", args);
|
||||
window.Sentry.captureMessage(internalBuildStringFromArgs(args), "error");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function prepareArgsForLogging(args) {
|
||||
let result = [];
|
||||
for (let i = 0; i < args.length; ++i) {
|
||||
result.push(prepareObjectForLogging(args[i]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<any>} args
|
||||
*/
|
||||
function internalBuildStringFromArgs(args) {
|
||||
let result = [];
|
||||
|
||||
for (let i = 0; i < args.length; ++i) {
|
||||
let arg = args[i];
|
||||
if (
|
||||
typeof arg === "string" ||
|
||||
typeof arg === "number" ||
|
||||
typeof arg === "boolean" ||
|
||||
arg === null ||
|
||||
arg === undefined
|
||||
) {
|
||||
result.push("" + arg);
|
||||
} else if (arg instanceof Error) {
|
||||
result.push(arg.message);
|
||||
} else {
|
||||
result.push("[object]");
|
||||
}
|
||||
}
|
||||
return result.join(" ");
|
||||
}
|
||||
|
||||
export function logSection(name, color) {
|
||||
while (name.length <= 14) {
|
||||
name = " " + name + " ";
|
||||
}
|
||||
name = name.padEnd(19, " ");
|
||||
|
||||
const lineCss =
|
||||
"letter-spacing: -3px; color: " + color + "; font-size: 6px; background: #eee; color: #eee;";
|
||||
const line = "%c----------------------------";
|
||||
console.log("\n" + line + " %c" + name + " " + line + "\n", lineCss, "color: " + color, lineCss);
|
||||
}
|
||||
|
||||
function extractHandleContext(handle) {
|
||||
let context = handle || "unknown";
|
||||
if (handle && handle.constructor && handle.constructor.name) {
|
||||
context = handle.constructor.name;
|
||||
if (context === "String") {
|
||||
context = handle;
|
||||
}
|
||||
}
|
||||
|
||||
if (handle && handle.name) {
|
||||
context = handle.name;
|
||||
}
|
||||
return context + "";
|
||||
}
|
||||
|
||||
function logInternal(handle, consoleMethod, args) {
|
||||
const context = extractHandleContext(handle).padEnd(20, " ");
|
||||
const labelColor = handle && handle.LOG_LABEL_COLOR ? handle.LOG_LABEL_COLOR : "#aaa";
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.logTimestamps) {
|
||||
const timestamp = "⏱ %c" + (Math_floor(performanceNow()) + "").padEnd(6, " ") + "";
|
||||
consoleMethod.call(
|
||||
console,
|
||||
timestamp + " %c" + context,
|
||||
"color: #7f7;",
|
||||
"color: " + labelColor + ";",
|
||||
...args
|
||||
);
|
||||
} else {
|
||||
if (G_IS_DEV && !globalConfig.debug.disableLoggingLogSources) {
|
||||
consoleMethod.call(console, "%c" + context, "color: " + labelColor, ...args);
|
||||
} else {
|
||||
consoleMethod.call(console, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
493
src/js/core/lzstring.js
Normal file
493
src/js/core/lzstring.js
Normal file
@@ -0,0 +1,493 @@
|
||||
// Copyright (c) 2013 Pieroxy <pieroxy@pieroxy.net>
|
||||
// This work is free. You can redistribute it and/or modify it
|
||||
// under the terms of the WTFPL, Version 2
|
||||
// For more information see LICENSE.txt or http://www.wtfpl.net/
|
||||
//
|
||||
// For more information, the home page:
|
||||
// http://pieroxy.net/blog/pages/lz-string/testing.html
|
||||
//
|
||||
// LZ-based compression algorithm, version 1.4.4
|
||||
|
||||
const fromCharCode = String.fromCharCode;
|
||||
const hasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
|
||||
const keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
|
||||
const baseReverseDic = {};
|
||||
|
||||
function getBaseValue(alphabet, character) {
|
||||
if (!baseReverseDic[alphabet]) {
|
||||
baseReverseDic[alphabet] = {};
|
||||
for (let i = 0; i < alphabet.length; i++) {
|
||||
baseReverseDic[alphabet][alphabet.charAt(i)] = i;
|
||||
}
|
||||
}
|
||||
return baseReverseDic[alphabet][character];
|
||||
}
|
||||
|
||||
//compress into uint8array (UCS-2 big endian format)
|
||||
export function compressU8(uncompressed) {
|
||||
let compressed = compress(uncompressed);
|
||||
let buf = new Uint8Array(compressed.length * 2); // 2 bytes per character
|
||||
|
||||
for (let i = 0, TotalLen = compressed.length; i < TotalLen; i++) {
|
||||
let current_value = compressed.charCodeAt(i);
|
||||
buf[i * 2] = current_value >>> 8;
|
||||
buf[i * 2 + 1] = current_value % 256;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Compreses with header
|
||||
/**
|
||||
* @param {string} uncompressed
|
||||
* @param {number} header
|
||||
*/
|
||||
export function compressU8WHeader(uncompressed, header) {
|
||||
let compressed = compress(uncompressed);
|
||||
let buf = new Uint8Array(2 + compressed.length * 2); // 2 bytes per character
|
||||
|
||||
buf[0] = header >>> 8;
|
||||
buf[1] = header % 256;
|
||||
for (let i = 0, TotalLen = compressed.length; i < TotalLen; i++) {
|
||||
let current_value = compressed.charCodeAt(i);
|
||||
buf[2 + i * 2] = current_value >>> 8;
|
||||
buf[2 + i * 2 + 1] = current_value % 256;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
//decompress from uint8array (UCS-2 big endian format)
|
||||
/**
|
||||
*
|
||||
* @param {Uint8Array} compressed
|
||||
*/
|
||||
export function decompressU8WHeader(compressed) {
|
||||
// let buf = new Array(compressed.length / 2); // 2 bytes per character
|
||||
// for (let i = 0, TotalLen = buf.length; i < TotalLen; i++) {
|
||||
// buf[i] = compressed[i * 2] * 256 + compressed[i * 2 + 1];
|
||||
// }
|
||||
|
||||
// let result = [];
|
||||
// buf.forEach(function (c) {
|
||||
// result.push(fromCharCode(c));
|
||||
// });
|
||||
let result = [];
|
||||
for (let i = 2, n = compressed.length; i < n; i += 2) {
|
||||
const code = compressed[i] * 256 + compressed[i + 1];
|
||||
result.push(fromCharCode(code));
|
||||
}
|
||||
return decompress(result.join(""));
|
||||
}
|
||||
|
||||
//compress into a string that is already URI encoded
|
||||
export function compressX64(input) {
|
||||
if (input == null) return "";
|
||||
return _compress(input, 6, function (a) {
|
||||
return keyStrUriSafe.charAt(a);
|
||||
});
|
||||
}
|
||||
|
||||
//decompress from an output of compressToEncodedURIComponent
|
||||
export function decompressX64(input) {
|
||||
if (input == null) return "";
|
||||
if (input == "") return null;
|
||||
input = input.replace(/ /g, "+");
|
||||
return _decompress(input.length, 32, function (index) {
|
||||
return getBaseValue(keyStrUriSafe, input.charAt(index));
|
||||
});
|
||||
}
|
||||
|
||||
function compress(uncompressed) {
|
||||
return _compress(uncompressed, 16, function (a) {
|
||||
return fromCharCode(a);
|
||||
});
|
||||
}
|
||||
|
||||
function _compress(uncompressed, bitsPerChar, getCharFromInt) {
|
||||
if (uncompressed == null) return "";
|
||||
let i,
|
||||
value,
|
||||
context_dictionary = {},
|
||||
context_dictionaryToCreate = {},
|
||||
context_c = "",
|
||||
context_wc = "",
|
||||
context_w = "",
|
||||
context_enlargeIn = 2, // Compensate for the first entry which should not count
|
||||
context_dictSize = 3,
|
||||
context_numBits = 2,
|
||||
context_data = [],
|
||||
context_data_val = 0,
|
||||
context_data_position = 0,
|
||||
ii;
|
||||
|
||||
for (ii = 0; ii < uncompressed.length; ii += 1) {
|
||||
context_c = uncompressed.charAt(ii);
|
||||
if (!hasOwnProperty.call(context_dictionary, context_c)) {
|
||||
context_dictionary[context_c] = context_dictSize++;
|
||||
context_dictionaryToCreate[context_c] = true;
|
||||
}
|
||||
|
||||
context_wc = context_w + context_c;
|
||||
if (hasOwnProperty.call(context_dictionary, context_wc)) {
|
||||
context_w = context_wc;
|
||||
} else {
|
||||
if (hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
|
||||
if (context_w.charCodeAt(0) < 256) {
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = context_data_val << 1;
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
}
|
||||
value = context_w.charCodeAt(0);
|
||||
for (i = 0; i < 8; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
} else {
|
||||
value = 1;
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = (context_data_val << 1) | value;
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = 0;
|
||||
}
|
||||
value = context_w.charCodeAt(0);
|
||||
for (i = 0; i < 16; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
}
|
||||
context_enlargeIn--;
|
||||
if (context_enlargeIn == 0) {
|
||||
context_enlargeIn = Math.pow(2, context_numBits);
|
||||
context_numBits++;
|
||||
}
|
||||
delete context_dictionaryToCreate[context_w];
|
||||
} else {
|
||||
value = context_dictionary[context_w];
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
}
|
||||
context_enlargeIn--;
|
||||
if (context_enlargeIn == 0) {
|
||||
context_enlargeIn = Math.pow(2, context_numBits);
|
||||
context_numBits++;
|
||||
}
|
||||
// Add wc to the dictionary.
|
||||
context_dictionary[context_wc] = context_dictSize++;
|
||||
context_w = String(context_c);
|
||||
}
|
||||
}
|
||||
|
||||
// Output the code for w.
|
||||
if (context_w !== "") {
|
||||
if (hasOwnProperty.call(context_dictionaryToCreate, context_w)) {
|
||||
if (context_w.charCodeAt(0) < 256) {
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = context_data_val << 1;
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
}
|
||||
value = context_w.charCodeAt(0);
|
||||
for (i = 0; i < 8; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
} else {
|
||||
value = 1;
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = (context_data_val << 1) | value;
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = 0;
|
||||
}
|
||||
value = context_w.charCodeAt(0);
|
||||
for (i = 0; i < 16; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
}
|
||||
context_enlargeIn--;
|
||||
if (context_enlargeIn == 0) {
|
||||
context_enlargeIn = Math.pow(2, context_numBits);
|
||||
context_numBits++;
|
||||
}
|
||||
delete context_dictionaryToCreate[context_w];
|
||||
} else {
|
||||
value = context_dictionary[context_w];
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
}
|
||||
context_enlargeIn--;
|
||||
if (context_enlargeIn == 0) {
|
||||
context_enlargeIn = Math.pow(2, context_numBits);
|
||||
context_numBits++;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the end of the stream
|
||||
value = 2;
|
||||
for (i = 0; i < context_numBits; i++) {
|
||||
context_data_val = (context_data_val << 1) | (value & 1);
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data_position = 0;
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
context_data_val = 0;
|
||||
} else {
|
||||
context_data_position++;
|
||||
}
|
||||
value = value >> 1;
|
||||
}
|
||||
|
||||
// Flush the last char
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
context_data_val = context_data_val << 1;
|
||||
if (context_data_position == bitsPerChar - 1) {
|
||||
context_data.push(getCharFromInt(context_data_val));
|
||||
break;
|
||||
} else context_data_position++;
|
||||
}
|
||||
return context_data.join("");
|
||||
}
|
||||
|
||||
function decompress(compressed) {
|
||||
if (compressed == null) return "";
|
||||
if (compressed == "") return null;
|
||||
return _decompress(compressed.length, 32768, function (index) {
|
||||
return compressed.charCodeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
function _decompress(length, resetValue, getNextValue) {
|
||||
let dictionary = [],
|
||||
next,
|
||||
enlargeIn = 4,
|
||||
dictSize = 4,
|
||||
numBits = 3,
|
||||
entry = "",
|
||||
result = [],
|
||||
i,
|
||||
w,
|
||||
bits,
|
||||
resb,
|
||||
maxpower,
|
||||
power,
|
||||
c,
|
||||
data = { val: getNextValue(0), position: resetValue, index: 1 };
|
||||
|
||||
for (i = 0; i < 3; i += 1) {
|
||||
dictionary[i] = i;
|
||||
}
|
||||
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, 2);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
|
||||
switch ((next = bits)) {
|
||||
case 0:
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, 8);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
c = fromCharCode(bits);
|
||||
break;
|
||||
case 1:
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, 16);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
c = fromCharCode(bits);
|
||||
break;
|
||||
case 2:
|
||||
return "";
|
||||
}
|
||||
dictionary[3] = c;
|
||||
w = c;
|
||||
result.push(c);
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (data.index > length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, numBits);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
|
||||
switch ((c = bits)) {
|
||||
case 0:
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, 8);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
|
||||
dictionary[dictSize++] = fromCharCode(bits);
|
||||
c = dictSize - 1;
|
||||
enlargeIn--;
|
||||
break;
|
||||
case 1:
|
||||
bits = 0;
|
||||
maxpower = Math.pow(2, 16);
|
||||
power = 1;
|
||||
while (power != maxpower) {
|
||||
resb = data.val & data.position;
|
||||
data.position >>= 1;
|
||||
if (data.position == 0) {
|
||||
data.position = resetValue;
|
||||
data.val = getNextValue(data.index++);
|
||||
}
|
||||
bits |= (resb > 0 ? 1 : 0) * power;
|
||||
power <<= 1;
|
||||
}
|
||||
dictionary[dictSize++] = fromCharCode(bits);
|
||||
c = dictSize - 1;
|
||||
enlargeIn--;
|
||||
break;
|
||||
case 2:
|
||||
return result.join("");
|
||||
}
|
||||
|
||||
if (enlargeIn == 0) {
|
||||
enlargeIn = Math.pow(2, numBits);
|
||||
numBits++;
|
||||
}
|
||||
|
||||
if (dictionary[c]) {
|
||||
// @ts-ignore
|
||||
entry = dictionary[c];
|
||||
} else {
|
||||
if (c === dictSize) {
|
||||
entry = w + w.charAt(0);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
result.push(entry);
|
||||
|
||||
// Add w+entry[0] to the dictionary.
|
||||
dictionary[dictSize++] = w + entry.charAt(0);
|
||||
enlargeIn--;
|
||||
|
||||
w = entry;
|
||||
|
||||
if (enlargeIn == 0) {
|
||||
enlargeIn = Math.pow(2, numBits);
|
||||
numBits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/js/core/perlin_noise.js
Normal file
175
src/js/core/perlin_noise.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import { perlinNoiseData } from "./perlin_noise_data";
|
||||
import { Math_sqrt } from "./builtins";
|
||||
|
||||
class Grad {
|
||||
constructor(x, y, z) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.z = z;
|
||||
}
|
||||
|
||||
dot2(x, y) {
|
||||
return this.x * x + this.y * y;
|
||||
}
|
||||
|
||||
dot3(x, y, z) {
|
||||
return this.x * x + this.y * y + this.z * z;
|
||||
}
|
||||
}
|
||||
|
||||
function fade(t) {
|
||||
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return (1 - t) * a + t * b;
|
||||
}
|
||||
|
||||
const F2 = 0.5 * (Math_sqrt(3) - 1);
|
||||
const G2 = (3 - Math_sqrt(3)) / 6;
|
||||
|
||||
const F3 = 1 / 3;
|
||||
const G3 = 1 / 6;
|
||||
|
||||
export class PerlinNoise {
|
||||
constructor(seed) {
|
||||
this.perm = new Array(512);
|
||||
this.gradP = new Array(512);
|
||||
this.grad3 = [
|
||||
new Grad(1, 1, 0),
|
||||
new Grad(-1, 1, 0),
|
||||
new Grad(1, -1, 0),
|
||||
new Grad(-1, -1, 0),
|
||||
new Grad(1, 0, 1),
|
||||
new Grad(-1, 0, 1),
|
||||
new Grad(1, 0, -1),
|
||||
new Grad(-1, 0, -1),
|
||||
new Grad(0, 1, 1),
|
||||
new Grad(0, -1, 1),
|
||||
new Grad(0, 1, -1),
|
||||
new Grad(0, -1, -1),
|
||||
];
|
||||
|
||||
this.seed = seed;
|
||||
this.initializeFromSeed(seed);
|
||||
}
|
||||
|
||||
initializeFromSeed(seed) {
|
||||
const P = perlinNoiseData;
|
||||
|
||||
if (seed > 0 && seed < 1) {
|
||||
// Scale the seed out
|
||||
seed *= 65536;
|
||||
}
|
||||
|
||||
seed = Math.floor(seed);
|
||||
if (seed < 256) {
|
||||
seed |= seed << 8;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let v;
|
||||
if (i & 1) {
|
||||
v = P[i] ^ (seed & 255);
|
||||
} else {
|
||||
v = P[i] ^ ((seed >> 8) & 255);
|
||||
}
|
||||
|
||||
this.perm[i] = this.perm[i + 256] = v;
|
||||
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2d Perlin Noise
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {number}
|
||||
*/
|
||||
computePerlin2(x, y) {
|
||||
// Find unit grid cell containing point
|
||||
let X = Math.floor(x),
|
||||
Y = Math.floor(y);
|
||||
|
||||
// Get relative xy coordinates of point within that cell
|
||||
x = x - X;
|
||||
y = y - Y;
|
||||
|
||||
// Wrap the integer cells at 255 (smaller integer period can be introduced here)
|
||||
X = X & 255;
|
||||
Y = Y & 255;
|
||||
|
||||
// Calculate noise contributions from each of the four corners
|
||||
let n00 = this.gradP[X + this.perm[Y]].dot2(x, y);
|
||||
let n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1);
|
||||
let n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y);
|
||||
let n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1);
|
||||
|
||||
// Compute the fade curve value for x
|
||||
let u = fade(x);
|
||||
|
||||
// Interpolate the four results
|
||||
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), fade(y));
|
||||
}
|
||||
|
||||
computeSimplex2(xin, yin) {
|
||||
var n0, n1, n2; // Noise contributions from the three corners
|
||||
// Skew the input space to determine which simplex cell we're in
|
||||
var s = (xin + yin) * F2; // Hairy factor for 2D
|
||||
var i = Math.floor(xin + s);
|
||||
var j = Math.floor(yin + s);
|
||||
var t = (i + j) * G2;
|
||||
var x0 = xin - i + t; // The x,y distances from the cell origin, unskewed.
|
||||
var y0 = yin - j + t;
|
||||
// For the 2D case, the simplex shape is an equilateral triangle.
|
||||
// Determine which simplex we are in.
|
||||
var i1, j1; // Offsets for second (middle) corner of simplex in (i,j) coords
|
||||
if (x0 > y0) {
|
||||
// lower triangle, XY order: (0,0)->(1,0)->(1,1)
|
||||
i1 = 1;
|
||||
j1 = 0;
|
||||
} else {
|
||||
// upper triangle, YX order: (0,0)->(0,1)->(1,1)
|
||||
i1 = 0;
|
||||
j1 = 1;
|
||||
}
|
||||
// A step of (1,0) in (i,j) means a step of (1-c,-c) in (x,y), and
|
||||
// a step of (0,1) in (i,j) means a step of (-c,1-c) in (x,y), where
|
||||
// c = (3-sqrt(3))/6
|
||||
var x1 = x0 - i1 + G2; // Offsets for middle corner in (x,y) unskewed coords
|
||||
var y1 = y0 - j1 + G2;
|
||||
var x2 = x0 - 1 + 2 * G2; // Offsets for last corner in (x,y) unskewed coords
|
||||
var y2 = y0 - 1 + 2 * G2;
|
||||
// Work out the hashed gradient indices of the three simplex corners
|
||||
i &= 255;
|
||||
j &= 255;
|
||||
var gi0 = this.gradP[i + this.perm[j]];
|
||||
var gi1 = this.gradP[i + i1 + this.perm[j + j1]];
|
||||
var gi2 = this.gradP[i + 1 + this.perm[j + 1]];
|
||||
// Calculate the contribution from the three corners
|
||||
var t0 = 0.5 - x0 * x0 - y0 * y0;
|
||||
if (t0 < 0) {
|
||||
n0 = 0;
|
||||
} else {
|
||||
t0 *= t0;
|
||||
n0 = t0 * t0 * gi0.dot2(x0, y0); // (x,y) of grad3 used for 2D gradient
|
||||
}
|
||||
var t1 = 0.5 - x1 * x1 - y1 * y1;
|
||||
if (t1 < 0) {
|
||||
n1 = 0;
|
||||
} else {
|
||||
t1 *= t1;
|
||||
n1 = t1 * t1 * gi1.dot2(x1, y1);
|
||||
}
|
||||
var t2 = 0.5 - x2 * x2 - y2 * y2;
|
||||
if (t2 < 0) {
|
||||
n2 = 0;
|
||||
} else {
|
||||
t2 *= t2;
|
||||
n2 = t2 * t2 * gi2.dot2(x2, y2);
|
||||
}
|
||||
// Add contributions from each corner to get the final noise value.
|
||||
// The result is scaled to return values in the interval [-1,1].
|
||||
return 70 * (n0 + n1 + n2);
|
||||
}
|
||||
}
|
||||
258
src/js/core/perlin_noise_data.js
Normal file
258
src/js/core/perlin_noise_data.js
Normal file
@@ -0,0 +1,258 @@
|
||||
export const perlinNoiseData = [
|
||||
151,
|
||||
160,
|
||||
137,
|
||||
91,
|
||||
90,
|
||||
15,
|
||||
131,
|
||||
13,
|
||||
201,
|
||||
95,
|
||||
96,
|
||||
53,
|
||||
194,
|
||||
233,
|
||||
7,
|
||||
225,
|
||||
140,
|
||||
36,
|
||||
103,
|
||||
30,
|
||||
69,
|
||||
142,
|
||||
8,
|
||||
99,
|
||||
37,
|
||||
240,
|
||||
21,
|
||||
10,
|
||||
23,
|
||||
190,
|
||||
6,
|
||||
148,
|
||||
247,
|
||||
120,
|
||||
234,
|
||||
75,
|
||||
0,
|
||||
26,
|
||||
197,
|
||||
62,
|
||||
94,
|
||||
252,
|
||||
219,
|
||||
203,
|
||||
117,
|
||||
35,
|
||||
11,
|
||||
32,
|
||||
57,
|
||||
177,
|
||||
33,
|
||||
88,
|
||||
237,
|
||||
149,
|
||||
56,
|
||||
87,
|
||||
174,
|
||||
20,
|
||||
125,
|
||||
136,
|
||||
171,
|
||||
168,
|
||||
68,
|
||||
175,
|
||||
74,
|
||||
165,
|
||||
71,
|
||||
134,
|
||||
139,
|
||||
48,
|
||||
27,
|
||||
166,
|
||||
77,
|
||||
146,
|
||||
158,
|
||||
231,
|
||||
83,
|
||||
111,
|
||||
229,
|
||||
122,
|
||||
60,
|
||||
211,
|
||||
133,
|
||||
230,
|
||||
220,
|
||||
105,
|
||||
92,
|
||||
41,
|
||||
55,
|
||||
46,
|
||||
245,
|
||||
40,
|
||||
244,
|
||||
102,
|
||||
143,
|
||||
54,
|
||||
65,
|
||||
25,
|
||||
63,
|
||||
161,
|
||||
1,
|
||||
216,
|
||||
80,
|
||||
73,
|
||||
209,
|
||||
76,
|
||||
132,
|
||||
187,
|
||||
208,
|
||||
89,
|
||||
18,
|
||||
169,
|
||||
200,
|
||||
196,
|
||||
135,
|
||||
130,
|
||||
116,
|
||||
188,
|
||||
159,
|
||||
86,
|
||||
164,
|
||||
100,
|
||||
109,
|
||||
198,
|
||||
173,
|
||||
186,
|
||||
3,
|
||||
64,
|
||||
52,
|
||||
217,
|
||||
226,
|
||||
250,
|
||||
124,
|
||||
123,
|
||||
5,
|
||||
202,
|
||||
38,
|
||||
147,
|
||||
118,
|
||||
126,
|
||||
255,
|
||||
82,
|
||||
85,
|
||||
212,
|
||||
207,
|
||||
206,
|
||||
59,
|
||||
227,
|
||||
47,
|
||||
16,
|
||||
58,
|
||||
17,
|
||||
182,
|
||||
189,
|
||||
28,
|
||||
42,
|
||||
223,
|
||||
183,
|
||||
170,
|
||||
213,
|
||||
119,
|
||||
248,
|
||||
152,
|
||||
2,
|
||||
44,
|
||||
154,
|
||||
163,
|
||||
70,
|
||||
221,
|
||||
153,
|
||||
101,
|
||||
155,
|
||||
167,
|
||||
43,
|
||||
172,
|
||||
9,
|
||||
129,
|
||||
22,
|
||||
39,
|
||||
253,
|
||||
19,
|
||||
98,
|
||||
108,
|
||||
110,
|
||||
79,
|
||||
113,
|
||||
224,
|
||||
232,
|
||||
178,
|
||||
185,
|
||||
112,
|
||||
104,
|
||||
218,
|
||||
246,
|
||||
97,
|
||||
228,
|
||||
251,
|
||||
34,
|
||||
242,
|
||||
193,
|
||||
238,
|
||||
210,
|
||||
144,
|
||||
12,
|
||||
191,
|
||||
179,
|
||||
162,
|
||||
241,
|
||||
81,
|
||||
51,
|
||||
145,
|
||||
235,
|
||||
249,
|
||||
14,
|
||||
239,
|
||||
107,
|
||||
49,
|
||||
192,
|
||||
214,
|
||||
31,
|
||||
181,
|
||||
199,
|
||||
106,
|
||||
157,
|
||||
184,
|
||||
84,
|
||||
204,
|
||||
176,
|
||||
115,
|
||||
121,
|
||||
50,
|
||||
45,
|
||||
127,
|
||||
4,
|
||||
150,
|
||||
254,
|
||||
138,
|
||||
236,
|
||||
205,
|
||||
93,
|
||||
222,
|
||||
114,
|
||||
67,
|
||||
29,
|
||||
24,
|
||||
72,
|
||||
243,
|
||||
141,
|
||||
128,
|
||||
195,
|
||||
78,
|
||||
66,
|
||||
215,
|
||||
61,
|
||||
156,
|
||||
180,
|
||||
];
|
||||
69
src/js/core/polyfills.js
Normal file
69
src/js/core/polyfills.js
Normal file
@@ -0,0 +1,69 @@
|
||||
function mathPolyfills() {
|
||||
// Converts from degrees to radians.
|
||||
Math.radians = function (degrees) {
|
||||
return (degrees * Math_PI) / 180.0;
|
||||
};
|
||||
|
||||
// Converts from radians to degrees.
|
||||
Math.degrees = function (radians) {
|
||||
return (radians * 180.0) / Math_PI;
|
||||
};
|
||||
}
|
||||
|
||||
function stringPolyfills() {
|
||||
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart
|
||||
if (!String.prototype.padStart) {
|
||||
String.prototype.padStart = function padStart(targetLength, padString) {
|
||||
targetLength = targetLength >> 0; //truncate if number, or convert non-number to 0;
|
||||
padString = String(typeof padString !== "undefined" ? padString : " ");
|
||||
if (this.length >= targetLength) {
|
||||
return String(this);
|
||||
} else {
|
||||
targetLength = targetLength - this.length;
|
||||
if (targetLength > padString.length) {
|
||||
padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed
|
||||
}
|
||||
return padString.slice(0, targetLength) + String(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padEnd
|
||||
if (!String.prototype.padEnd) {
|
||||
String.prototype.padEnd = function padEnd(targetLength, padString) {
|
||||
targetLength = targetLength >> 0; //floor if number or convert non-number to 0;
|
||||
padString = String(typeof padString !== "undefined" ? padString : " ");
|
||||
if (this.length > targetLength) {
|
||||
return String(this);
|
||||
} else {
|
||||
targetLength = targetLength - this.length;
|
||||
if (targetLength > padString.length) {
|
||||
padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed
|
||||
}
|
||||
return String(this) + padString.slice(0, targetLength);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function initPolyfills() {
|
||||
mathPolyfills();
|
||||
stringPolyfills();
|
||||
}
|
||||
|
||||
function initExtensions() {
|
||||
String.prototype.replaceAll = function (search, replacement) {
|
||||
var target = this;
|
||||
return target.split(search).join(replacement);
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch polyfill
|
||||
import "whatwg-fetch";
|
||||
import { Math_PI } from "./builtins";
|
||||
|
||||
// Other polyfills
|
||||
initPolyfills();
|
||||
initExtensions();
|
||||
10
src/js/core/query_parameters.js
Normal file
10
src/js/core/query_parameters.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const queryString = require("query-string");
|
||||
const options = queryString.parse(location.search);
|
||||
|
||||
export let queryParamOptions = {
|
||||
embedProvider: null,
|
||||
};
|
||||
|
||||
if (options.embed) {
|
||||
queryParamOptions.embedProvider = options.embed;
|
||||
}
|
||||
300
src/js/core/read_write_proxy.js
Normal file
300
src/js/core/read_write_proxy.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { sha1 } from "./sensitive_utils.encrypt";
|
||||
import { createLogger } from "./logging";
|
||||
import { FILE_NOT_FOUND } from "../platform/storage";
|
||||
import { accessNestedPropertyReverse } from "./utils";
|
||||
import { IS_DEBUG, globalConfig } from "./config";
|
||||
import { JSON_stringify, JSON_parse } from "./builtins";
|
||||
import { ExplainedResult } from "./explained_result";
|
||||
import { decompressX64, compressX64 } from ".//lzstring";
|
||||
import { asyncCompressor, compressionPrefix } from "./async_compression";
|
||||
|
||||
const logger = createLogger("read_write_proxy");
|
||||
|
||||
const salt = accessNestedPropertyReverse(globalConfig, ["file", "info"]);
|
||||
|
||||
// Helper which only writes / reads if verify() works. Also performs migration
|
||||
export class ReadWriteProxy {
|
||||
constructor(app, filename) {
|
||||
/** @type {Application} */
|
||||
this.app = app;
|
||||
|
||||
this.filename = filename;
|
||||
|
||||
/** @type {object} */
|
||||
this.currentData = null;
|
||||
|
||||
// TODO: EXTREMELY HACKY! To verify we need to do this a step later
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
setTimeout(() => {
|
||||
assert(
|
||||
this.verify(this.getDefaultData()).result,
|
||||
"Verify() failed for default data: " + this.verify(this.getDefaultData()).reason
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- Methods to override
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
verify(data) {
|
||||
abstract;
|
||||
return ExplainedResult.bad();
|
||||
}
|
||||
|
||||
// Should return the default data
|
||||
getDefaultData() {
|
||||
abstract;
|
||||
return {};
|
||||
}
|
||||
|
||||
// Should return the current version as an integer
|
||||
getCurrentVersion() {
|
||||
abstract;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Should migrate the data (Modify in place)
|
||||
/** @returns {ExplainedResult} */
|
||||
migrate(data) {
|
||||
abstract;
|
||||
return ExplainedResult.bad();
|
||||
}
|
||||
|
||||
// -- / Methods
|
||||
|
||||
// Resets whole data, returns promise
|
||||
resetEverythingAsync() {
|
||||
logger.warn("Reset data to default");
|
||||
this.currentData = this.getDefaultData();
|
||||
return this.writeAsync();
|
||||
}
|
||||
|
||||
getCurrentData() {
|
||||
return this.currentData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the data asychronously, fails if verify() fails
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
writeAsync() {
|
||||
const verifyResult = this.internalVerifyEntry(this.currentData);
|
||||
|
||||
if (!verifyResult.result) {
|
||||
logger.error("Tried to write invalid data to", this.filename, "reason:", verifyResult.reason);
|
||||
return Promise.reject(verifyResult.reason);
|
||||
}
|
||||
const jsonString = JSON_stringify(this.currentData);
|
||||
|
||||
if (!this.app.pageVisible || this.app.unloaded) {
|
||||
logger.log("Saving file sync because in unload handler");
|
||||
const checksum = sha1(jsonString + salt);
|
||||
let compressed = compressionPrefix + compressX64(checksum + jsonString);
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
compressed = jsonString;
|
||||
}
|
||||
|
||||
if (!this.app.storage.writeFileSyncIfSupported(this.filename, compressed)) {
|
||||
return Promise.reject("Failed to write " + this.filename + " sync!");
|
||||
} else {
|
||||
logger.log("📄 Wrote (sync!)", this.filename);
|
||||
return Promise.resolve(compressed);
|
||||
}
|
||||
}
|
||||
|
||||
return asyncCompressor
|
||||
.compressFileAsync(jsonString)
|
||||
.then(compressed => {
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
compressed = jsonString;
|
||||
}
|
||||
return this.app.storage.writeFileAsync(this.filename, compressed);
|
||||
})
|
||||
.then(() => {
|
||||
logger.log("📄 Wrote", this.filename);
|
||||
return jsonString;
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error("Failed to write", this.filename, ":", err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// Reads the data asynchronously, fails if verify() fails
|
||||
readAsync() {
|
||||
// Start read request
|
||||
return (
|
||||
this.app.storage
|
||||
.readFileAsync(this.filename)
|
||||
|
||||
// Check for errors during read
|
||||
.catch(err => {
|
||||
if (err === FILE_NOT_FOUND) {
|
||||
logger.log("File not found, using default data");
|
||||
|
||||
// File not found or unreadable, assume default file
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return Promise.reject("file-error: " + err);
|
||||
})
|
||||
|
||||
// Decrypt data (if its encrypted)
|
||||
// @ts-ignore
|
||||
.then(rawData => {
|
||||
if (rawData == null) {
|
||||
// So, the file has not been found, use default data
|
||||
return JSON_stringify(this.getDefaultData());
|
||||
}
|
||||
|
||||
if (rawData.startsWith(compressionPrefix)) {
|
||||
const decompressed = decompressX64(rawData.substr(compressionPrefix.length));
|
||||
if (!decompressed) {
|
||||
// LZ string decompression failure
|
||||
return Promise.reject("bad-content / decompression-failed");
|
||||
}
|
||||
if (decompressed.length < 40) {
|
||||
// String too short
|
||||
return Promise.reject("bad-content / payload-too-small");
|
||||
}
|
||||
|
||||
// Compare stored checksum with actual checksum
|
||||
const checksum = decompressed.substring(0, 40);
|
||||
const jsonString = decompressed.substr(40);
|
||||
const desiredChecksum = sha1(jsonString + salt);
|
||||
if (desiredChecksum !== checksum) {
|
||||
// Checksum mismatch
|
||||
return Promise.reject("bad-content / checksum-mismatch");
|
||||
}
|
||||
return jsonString;
|
||||
} else {
|
||||
if (!G_IS_DEV) {
|
||||
return Promise.reject("bad-content / missing-compression");
|
||||
}
|
||||
}
|
||||
return rawData;
|
||||
})
|
||||
|
||||
// Parse JSON, this could throw but thats fine
|
||||
.then(res => {
|
||||
try {
|
||||
return JSON_parse(res);
|
||||
} catch (ex) {
|
||||
logger.error(
|
||||
"Failed to parse file content of",
|
||||
this.filename,
|
||||
":",
|
||||
ex,
|
||||
"(content was:",
|
||||
res,
|
||||
")"
|
||||
);
|
||||
throw new Error("invalid-serialized-data");
|
||||
}
|
||||
})
|
||||
|
||||
// Verify basic structure
|
||||
.then(contents => {
|
||||
const result = this.internalVerifyBasicStructure(contents);
|
||||
if (!result.isGood()) {
|
||||
return Promise.reject("verify-failed: " + result.reason);
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Check version and migrate if required
|
||||
.then(contents => {
|
||||
if (contents.version > this.getCurrentVersion()) {
|
||||
return Promise.reject("stored-data-is-newer");
|
||||
}
|
||||
|
||||
if (contents.version < this.getCurrentVersion()) {
|
||||
logger.log(
|
||||
"Trying to migrate data object from version",
|
||||
contents.version,
|
||||
"to",
|
||||
this.getCurrentVersion()
|
||||
);
|
||||
const migrationResult = this.migrate(contents); // modify in place
|
||||
if (migrationResult.isBad()) {
|
||||
return Promise.reject("migration-failed: " + migrationResult.reason);
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Verify
|
||||
.then(contents => {
|
||||
const verifyResult = this.internalVerifyEntry(contents);
|
||||
if (!verifyResult.result) {
|
||||
logger.error(
|
||||
"Read invalid data from",
|
||||
this.filename,
|
||||
"reason:",
|
||||
verifyResult.reason,
|
||||
"contents:",
|
||||
contents
|
||||
);
|
||||
return Promise.reject("invalid-data: " + verifyResult.reason);
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Store
|
||||
.then(contents => {
|
||||
this.currentData = contents;
|
||||
logger.log("📄 Read data with version", this.currentData.version, "from", this.filename);
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Catchall
|
||||
.catch(err => {
|
||||
return Promise.reject("Failed to read " + this.filename + ": " + err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
deleteAsync() {
|
||||
return this.app.storage.deleteFileAsync(this.filename);
|
||||
}
|
||||
|
||||
// Internal
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
internalVerifyBasicStructure(data) {
|
||||
if (!data) {
|
||||
return ExplainedResult.bad("Data is empty");
|
||||
}
|
||||
if (!Number.isInteger(data.version) || data.version < 0) {
|
||||
return ExplainedResult.bad(
|
||||
`Data has invalid version: ${data.version} (expected ${this.getCurrentVersion()})`
|
||||
);
|
||||
}
|
||||
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
internalVerifyEntry(data) {
|
||||
if (data.version !== this.getCurrentVersion()) {
|
||||
return ExplainedResult.bad(
|
||||
"Version mismatch, got " + data.version + " and expected " + this.getCurrentVersion()
|
||||
);
|
||||
}
|
||||
|
||||
const verifyStructureError = this.internalVerifyBasicStructure(data);
|
||||
if (!verifyStructureError.isGood()) {
|
||||
return verifyStructureError;
|
||||
}
|
||||
return this.verify(data);
|
||||
}
|
||||
}
|
||||
287
src/js/core/rectangle.js
Normal file
287
src/js/core/rectangle.js
Normal file
@@ -0,0 +1,287 @@
|
||||
import { globalConfig } from "./config";
|
||||
import { Math_ceil, Math_floor, Math_max, Math_min } from "./builtins";
|
||||
import { clamp, epsilonCompare, round2Digits } from "./utils";
|
||||
import { Vector } from "./vector";
|
||||
|
||||
export class Rectangle {
|
||||
constructor(x = 0, y = 0, w = 0, h = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a rectangle from top right bottom and left offsets
|
||||
* @param {number} top
|
||||
* @param {number} right
|
||||
* @param {number} bottom
|
||||
* @param {number} left
|
||||
*/
|
||||
static fromTRBL(top, right, bottom, left) {
|
||||
return new Rectangle(left, top, right - left, bottom - top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new square rectangle
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} size
|
||||
*/
|
||||
static fromSquare(x, y, size) {
|
||||
return new Rectangle(x, y, size, size);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Vector} p1
|
||||
* @param {Vector} p2
|
||||
*/
|
||||
static fromTwoPoints(p1, p2) {
|
||||
const left = Math_min(p1.x, p2.x);
|
||||
const top = Math_min(p1.y, p2.y);
|
||||
const right = Math_max(p1.x, p2.x);
|
||||
const bottom = Math_max(p1.y, p2.y);
|
||||
return new Rectangle(left, top, right - left, bottom - top);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Rectangle} a
|
||||
* @param {Rectangle} b
|
||||
*/
|
||||
static intersects(a, b) {
|
||||
return a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a rectangle arround a rotated point
|
||||
* @param {Array<Vector>} points
|
||||
* @param {number} angle
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
static getAroundPointsRotated(points, angle) {
|
||||
let minX = 1e10;
|
||||
let minY = 1e10;
|
||||
let maxX = -1e10;
|
||||
let maxY = -1e10;
|
||||
for (let i = 0; i < points.length; ++i) {
|
||||
const rotated = points[i].rotated(angle);
|
||||
minX = Math_min(minX, rotated.x);
|
||||
minY = Math_min(minY, rotated.y);
|
||||
maxX = Math_max(maxX, rotated.x);
|
||||
maxY = Math_max(maxY, rotated.y);
|
||||
}
|
||||
return new Rectangle(minX, minY, maxX - minX, maxY - minY);
|
||||
}
|
||||
|
||||
// Ensures the rectangle contains the given square
|
||||
extendBySquare(centerX, centerY, halfWidth, halfHeight) {
|
||||
if (this.isEmpty()) {
|
||||
// Just assign values since this rectangle is empty
|
||||
this.x = centerX - halfWidth;
|
||||
this.y = centerY - halfHeight;
|
||||
this.w = halfWidth * 2;
|
||||
this.h = halfHeight * 2;
|
||||
// console.log("Assigned", this.x, this.y, this.w, this.h);
|
||||
} else {
|
||||
// console.log("before", this.x, this.y, this.w, this.h);
|
||||
this.setLeft(Math_min(this.x, centerX - halfWidth));
|
||||
this.setRight(Math_max(this.right(), centerX + halfWidth));
|
||||
this.setTop(Math_min(this.y, centerY - halfHeight));
|
||||
this.setBottom(Math_max(this.bottom(), centerY + halfHeight));
|
||||
// console.log("Extended", this.x, this.y, this.w, this.h);
|
||||
}
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return epsilonCompare(this.w * this.h, 0);
|
||||
}
|
||||
|
||||
equalsEpsilon(other, epsilon) {
|
||||
return (
|
||||
epsilonCompare(this.x, other.x, epsilon) &&
|
||||
epsilonCompare(this.y, other.y, epsilon) &&
|
||||
epsilonCompare(this.w, other.w, epsilon) &&
|
||||
epsilonCompare(this.h, other.h, epsilon)
|
||||
);
|
||||
}
|
||||
|
||||
left() {
|
||||
return this.x;
|
||||
}
|
||||
|
||||
right() {
|
||||
return this.x + this.w;
|
||||
}
|
||||
|
||||
top() {
|
||||
return this.y;
|
||||
}
|
||||
|
||||
bottom() {
|
||||
return this.y + this.h;
|
||||
}
|
||||
|
||||
trbl() {
|
||||
return [this.y, this.right(), this.bottom(), this.x];
|
||||
}
|
||||
|
||||
getCenter() {
|
||||
return new Vector(this.x + this.w / 2, this.y + this.h / 2);
|
||||
}
|
||||
|
||||
setRight(right) {
|
||||
this.w = right - this.x;
|
||||
}
|
||||
|
||||
setBottom(bottom) {
|
||||
this.h = bottom - this.y;
|
||||
}
|
||||
|
||||
// Sets top while keeping bottom
|
||||
setTop(top) {
|
||||
const bottom = this.bottom();
|
||||
this.y = top;
|
||||
this.setBottom(bottom);
|
||||
}
|
||||
|
||||
// Sets left while keeping right
|
||||
setLeft(left) {
|
||||
const right = this.right();
|
||||
this.x = left;
|
||||
this.setRight(right);
|
||||
}
|
||||
|
||||
topLeft() {
|
||||
return new Vector(this.x, this.y);
|
||||
}
|
||||
|
||||
bottomRight() {
|
||||
return new Vector(this.right(), this.bottom());
|
||||
}
|
||||
|
||||
moveBy(x, y) {
|
||||
this.x += x;
|
||||
this.y += y;
|
||||
}
|
||||
|
||||
moveByVector(vec) {
|
||||
this.x += vec.x;
|
||||
this.y += vec.y;
|
||||
}
|
||||
|
||||
// Returns a scaled version which also scales the position of the rectangle
|
||||
allScaled(factor) {
|
||||
return new Rectangle(this.x * factor, this.y * factor, this.w * factor, this.h * factor);
|
||||
}
|
||||
|
||||
// Increases the rectangle in all directions
|
||||
expandInAllDirections(amount) {
|
||||
this.x -= amount;
|
||||
this.y -= amount;
|
||||
this.w += 2 * amount;
|
||||
this.h += 2 * amount;
|
||||
return this;
|
||||
}
|
||||
|
||||
// Culling helpers
|
||||
getMinStartTile() {
|
||||
return new Vector(this.x, this.y).snapWorldToTile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the given rectangle is contained
|
||||
* @param {Rectangle} rect
|
||||
* @returns {boolean}
|
||||
*/
|
||||
containsRect(rect) {
|
||||
return (
|
||||
this.x <= rect.right() &&
|
||||
rect.x <= this.right() &&
|
||||
this.y <= rect.bottom() &&
|
||||
rect.y <= this.bottom()
|
||||
);
|
||||
}
|
||||
|
||||
containsRect4Params(x, y, w, h) {
|
||||
return this.x <= x + w && x <= this.right() && this.y <= y + h && y <= this.bottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the rectangle contains the given circle at (x, y) with the radius (radius)
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} radius
|
||||
*/
|
||||
containsCircle(x, y, radius) {
|
||||
return (
|
||||
this.x <= x + radius &&
|
||||
x - radius <= this.right() &&
|
||||
this.y <= y + radius &&
|
||||
y - radius <= this.bottom()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if hte rectangle contains the given point
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
containsPoint(x, y) {
|
||||
return x >= this.x && x < this.right() && y >= this.y && y < this.bottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shared area with another rectangle, or null if there is no intersection
|
||||
* @param {Rectangle} rect
|
||||
* @returns {Rectangle|null}
|
||||
*/
|
||||
getUnion(rect) {
|
||||
const left = Math_max(this.x, rect.x);
|
||||
const top = Math_max(this.y, rect.y);
|
||||
|
||||
const right = Math_min(this.x + this.w, rect.x + rect.w);
|
||||
const bottom = Math_min(this.y + this.h, rect.y + rect.h);
|
||||
|
||||
if (right <= left || bottom <= top) {
|
||||
return null;
|
||||
}
|
||||
return Rectangle.fromTRBL(top, right, bottom, left);
|
||||
}
|
||||
|
||||
/**
|
||||
* Good for caching stuff
|
||||
*/
|
||||
toCompareableString() {
|
||||
return (
|
||||
round2Digits(this.x) +
|
||||
"/" +
|
||||
round2Digits(this.y) +
|
||||
"/" +
|
||||
round2Digits(this.w) +
|
||||
"/" +
|
||||
round2Digits(this.h)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new recangle in tile space which includes all tiles which are visible in this rect
|
||||
* @param {boolean=} includeHalfTiles
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
toTileCullRectangle(includeHalfTiles = true) {
|
||||
let scaled = this.allScaled(1.0 / globalConfig.tileSize);
|
||||
|
||||
if (includeHalfTiles) {
|
||||
// Increase rectangle size
|
||||
scaled = Rectangle.fromTRBL(
|
||||
Math_floor(scaled.y),
|
||||
Math_ceil(scaled.right()),
|
||||
Math_ceil(scaled.bottom()),
|
||||
Math_floor(scaled.x)
|
||||
);
|
||||
}
|
||||
|
||||
return scaled;
|
||||
}
|
||||
}
|
||||
72
src/js/core/request_channel.js
Normal file
72
src/js/core/request_channel.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createLogger } from "../core/logging";
|
||||
import { fastArrayDeleteValueIfContained } from "../core/utils";
|
||||
|
||||
const logger = createLogger("request_channel");
|
||||
|
||||
// Thrown when a request is aborted
|
||||
export const PROMISE_ABORTED = "promise-aborted";
|
||||
|
||||
export class RequestChannel {
|
||||
constructor() {
|
||||
/** @type {Array<Promise>} */
|
||||
this.pendingPromises = [];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Promise<any>} promise
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
watch(promise) {
|
||||
// log(this, "Added new promise:", promise, "(pending =", this.pendingPromises.length, ")");
|
||||
let cancelled = false;
|
||||
const wrappedPromise = new Promise((resolve, reject) => {
|
||||
promise.then(
|
||||
result => {
|
||||
// Remove from pending promises
|
||||
fastArrayDeleteValueIfContained(this.pendingPromises, wrappedPromise);
|
||||
|
||||
// If not cancelled, resolve promise with same payload
|
||||
if (!cancelled) {
|
||||
resolve.call(this, result);
|
||||
} else {
|
||||
logger.warn("Not resolving because promise got cancelled");
|
||||
// reject.call(this, PROMISE_ABORTED);
|
||||
}
|
||||
},
|
||||
err => {
|
||||
// Remove from pending promises
|
||||
fastArrayDeleteValueIfContained(this.pendingPromises, wrappedPromise);
|
||||
|
||||
// If not cancelled, reject promise with same payload
|
||||
if (!cancelled) {
|
||||
reject.call(this, err);
|
||||
} else {
|
||||
logger.warn("Not rejecting because promise got cancelled");
|
||||
// reject.call(this, PROMISE_ABORTED);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Add cancel handler
|
||||
// @ts-ignore
|
||||
wrappedPromise.cancel = function () {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
this.pendingPromises.push(wrappedPromise);
|
||||
return wrappedPromise;
|
||||
}
|
||||
|
||||
cancelAll() {
|
||||
if (this.pendingPromises.length > 0) {
|
||||
logger.log("Cancel all pending promises (", this.pendingPromises.length, ")");
|
||||
}
|
||||
for (let i = 0; i < this.pendingPromises.length; ++i) {
|
||||
// @ts-ignore
|
||||
this.pendingPromises[i].cancel();
|
||||
}
|
||||
this.pendingPromises = [];
|
||||
}
|
||||
}
|
||||
133
src/js/core/rng.js
Normal file
133
src/js/core/rng.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Math_random } from "./builtins";
|
||||
|
||||
// ALEA RNG
|
||||
|
||||
function Mash() {
|
||||
var n = 0xefc8249d;
|
||||
return function (data) {
|
||||
data = data.toString();
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
n += data.charCodeAt(i);
|
||||
var h = 0.02519603282416938 * n;
|
||||
n = h >>> 0;
|
||||
h -= n;
|
||||
h *= n;
|
||||
n = h >>> 0;
|
||||
h -= n;
|
||||
n += h * 0x100000000; // 2^32
|
||||
}
|
||||
return (n >>> 0) * 2.3283064365386963e-10; // 2^-32
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|string} seed
|
||||
*/
|
||||
function makeNewRng(seed) {
|
||||
// Johannes Baagøe <baagoe@baagoe.com>, 2010
|
||||
var c = 1;
|
||||
var mash = Mash();
|
||||
let s0 = mash(" ");
|
||||
let s1 = mash(" ");
|
||||
let s2 = mash(" ");
|
||||
|
||||
s0 -= mash(seed);
|
||||
if (s0 < 0) {
|
||||
s0 += 1;
|
||||
}
|
||||
s1 -= mash(seed);
|
||||
if (s1 < 0) {
|
||||
s1 += 1;
|
||||
}
|
||||
s2 -= mash(seed);
|
||||
if (s2 < 0) {
|
||||
s2 += 1;
|
||||
}
|
||||
mash = null;
|
||||
|
||||
var random = function () {
|
||||
var t = 2091639 * s0 + c * 2.3283064365386963e-10; // 2^-32
|
||||
s0 = s1;
|
||||
s1 = s2;
|
||||
return (s2 = t - (c = t | 0));
|
||||
};
|
||||
|
||||
random.exportState = function () {
|
||||
return [s0, s1, s2, c];
|
||||
};
|
||||
|
||||
random.importState = function (i) {
|
||||
s0 = +i[0] || 0;
|
||||
s1 = +i[1] || 0;
|
||||
s2 = +i[2] || 0;
|
||||
c = +i[3] || 0;
|
||||
};
|
||||
|
||||
return random;
|
||||
}
|
||||
|
||||
export class RandomNumberGenerator {
|
||||
/**
|
||||
*
|
||||
* @param {number|string=} seed
|
||||
*/
|
||||
constructor(seed) {
|
||||
this.internalRng = makeNewRng(seed || Math_random());
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seeds the generator
|
||||
* @param {number|string} seed
|
||||
*/
|
||||
reseed(seed) {
|
||||
this.internalRng = makeNewRng(seed || Math_random());
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number} between 0 and 1
|
||||
*/
|
||||
next() {
|
||||
return this.internalRng();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number} Integer in range [min, max[
|
||||
*/
|
||||
nextIntRange(min, max) {
|
||||
assert(Number.isFinite(min), "Minimum is no integer");
|
||||
assert(Number.isFinite(max), "Maximum is no integer");
|
||||
assert(max > min, "rng: max <= min");
|
||||
return Math.floor(this.next() * (max - min) + min);
|
||||
}
|
||||
/**
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number} Integer in range [min, max]
|
||||
*/
|
||||
nextIntRangeInclusive(min, max) {
|
||||
assert(Number.isFinite(min), "Minimum is no integer");
|
||||
assert(Number.isFinite(max), "Maximum is no integer");
|
||||
assert(max > min, "rng: max <= min");
|
||||
return Math.round(this.next() * (max - min) + min);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number} Number in range [min, max[
|
||||
*/
|
||||
nextRange(min, max) {
|
||||
assert(max > min, "rng: max <= min");
|
||||
return this.next() * (max - min) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the seed
|
||||
* @param {number} seed
|
||||
*/
|
||||
setSeed(seed) {
|
||||
this.internalRng = makeNewRng(seed);
|
||||
}
|
||||
}
|
||||
62
src/js/core/sensitive_utils.encrypt.js
Normal file
62
src/js/core/sensitive_utils.encrypt.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { globalConfig } from "./config";
|
||||
import { decompressX64, compressX64 } from "./lzstring";
|
||||
|
||||
const Rusha = require("rusha");
|
||||
|
||||
const encryptKey = globalConfig.info.sgSalt;
|
||||
|
||||
export function decodeHashedString(s) {
|
||||
return decompressX64(s);
|
||||
}
|
||||
|
||||
export function sha1(str) {
|
||||
return Rusha.createHash().update(str).digest("hex");
|
||||
}
|
||||
|
||||
// Window.location.host
|
||||
export function getNameOfProvider() {
|
||||
return window[decodeHashedString("DYewxghgLgliB2Q")][decodeHashedString("BYewzgLgdghgtgUyA")];
|
||||
}
|
||||
|
||||
export function compressWithChecksum(object) {
|
||||
const stringified = JSON.stringify(object);
|
||||
const checksum = Rusha.createHash()
|
||||
.update(stringified + encryptKey)
|
||||
.digest("hex");
|
||||
return compressX64(checksum + stringified);
|
||||
}
|
||||
|
||||
export function decompressWithChecksum(binary) {
|
||||
let decompressed = null;
|
||||
try {
|
||||
decompressed = decompressX64(binary);
|
||||
} catch (err) {
|
||||
throw new Error("failed-to-decompress");
|
||||
}
|
||||
|
||||
// Split into checksum and content
|
||||
if (!decompressed || decompressed.length < 41) {
|
||||
throw new Error("checksum-missing");
|
||||
}
|
||||
|
||||
const checksum = decompressed.substr(0, 40);
|
||||
const rawData = decompressed.substr(40);
|
||||
|
||||
// Validate checksum
|
||||
const computedChecksum = Rusha.createHash()
|
||||
.update(rawData + encryptKey)
|
||||
.digest("hex");
|
||||
if (computedChecksum !== checksum) {
|
||||
throw new Error("checksum-mismatch");
|
||||
}
|
||||
|
||||
// Try parsing the JSON
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
} catch (err) {
|
||||
throw new Error("failed-to-parse");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
66
src/js/core/signal.js
Normal file
66
src/js/core/signal.js
Normal file
@@ -0,0 +1,66 @@
|
||||
export const STOP_PROPAGATION = "stop_propagation";
|
||||
|
||||
export class Signal {
|
||||
constructor() {
|
||||
this.receivers = [];
|
||||
this.modifyCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new signal listener
|
||||
* @param {object} receiver
|
||||
* @param {object} scope
|
||||
*/
|
||||
add(receiver, scope = null) {
|
||||
assert(receiver, "receiver is null");
|
||||
this.receivers.push({ receiver, scope });
|
||||
++this.modifyCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the signal
|
||||
* @param {...any} payload
|
||||
*/
|
||||
dispatch() {
|
||||
const modifyState = this.modifyCount;
|
||||
|
||||
const n = this.receivers.length;
|
||||
for (let i = 0; i < n; ++i) {
|
||||
const { receiver, scope } = this.receivers[i];
|
||||
if (receiver.apply(scope, arguments) === STOP_PROPAGATION) {
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
|
||||
if (modifyState !== this.modifyCount) {
|
||||
// Signal got modified during iteration
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a receiver
|
||||
* @param {object} receiver
|
||||
*/
|
||||
remove(receiver) {
|
||||
let index = null;
|
||||
const n = this.receivers.length;
|
||||
for (let i = 0; i < n; ++i) {
|
||||
if (this.receivers[i].receiver === receiver) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert(index !== null, "Receiver not found in list");
|
||||
this.receivers.splice(index, 1);
|
||||
++this.modifyCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all receivers
|
||||
*/
|
||||
removeAll() {
|
||||
this.receivers = [];
|
||||
++this.modifyCount;
|
||||
}
|
||||
}
|
||||
78
src/js/core/singleton_factory.js
Normal file
78
src/js/core/singleton_factory.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// simple factory pattern
|
||||
export class SingletonFactory {
|
||||
constructor() {
|
||||
// Store array as well as dictionary, to speed up lookups
|
||||
this.entries = [];
|
||||
this.idToEntry = {};
|
||||
}
|
||||
|
||||
register(classHandle) {
|
||||
// First, construct instance
|
||||
const instance = new classHandle();
|
||||
|
||||
// Extract id
|
||||
const id = instance.getId();
|
||||
assert(id, "Factory: Invalid id for class " + classHandle.name + ": " + id);
|
||||
|
||||
// Check duplicates
|
||||
assert(!this.idToEntry[id], "Duplicate factory entry for " + id);
|
||||
|
||||
// Insert
|
||||
this.entries.push(instance);
|
||||
this.idToEntry[id] = instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given id is registered
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasId(id) {
|
||||
return !!this.idToEntry[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an instance by a given id
|
||||
* @param {string} id
|
||||
* @returns {object}
|
||||
*/
|
||||
findById(id) {
|
||||
const entry = this.idToEntry[id];
|
||||
if (!entry) {
|
||||
assert(false, "Factory: Object with id '" + id + "' is not registered!");
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an instance by its constructor (The class handle)
|
||||
* @param {object} classHandle
|
||||
* @returns {object}
|
||||
*/
|
||||
findByClass(classHandle) {
|
||||
for (let i = 0; i < this.entries.length; ++i) {
|
||||
if (this.entries[i] instanceof classHandle) {
|
||||
return this.entries[i];
|
||||
}
|
||||
}
|
||||
assert(false, "Factory: Object not found by classHandle (classid: " + classHandle.name + ")");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all entries
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getEntries() {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns amount of stored entries
|
||||
* @returns {number}
|
||||
*/
|
||||
getNumEntries() {
|
||||
return this.entries.length;
|
||||
}
|
||||
}
|
||||
351
src/js/core/sprites.js
Normal file
351
src/js/core/sprites.js
Normal file
@@ -0,0 +1,351 @@
|
||||
import { DrawParameters } from "./draw_parameters";
|
||||
import { Math_floor } from "./builtins";
|
||||
import { Rectangle } from "./rectangle";
|
||||
import { epsilonCompare, round3Digits } from "./utils";
|
||||
|
||||
const floorSpriteCoordinates = false;
|
||||
|
||||
const ORIGINAL_SCALE = "1";
|
||||
|
||||
export class BaseSprite {
|
||||
/**
|
||||
* Returns the raw handle
|
||||
* @returns {HTMLImageElement|HTMLCanvasElement}
|
||||
*/
|
||||
getRawTexture() {
|
||||
abstract;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sprite
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
draw(context, x, y, w, h) {
|
||||
// eslint-disable-line no-unused-vars
|
||||
abstract;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position of a sprite within an atlas
|
||||
*/
|
||||
export class SpriteAtlasLink {
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {number} param0.packedX
|
||||
* @param {number} param0.packedY
|
||||
* @param {number} param0.packOffsetX
|
||||
* @param {number} param0.packOffsetY
|
||||
* @param {number} param0.packedW
|
||||
* @param {number} param0.packedH
|
||||
* @param {number} param0.w
|
||||
* @param {number} param0.h
|
||||
* @param {HTMLImageElement|HTMLCanvasElement} param0.atlas
|
||||
*/
|
||||
constructor({ w, h, packedX, packedY, packOffsetX, packOffsetY, packedW, packedH, atlas }) {
|
||||
this.packedX = packedX;
|
||||
this.packedY = packedY;
|
||||
this.packedW = packedW;
|
||||
this.packedH = packedH;
|
||||
this.packOffsetX = packOffsetX;
|
||||
this.packOffsetY = packOffsetY;
|
||||
this.atlas = atlas;
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
}
|
||||
}
|
||||
|
||||
export class AtlasSprite extends BaseSprite {
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {string} param0.spriteName
|
||||
*/
|
||||
constructor({ spriteName = "sprite" }) {
|
||||
super();
|
||||
/** @type {Object.<string, SpriteAtlasLink>} */
|
||||
this.linksByResolution = {};
|
||||
this.spriteName = spriteName;
|
||||
}
|
||||
|
||||
getRawTexture() {
|
||||
return this.linksByResolution[ORIGINAL_SCALE].atlas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sprite onto a regular context using no contexts
|
||||
* @see {BaseSprite.draw}
|
||||
*/
|
||||
draw(context, x, y, w, h) {
|
||||
if (G_IS_DEV) {
|
||||
assert(context instanceof CanvasRenderingContext2D, "Not a valid context");
|
||||
}
|
||||
console.warn("drawing sprite regulary");
|
||||
|
||||
const link = this.linksByResolution[ORIGINAL_SCALE];
|
||||
|
||||
const width = w || link.w;
|
||||
const height = h || link.h;
|
||||
|
||||
const scaleW = width / link.w;
|
||||
const scaleH = height / link.h;
|
||||
|
||||
context.drawImage(
|
||||
link.atlas,
|
||||
|
||||
link.packedX,
|
||||
link.packedY,
|
||||
link.packedW,
|
||||
link.packedH,
|
||||
|
||||
x + link.packOffsetX * scaleW,
|
||||
y + link.packOffsetY * scaleH,
|
||||
link.packedW * scaleW,
|
||||
link.packedH * scaleH
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} size
|
||||
* @param {boolean=} clipping
|
||||
*/
|
||||
drawCachedCentered(parameters, x, y, size, clipping = true) {
|
||||
this.drawCached(parameters, x - size / 2, y - size / 2, size, size, clipping);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} size
|
||||
*/
|
||||
drawCentered(context, x, y, size) {
|
||||
this.draw(context, x - size / 2, y - size / 2, size, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sprite
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {boolean=} clipping Whether to perform culling
|
||||
*/
|
||||
drawCached(parameters, x, y, w = null, h = null, clipping = true) {
|
||||
if (G_IS_DEV) {
|
||||
assertAlways(parameters instanceof DrawParameters, "Not a valid context");
|
||||
assertAlways(!!w && w > 0, "Not a valid width:" + w);
|
||||
assertAlways(!!h && h > 0, "Not a valid height:" + h);
|
||||
}
|
||||
|
||||
const visibleRect = parameters.visibleRect;
|
||||
|
||||
const scale = parameters.desiredAtlasScale;
|
||||
const link = this.linksByResolution[scale];
|
||||
const scaleW = w / link.w;
|
||||
const scaleH = h / link.h;
|
||||
|
||||
let destX = x + link.packOffsetX * scaleW;
|
||||
let destY = y + link.packOffsetY * scaleH;
|
||||
let destW = link.packedW * scaleW;
|
||||
let destH = link.packedH * scaleH;
|
||||
|
||||
let srcX = link.packedX;
|
||||
let srcY = link.packedY;
|
||||
let srcW = link.packedW;
|
||||
let srcH = link.packedH;
|
||||
|
||||
let intersection = null;
|
||||
|
||||
if (clipping) {
|
||||
const rect = new Rectangle(destX, destY, destW, destH);
|
||||
intersection = rect.getUnion(visibleRect);
|
||||
if (!intersection) {
|
||||
return;
|
||||
}
|
||||
|
||||
srcX += (intersection.x - destX) / scaleW;
|
||||
srcY += (intersection.y - destY) / scaleH;
|
||||
|
||||
srcW *= intersection.w / destW;
|
||||
srcH *= intersection.h / destH;
|
||||
|
||||
destX = intersection.x;
|
||||
destY = intersection.y;
|
||||
|
||||
destW = intersection.w;
|
||||
destH = intersection.h;
|
||||
}
|
||||
|
||||
// assert(epsilonCompare(scaleW, scaleH), "Sprite should be square for cached rendering");
|
||||
|
||||
if (floorSpriteCoordinates) {
|
||||
parameters.context.drawImage(
|
||||
link.atlas,
|
||||
|
||||
// atlas src pos
|
||||
Math_floor(srcX),
|
||||
Math_floor(srcY),
|
||||
|
||||
// atlas src size
|
||||
Math_floor(srcW),
|
||||
Math_floor(srcH),
|
||||
|
||||
// dest pos
|
||||
Math_floor(destX),
|
||||
Math_floor(destY),
|
||||
|
||||
// dest size
|
||||
Math_floor(destW),
|
||||
Math_floor(destH)
|
||||
);
|
||||
} else {
|
||||
parameters.context.drawImage(
|
||||
link.atlas,
|
||||
|
||||
// atlas src pos
|
||||
srcX,
|
||||
srcY,
|
||||
|
||||
// atlas src siize
|
||||
srcW,
|
||||
srcH,
|
||||
|
||||
// dest pos and size
|
||||
destX,
|
||||
destY,
|
||||
destW,
|
||||
destH
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders into an html element
|
||||
* @param {HTMLElement} element
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
renderToHTMLElement(element, w = 1, h = 1) {
|
||||
element.style.position = "relative";
|
||||
element.innerHTML = this.getAsHTML(w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the html to render as icon
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
getAsHTML(w, h) {
|
||||
const link = this.linksByResolution["0.5"];
|
||||
|
||||
// Find out how much we have to scale it so that it fits
|
||||
const scaleX = w / link.w;
|
||||
const scaleY = h / link.h;
|
||||
|
||||
// Find out how big the scaled atlas is
|
||||
const atlasW = link.atlas.width * scaleX;
|
||||
const atlasH = link.atlas.height * scaleY;
|
||||
|
||||
// @ts-ignore
|
||||
const srcSafe = link.atlas.src.replaceAll("\\", "/");
|
||||
|
||||
// Find out how big we render the sprite
|
||||
const widthAbsolute = scaleX * link.packedW;
|
||||
const heightAbsolute = scaleY * link.packedH;
|
||||
|
||||
// Compute the position in the relative container
|
||||
const leftRelative = (link.packOffsetX * scaleX) / w;
|
||||
const topRelative = (link.packOffsetY * scaleY) / h;
|
||||
const widthRelative = widthAbsolute / w;
|
||||
const heightRelative = heightAbsolute / h;
|
||||
|
||||
// Scale the atlas relative to the width and height of the element
|
||||
const bgW = atlasW / widthAbsolute;
|
||||
const bgH = atlasH / heightAbsolute;
|
||||
|
||||
// Figure out what the position of the atlas is
|
||||
const bgX = link.packedX * scaleX;
|
||||
const bgY = link.packedY * scaleY;
|
||||
|
||||
// Fuck you, whoever thought its a good idea to make background-position work like it does now
|
||||
const bgXRelative = -bgX / (widthAbsolute - atlasW);
|
||||
const bgYRelative = -bgY / (heightAbsolute - atlasH);
|
||||
|
||||
return `
|
||||
<span class="spritesheetImage" style="
|
||||
background-image: url('${srcSafe}');
|
||||
left: ${round3Digits(leftRelative * 100.0)}%;
|
||||
top: ${round3Digits(topRelative * 100.0)}%;
|
||||
width: ${round3Digits(widthRelative * 100.0)}%;
|
||||
height: ${round3Digits(heightRelative * 100.0)}%;
|
||||
background-repeat: repeat;
|
||||
background-position: ${round3Digits(bgXRelative * 100.0)}% ${round3Digits(
|
||||
bgYRelative * 100.0
|
||||
)}%;
|
||||
background-size: ${round3Digits(bgW * 100.0)}% ${round3Digits(bgH * 100.0)}%;
|
||||
"></span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export class RegularSprite extends BaseSprite {
|
||||
constructor(sprite, w, h) {
|
||||
super();
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.sprite = sprite;
|
||||
}
|
||||
|
||||
getRawTexture() {
|
||||
return this.sprite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sprite, do *not* use this for sprites which are rendered! Only for drawing
|
||||
* images into buffers
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
draw(context, x, y, w, h) {
|
||||
assert(context, "No context given");
|
||||
assert(x !== undefined, "No x given");
|
||||
assert(y !== undefined, "No y given");
|
||||
assert(w !== undefined, "No width given");
|
||||
assert(h !== undefined, "No height given");
|
||||
context.drawImage(this.sprite, x, y, w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the sprite, do *not* use this for sprites which are rendered! Only for drawing
|
||||
* images into buffers
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
drawCentered(context, x, y, w, h) {
|
||||
assert(context, "No context given");
|
||||
assert(x !== undefined, "No x given");
|
||||
assert(y !== undefined, "No y given");
|
||||
assert(w !== undefined, "No width given");
|
||||
assert(h !== undefined, "No height given");
|
||||
context.drawImage(this.sprite, x - w / 2, y - h / 2, w, h);
|
||||
}
|
||||
}
|
||||
121
src/js/core/state_manager.js
Normal file
121
src/js/core/state_manager.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/* typehints:start*/
|
||||
import { Application } from "../application";
|
||||
/* typehints:end*/
|
||||
|
||||
import { GameState } from "./game_state";
|
||||
import { createLogger } from "./logging";
|
||||
import { APPLICATION_ERROR_OCCURED } from "./error_handler";
|
||||
import { waitNextFrame, removeAllChildren } from "./utils";
|
||||
|
||||
const logger = createLogger("state_manager");
|
||||
|
||||
/**
|
||||
* This is the main state machine which drives the game states.
|
||||
*/
|
||||
export class StateManager {
|
||||
/**
|
||||
* @param {Application} app
|
||||
*/
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/** @type {GameState} */
|
||||
this.currentState = null;
|
||||
|
||||
/** @type {Object.<string, new() => GameState>} */
|
||||
this.stateClasses = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new state class, should be a GameState derived class
|
||||
* @param {object} stateClass
|
||||
*/
|
||||
register(stateClass) {
|
||||
// Create a dummy to retrieve the key
|
||||
const dummy = new stateClass();
|
||||
assert(dummy instanceof GameState, "Not a state!");
|
||||
const key = dummy.getKey();
|
||||
assert(!this.stateClasses[key], `State '${key}' is already registered!`);
|
||||
this.stateClasses[key] = stateClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new state or returns the instance from the cache
|
||||
* @param {string} key
|
||||
*/
|
||||
constructState(key) {
|
||||
if (this.stateClasses[key]) {
|
||||
return new this.stateClasses[key]();
|
||||
}
|
||||
assert(false, `State '${key}' is not known!`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to a given state
|
||||
* @param {string} key State Key
|
||||
*/
|
||||
moveToState(key, payload = {}) {
|
||||
if (APPLICATION_ERROR_OCCURED) {
|
||||
console.warn("Skipping state transition because of application crash");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentState) {
|
||||
if (key === this.currentState.getKey()) {
|
||||
logger.error(`State '${key}' is already active!`);
|
||||
return false;
|
||||
}
|
||||
this.currentState.internalLeaveCallback();
|
||||
|
||||
// Remove all references
|
||||
for (const stateKey in this.currentState) {
|
||||
if (this.currentState.hasOwnProperty(stateKey)) {
|
||||
delete this.currentState[stateKey];
|
||||
}
|
||||
}
|
||||
this.currentState = null;
|
||||
}
|
||||
|
||||
this.currentState = this.constructState(key);
|
||||
this.currentState.internalRegisterCallback(this, this.app);
|
||||
|
||||
// Clean up old elements
|
||||
removeAllChildren(document.body);
|
||||
|
||||
document.body.className = "gameState " + (this.currentState.getHasFadeIn() ? "" : "arrived");
|
||||
document.body.id = "state_" + key;
|
||||
document.body.innerHTML = this.currentState.internalGetFullHtml();
|
||||
|
||||
const dialogParent = document.createElement("div");
|
||||
dialogParent.classList.add("modalDialogParent");
|
||||
document.body.appendChild(dialogParent);
|
||||
|
||||
this.app.sound.playThemeMusic(this.currentState.getThemeMusic());
|
||||
|
||||
this.currentState.internalEnterCallback(payload);
|
||||
this.currentState.onResized(this.app.screenWidth, this.app.screenHeight);
|
||||
|
||||
this.app.analytics.trackStateEnter(key);
|
||||
|
||||
window.history.pushState(
|
||||
{
|
||||
key,
|
||||
},
|
||||
key
|
||||
);
|
||||
|
||||
waitNextFrame().then(() => {
|
||||
document.body.classList.add("arrived");
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current state
|
||||
* @returns {GameState}
|
||||
*/
|
||||
getCurrentState() {
|
||||
return this.currentState;
|
||||
}
|
||||
}
|
||||
39
src/js/core/tracked_state.js
Normal file
39
src/js/core/tracked_state.js
Normal file
@@ -0,0 +1,39 @@
|
||||
export class TrackedState {
|
||||
constructor(callbackMethod = null, callbackScope = null) {
|
||||
this.lastSeenValue = null;
|
||||
|
||||
if (callbackMethod) {
|
||||
this.callback = callbackMethod;
|
||||
if (callbackScope) {
|
||||
this.callback = this.callback.bind(callbackScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(value, changeHandler = null, changeScope = null) {
|
||||
if (value !== this.lastSeenValue) {
|
||||
// Copy value since the changeHandler call could actually modify our lastSeenValue
|
||||
const valueCopy = value;
|
||||
this.lastSeenValue = value;
|
||||
if (changeHandler) {
|
||||
if (changeScope) {
|
||||
changeHandler.call(changeScope, valueCopy);
|
||||
} else {
|
||||
changeHandler(valueCopy);
|
||||
}
|
||||
} else if (this.callback) {
|
||||
this.callback(value);
|
||||
} else {
|
||||
assert(false, "No callback specified");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSilent(value) {
|
||||
this.lastSeenValue = value;
|
||||
}
|
||||
|
||||
get() {
|
||||
return this.lastSeenValue;
|
||||
}
|
||||
}
|
||||
889
src/js/core/utils.js
Normal file
889
src/js/core/utils.js
Normal file
@@ -0,0 +1,889 @@
|
||||
import { globalConfig, IS_DEBUG } from "./config";
|
||||
import {
|
||||
Math_abs,
|
||||
Math_atan2,
|
||||
Math_ceil,
|
||||
Math_floor,
|
||||
Math_log10,
|
||||
Math_max,
|
||||
Math_min,
|
||||
Math_PI,
|
||||
Math_pow,
|
||||
Math_random,
|
||||
Math_round,
|
||||
Math_sin,
|
||||
performanceNow,
|
||||
} from "./builtins";
|
||||
import { Vector } from "./vector";
|
||||
|
||||
// Constants
|
||||
export const TOP = new Vector(0, -1);
|
||||
export const RIGHT = new Vector(1, 0);
|
||||
export const BOTTOM = new Vector(0, 1);
|
||||
export const LEFT = new Vector(-1, 0);
|
||||
export const ALL_DIRECTIONS = [TOP, RIGHT, BOTTOM, LEFT];
|
||||
|
||||
export const thousand = 1000;
|
||||
export const million = 1000 * 1000;
|
||||
export const billion = 1000 * 1000 * 1000;
|
||||
|
||||
/**
|
||||
* Returns the build id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getBuildId() {
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
return "local-dev";
|
||||
} else if (G_IS_DEV) {
|
||||
return "dev-" + getPlatformName() + "-" + G_BUILD_COMMIT_HASH;
|
||||
} else {
|
||||
return "prod-" + getPlatformName() + "-" + G_BUILD_COMMIT_HASH;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the environment id (dev, prod, etc)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getEnvironmentId() {
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
return "local-dev";
|
||||
} else if (G_IS_DEV) {
|
||||
return "dev-" + getPlatformName();
|
||||
} else if (G_IS_RELEASE) {
|
||||
return "release-" + getPlatformName();
|
||||
} else {
|
||||
return "staging-" + getPlatformName();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this platform is android
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAndroid() {
|
||||
if (!G_IS_MOBILE_APP) {
|
||||
return false;
|
||||
}
|
||||
const platform = window.device.platform;
|
||||
return platform === "Android" || platform === "amazon-fireos";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this platform is iOs
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isIos() {
|
||||
if (!G_IS_MOBILE_APP) {
|
||||
return false;
|
||||
}
|
||||
return window.device.platform === "iOS";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a platform name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getPlatformName() {
|
||||
if (G_IS_STANDALONE) {
|
||||
return "standalone";
|
||||
} else if (G_IS_BROWSER) {
|
||||
return "browser";
|
||||
} else if (G_IS_MOBILE_APP && isAndroid()) {
|
||||
return "android";
|
||||
} else if (G_IS_MOBILE_APP && isIos()) {
|
||||
return "ios";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IPC renderer, or null if not within the standalone
|
||||
* @returns {object|null}
|
||||
*/
|
||||
let ipcRenderer = null;
|
||||
export function getIPCRenderer() {
|
||||
if (!G_IS_STANDALONE) {
|
||||
return null;
|
||||
}
|
||||
if (!ipcRenderer) {
|
||||
ipcRenderer = eval("require")("electron").ipcRenderer;
|
||||
}
|
||||
return ipcRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a sensitive token by only displaying the first digits of it. Use for
|
||||
* stuff like savegame keys etc which should not appear in the log.
|
||||
* @param {string} key
|
||||
*/
|
||||
export function formatSensitive(key) {
|
||||
if (!key) {
|
||||
return "<null>";
|
||||
}
|
||||
key = key || "";
|
||||
return "[" + key.substr(0, 8) + "...]";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new 2D array with the given fill method
|
||||
* @param {number} w Width
|
||||
* @param {number} h Height
|
||||
* @param {(function(number, number) : any) | number | boolean | string | null | undefined} filler Either Fill method, which should return the content for each cell, or static content
|
||||
* @param {string=} context Optional context for memory tracking
|
||||
* @returns {Array<Array<any>>}
|
||||
*/
|
||||
export function make2DArray(w, h, filler, context = null) {
|
||||
if (typeof filler === "function") {
|
||||
const tiles = new Array(w);
|
||||
for (let x = 0; x < w; ++x) {
|
||||
const row = new Array(h);
|
||||
for (let y = 0; y < h; ++y) {
|
||||
row[y] = filler(x, y);
|
||||
}
|
||||
tiles[x] = row;
|
||||
}
|
||||
return tiles;
|
||||
} else {
|
||||
const tiles = new Array(w);
|
||||
const row = new Array(h);
|
||||
for (let y = 0; y < h; ++y) {
|
||||
row[y] = filler;
|
||||
}
|
||||
|
||||
for (let x = 0; x < w; ++x) {
|
||||
tiles[x] = row.slice();
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a new 2D array with undefined contents
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {string=} context
|
||||
* @returns {Array<Array<any>>}
|
||||
*/
|
||||
export function make2DUndefinedArray(w, h, context = null) {
|
||||
const result = new Array(w);
|
||||
for (let x = 0; x < w; ++x) {
|
||||
result[x] = new Array(h);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a given 2D array with the given fill method
|
||||
* @param {Array<Array<any>>} array
|
||||
* @param {number} w Width
|
||||
* @param {number} h Height
|
||||
* @param {(function(number, number) : any) | number | boolean | string | null | undefined} filler Either Fill method, which should return the content for each cell, or static content
|
||||
*/
|
||||
export function clear2DArray(array, w, h, filler) {
|
||||
assert(array.length === w, "Array dims mismatch w");
|
||||
assert(array[0].length === h, "Array dims mismatch h");
|
||||
if (typeof filler === "function") {
|
||||
for (let x = 0; x < w; ++x) {
|
||||
const row = array[x];
|
||||
for (let y = 0; y < h; ++y) {
|
||||
row[y] = filler(x, y);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let x = 0; x < w; ++x) {
|
||||
const row = array[x];
|
||||
for (let y = 0; y < h; ++y) {
|
||||
row[y] = filler;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new map (an empty object without any props)
|
||||
*/
|
||||
export function newEmptyMap() {
|
||||
return Object.create(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random integer in the range [start,end]
|
||||
* @param {number} start
|
||||
* @param {number} end
|
||||
*/
|
||||
export function randomInt(start, end) {
|
||||
return start + Math_round(Math_random() * (end - start));
|
||||
}
|
||||
|
||||
/**
|
||||
* Access an object in a very annoying way, used for obsfuscation.
|
||||
* @param {any} obj
|
||||
* @param {Array<string>} keys
|
||||
*/
|
||||
export function accessNestedPropertyReverse(obj, keys) {
|
||||
let result = obj;
|
||||
for (let i = keys.length - 1; i >= 0; --i) {
|
||||
result = result[keys[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a random entry of an array
|
||||
* @param {Array | string} arr
|
||||
*/
|
||||
export function randomChoice(arr) {
|
||||
return arr[Math_floor(Math_random() * arr.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes from an array by swapping with the last element
|
||||
* @param {Array<any>} array
|
||||
* @param {number} index
|
||||
*/
|
||||
export function fastArrayDelete(array, index) {
|
||||
if (index < 0 || index >= array.length) {
|
||||
throw new Error("Out of bounds");
|
||||
}
|
||||
// When the element is not the last element
|
||||
if (index !== array.length - 1) {
|
||||
// Get the last element, and swap it with the one we want to delete
|
||||
const last = array[array.length - 1];
|
||||
array[index] = last;
|
||||
}
|
||||
|
||||
// Finally remove the last element
|
||||
array.length -= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes from an array by swapping with the last element. Searches
|
||||
* for the value in the array first
|
||||
* @param {Array<any>} array
|
||||
* @param {any} value
|
||||
*/
|
||||
export function fastArrayDeleteValue(array, value) {
|
||||
if (array == null) {
|
||||
throw new Error("Tried to delete from non array!");
|
||||
}
|
||||
const index = array.indexOf(value);
|
||||
if (index < 0) {
|
||||
console.error("Value", value, "not contained in array:", array, "!");
|
||||
return value;
|
||||
}
|
||||
return fastArrayDelete(array, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see fastArrayDeleteValue
|
||||
* @param {Array<any>} array
|
||||
* @param {any} value
|
||||
*/
|
||||
export function fastArrayDeleteValueIfContained(array, value) {
|
||||
if (array == null) {
|
||||
throw new Error("Tried to delete from non array!");
|
||||
}
|
||||
const index = array.indexOf(value);
|
||||
if (index < 0) {
|
||||
return value;
|
||||
}
|
||||
return fastArrayDelete(array, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes from an array at the given index
|
||||
* @param {Array<any>} array
|
||||
* @param {number} index
|
||||
*/
|
||||
export function arrayDelete(array, index) {
|
||||
if (index < 0 || index >= array.length) {
|
||||
throw new Error("Out of bounds");
|
||||
}
|
||||
array.splice(index, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the given value from an array
|
||||
* @param {Array<any>} array
|
||||
* @param {any} value
|
||||
*/
|
||||
export function arrayDeleteValue(array, value) {
|
||||
if (array == null) {
|
||||
throw new Error("Tried to delete from non array!");
|
||||
}
|
||||
const index = array.indexOf(value);
|
||||
if (index < 0) {
|
||||
console.error("Value", value, "not contained in array:", array, "!");
|
||||
return value;
|
||||
}
|
||||
return arrayDelete(array, index);
|
||||
}
|
||||
|
||||
// Converts a direction into a 0 .. 7 index
|
||||
/**
|
||||
* Converts a direction into a index from 0 .. 7, used for miners, zombies etc which have 8 sprites
|
||||
* @param {Vector} offset direction
|
||||
* @param {boolean} inverse if inverse, the direction is reversed
|
||||
* @returns {number} in range [0, 7]
|
||||
*/
|
||||
export function angleToSpriteIndex(offset, inverse = false) {
|
||||
const twoPi = 2.0 * Math_PI;
|
||||
const factor = inverse ? -1 : 1;
|
||||
const offs = inverse ? 2.5 : 3.5;
|
||||
const angle = (factor * Math_atan2(offset.y, offset.x) + offs * Math_PI) % twoPi;
|
||||
|
||||
const index = Math_round((angle / twoPi) * 8) % 8;
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two floats for epsilon equality
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function epsilonCompare(a, b, epsilon = 1e-5) {
|
||||
return Math_abs(a - b) < epsilon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a float for epsilon equal to 0
|
||||
* @param {number} a
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function epsilonIsZero(a) {
|
||||
return epsilonCompare(a, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates two numbers
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @param {number} x Mix factor, 0 means 100% a, 1 means 100%b, rest is interpolated
|
||||
*/
|
||||
export function lerp(a, b, x) {
|
||||
return a * (1 - x) + b * x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a value which is nice to display, e.g. 15669 -> 15000. Also handles fractional stuff
|
||||
* @param {number} num
|
||||
*/
|
||||
export function findNiceValue(num) {
|
||||
if (num > 1e8) {
|
||||
return num;
|
||||
}
|
||||
if (num < 0.00001) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const roundAmount = 0.5 * Math_pow(10, Math_floor(Math_log10(num) - 1));
|
||||
const niceValue = Math_floor(num / roundAmount) * roundAmount;
|
||||
if (num >= 10) {
|
||||
return Math_round(niceValue);
|
||||
}
|
||||
if (num >= 1) {
|
||||
return Math_round(niceValue * 10) / 10;
|
||||
}
|
||||
|
||||
return Math_round(niceValue * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a nice integer value
|
||||
* @see findNiceValue
|
||||
* @param {number} num
|
||||
*/
|
||||
export function findNiceIntegerValue(num) {
|
||||
return Math_ceil(findNiceValue(num));
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart rounding + fractional handling
|
||||
* @param {number} n
|
||||
*/
|
||||
function roundSmart(n) {
|
||||
if (n < 100) {
|
||||
return n.toFixed(1);
|
||||
}
|
||||
return Math_round(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a big number
|
||||
* @param {number} num
|
||||
* @param {string=} divider THe divider for numbers like 50,000 (divider=',')
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatBigNumber(num, divider = ".") {
|
||||
const sign = num < 0 ? "-" : "";
|
||||
num = Math_abs(num);
|
||||
|
||||
if (num > 1e54) {
|
||||
return sign + "inf";
|
||||
}
|
||||
|
||||
if (num < 10 && !Number.isInteger(num)) {
|
||||
return sign + num.toFixed(2);
|
||||
}
|
||||
if (num < 50 && !Number.isInteger(num)) {
|
||||
return sign + num.toFixed(1);
|
||||
}
|
||||
num = Math_floor(num);
|
||||
|
||||
if (num < 1000) {
|
||||
return sign + "" + num;
|
||||
}
|
||||
|
||||
// if (num > 1e51) return sign + T.common.number_format.sedecillion.replace("%amount%", "" + roundSmart(num / 1e51));
|
||||
// if (num > 1e48)
|
||||
// return sign + T.common.number_format.quinquadecillion.replace("%amount%", "" + roundSmart(num / 1e48));
|
||||
// if (num > 1e45)
|
||||
// return sign + T.common.number_format.quattuordecillion.replace("%amount%", "" + roundSmart(num / 1e45));
|
||||
// if (num > 1e42) return sign + T.common.number_format.tredecillion.replace("%amount%", "" + roundSmart(num / 1e42));
|
||||
// if (num > 1e39) return sign + T.common.number_format.duodecillions.replace("%amount%", "" + roundSmart(num / 1e39));
|
||||
// if (num > 1e36) return sign + T.common.number_format.undecillions.replace("%amount%", "" + roundSmart(num / 1e36));
|
||||
// if (num > 1e33) return sign + T.common.number_format.decillions.replace("%amount%", "" + roundSmart(num / 1e33));
|
||||
// if (num > 1e30) return sign + T.common.number_format.nonillions.replace("%amount%", "" + roundSmart(num / 1e30));
|
||||
// if (num > 1e27) return sign + T.common.number_format.octillions.replace("%amount%", "" + roundSmart(num / 1e27));
|
||||
// if (num >= 1e24) return sign + T.common.number_format.septillions.replace("%amount%", "" + roundSmart(num / 1e24));
|
||||
// if (num >= 1e21) return sign + T.common.number_format.sextillions.replace("%amount%", "" + roundSmart(num / 1e21));
|
||||
// if (num >= 1e18) return sign + T.common.number_format.quintillions.replace("%amount%", "" + roundSmart(num / 1e18));
|
||||
// if (num >= 1e15) return sign + T.common.number_format.quantillions.replace("%amount%", "" + roundSmart(num / 1e15));
|
||||
// if (num >= 1e12) return sign + T.common.number_format.trillions.replace("%amount%", "" + roundSmart(num / 1e12));
|
||||
// if (num >= 1e9) return sign + T.common.number_format.billions.replace("%amount%", "" + roundSmart(num / 1e9));
|
||||
// if (num >= 1e6) return sign + T.common.number_format.millions.replace("%amount%", "" + roundSmart(num / 1e6));
|
||||
// if (num > 99999) return sign + T.common.number_format.thousands.replace("%amount%", "" + roundSmart(num / 1e3));
|
||||
|
||||
let rest = num;
|
||||
let out = "";
|
||||
|
||||
while (rest >= 1000) {
|
||||
out = (rest % 1000).toString().padStart(3, "0") + (out !== "" ? divider : "") + out;
|
||||
rest = Math_floor(rest / 1000);
|
||||
}
|
||||
|
||||
out = rest + divider + out;
|
||||
return sign + out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a big number, but does not add any suffix and instead uses its full representation
|
||||
* @param {number} num
|
||||
* @param {string=} divider THe divider for numbers like 50,000 (divider=',')
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatBigNumberFull(num, divider = T.common.number_format.divider_thousands || " ") {
|
||||
if (num < 1000) {
|
||||
return num + "";
|
||||
}
|
||||
if (num > 1e54) {
|
||||
return "infinite";
|
||||
}
|
||||
let rest = num;
|
||||
let out = "";
|
||||
while (rest >= 1000) {
|
||||
out = (rest % 1000).toString().padStart(3, "0") + divider + out;
|
||||
rest = Math_floor(rest / 1000);
|
||||
}
|
||||
out = rest + divider + out;
|
||||
|
||||
return out.substring(0, out.length - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an amount of seconds into something like "5s ago"
|
||||
* @param {number} secs Seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSecondsToTimeAgo(secs) {
|
||||
const seconds = Math_floor(secs);
|
||||
const minutes = Math_floor(seconds / 60);
|
||||
const hours = Math_floor(minutes / 60);
|
||||
const days = Math_floor(hours / 24);
|
||||
|
||||
const trans = T.common.time;
|
||||
|
||||
if (seconds <= 60) {
|
||||
if (seconds <= 1) {
|
||||
return trans.one_second_before;
|
||||
}
|
||||
return trans.seconds_before.replace("%amount%", "" + seconds);
|
||||
} else if (minutes <= 60) {
|
||||
if (minutes <= 1) {
|
||||
return trans.one_minute_before;
|
||||
}
|
||||
return trans.minutes_before.replace("%amount%", "" + minutes);
|
||||
} else if (hours <= 60) {
|
||||
if (hours <= 1) {
|
||||
return trans.one_hour_before;
|
||||
}
|
||||
return trans.hours_before.replace("%amount%", "" + hours);
|
||||
} else {
|
||||
if (days <= 1) {
|
||||
return trans.one_day_before;
|
||||
}
|
||||
return trans.days_before.replace("%amount%", "" + days);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats seconds into a readable string like "5h 23m"
|
||||
* @param {number} secs Seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSeconds(secs) {
|
||||
const trans = T.common.time;
|
||||
secs = Math_ceil(secs);
|
||||
if (secs < 60) {
|
||||
return trans.seconds_short.replace("%seconds%", "" + secs);
|
||||
} else if (secs < 60 * 60) {
|
||||
const minutes = Math_floor(secs / 60);
|
||||
const seconds = secs % 60;
|
||||
return trans.minutes_seconds_short
|
||||
.replace("%seconds%", "" + seconds)
|
||||
.replace("%minutes%", "" + minutes);
|
||||
} else {
|
||||
const hours = Math_floor(secs / 3600);
|
||||
const minutes = Math_floor(secs / 60) % 60;
|
||||
return trans.hours_minutes_short.replace("%minutes%", "" + minutes).replace("%hours%", "" + hours);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delayes a promise so that it will resolve after a *minimum* amount of time only
|
||||
* @param {Promise<any>} promise The promise to delay
|
||||
* @param {number} minTimeMs The time to make it run at least
|
||||
* @returns {Promise<any>} The delayed promise
|
||||
*/
|
||||
export function artificialDelayedPromise(promise, minTimeMs = 500) {
|
||||
if (G_IS_DEV && globalConfig.debug.noArtificialDelays) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
const startTime = performanceNow();
|
||||
return promise.then(
|
||||
result => {
|
||||
const timeTaken = performanceNow() - startTime;
|
||||
const waitTime = Math_floor(minTimeMs - timeTaken);
|
||||
if (waitTime > 0) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve(result);
|
||||
}, waitTime);
|
||||
});
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
},
|
||||
error => {
|
||||
const timeTaken = performanceNow() - startTime;
|
||||
const waitTime = Math_floor(minTimeMs - timeTaken);
|
||||
if (waitTime > 0) {
|
||||
// @ts-ignore
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(error);
|
||||
}, waitTime);
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a sine-based animation which pulsates from 0 .. 1 .. 0
|
||||
* @param {number} time Current time in seconds
|
||||
* @param {number} duration Duration of the full pulse in seconds
|
||||
* @param {number} seed Seed to offset the animation
|
||||
*/
|
||||
export function pulseAnimation(time, duration = 1.0, seed = 0.0) {
|
||||
return Math_sin((time * Math_PI * 2.0) / duration + seed * 5642.86729349) * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the smallest angle between two angles
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {number} 0 .. 2 PI
|
||||
*/
|
||||
export function smallestAngle(a, b) {
|
||||
return safeMod(a - b + Math_PI, 2.0 * Math_PI) - Math_PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modulo which works for negative numbers
|
||||
* @param {number} n
|
||||
* @param {number} m
|
||||
*/
|
||||
export function safeMod(n, m) {
|
||||
return ((n % m) + m) % m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an angle between 0 and 2 pi
|
||||
* @param {number} angle
|
||||
*/
|
||||
export function wrapAngle(angle) {
|
||||
return safeMod(angle, 2.0 * Math_PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits two frames so the ui is updated
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function waitNextFrame() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
window.requestAnimationFrame(function () {
|
||||
window.requestAnimationFrame(function () {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds 1 digit
|
||||
* @param {number} n
|
||||
* @returns {number}
|
||||
*/
|
||||
export function round1Digit(n) {
|
||||
return Math_floor(n * 10.0) / 10.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds 2 digits
|
||||
* @param {number} n
|
||||
* @returns {number}
|
||||
*/
|
||||
export function round2Digits(n) {
|
||||
return Math_floor(n * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds 3 digits
|
||||
* @param {number} n
|
||||
* @returns {number}
|
||||
*/
|
||||
export function round3Digits(n) {
|
||||
return Math_floor(n * 1000.0) / 1000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds 4 digits
|
||||
* @param {number} n
|
||||
* @returns {number}
|
||||
*/
|
||||
export function round4Digits(n) {
|
||||
return Math_floor(n * 10000.0) / 10000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a value between [min, max]
|
||||
* @param {number} v
|
||||
* @param {number=} minimum Default 0
|
||||
* @param {number=} maximum Default 1
|
||||
*/
|
||||
export function clamp(v, minimum = 0, maximum = 1) {
|
||||
return Math_max(minimum, Math_min(maximum, v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures how long a function took
|
||||
* @param {string} name
|
||||
* @param {function():void} target
|
||||
*/
|
||||
export function measure(name, target) {
|
||||
const now = performanceNow();
|
||||
for (let i = 0; i < 25; ++i) {
|
||||
target();
|
||||
}
|
||||
const dur = (performanceNow() - now) / 25.0;
|
||||
console.warn("->", name, "took", dur.toFixed(2), "ms");
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a new div
|
||||
* @param {Element} parent
|
||||
* @param {string=} id
|
||||
* @param {Array<string>=} classes
|
||||
* @param {string=} innerHTML
|
||||
*/
|
||||
export function makeDiv(parent, id = null, classes = [], innerHTML = "") {
|
||||
const div = document.createElement("div");
|
||||
if (id) {
|
||||
div.id = id;
|
||||
}
|
||||
for (let i = 0; i < classes.length; ++i) {
|
||||
div.classList.add(classes[i]);
|
||||
}
|
||||
div.innerHTML = innerHTML;
|
||||
parent.appendChild(div);
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all children of the given element
|
||||
* @param {Element} elem
|
||||
*/
|
||||
export function removeAllChildren(elem) {
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(elem);
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
export function smartFadeNumber(current, newOne, minFade = 0.01, maxFade = 0.9) {
|
||||
const tolerance = Math.min(current, newOne) * 0.5 + 10;
|
||||
let fade = minFade;
|
||||
if (Math.abs(current - newOne) < tolerance) {
|
||||
fade = maxFade;
|
||||
}
|
||||
|
||||
return current * fade + newOne * (1 - fade);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes lockstep simulation by converting times like 34.0000000003 to 34.00.
|
||||
* We use 3 digits of precision, this allows to store sufficient precision of 1 ms without
|
||||
* the risk to simulation errors due to resync issues
|
||||
* @param {number} value
|
||||
*/
|
||||
export function quantizeFloat(value) {
|
||||
return Math.round(value * 1000.0) / 1000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe check to check if a timer is expired. quantizes numbers
|
||||
* @param {number} now Current time
|
||||
* @param {number} lastTick Last tick of the timer
|
||||
* @param {number} tickRate Interval of the timer
|
||||
*/
|
||||
export function checkTimerExpired(now, lastTick, tickRate) {
|
||||
if (!G_IS_PROD) {
|
||||
if (quantizeFloat(now) !== now) {
|
||||
console.error("Got non-quantizied time:" + now + " vs " + quantizeFloat(now));
|
||||
now = quantizeFloat(now);
|
||||
}
|
||||
if (quantizeFloat(lastTick) !== lastTick) {
|
||||
// FIXME: REENABLE
|
||||
// console.error("Got non-quantizied timer:" + lastTick + " vs " + quantizeFloat(lastTick));
|
||||
lastTick = quantizeFloat(lastTick);
|
||||
}
|
||||
} else {
|
||||
// just to be safe
|
||||
now = quantizeFloat(now);
|
||||
lastTick = quantizeFloat(lastTick);
|
||||
}
|
||||
/*
|
||||
Ok, so heres the issue (Died a bit while debugging it):
|
||||
|
||||
In multiplayer lockstep simulation, client A will simulate everything at T, but client B
|
||||
will simulate it at T + 3. So we are running into the following precision issue:
|
||||
Lets say on client A the time is T = 30. Then on clientB the time is T = 33.
|
||||
Now, our timer takes 0.1 seconds and ticked at 29.90 - What does happen now?
|
||||
Client A computes the timer and checks T > lastTick + interval. He computes
|
||||
|
||||
30 >= 29.90 + 0.1 <=> 30 >= 30.0000 <=> True <=> Tick performed
|
||||
|
||||
However, this is what it looks on client B:
|
||||
|
||||
33 >= 32.90 + 0.1 <=> 33 >= 32.999999999999998 <=> False <=> No tick performed!
|
||||
|
||||
This means that Client B will only tick at the *next* frame, which means it from now is out
|
||||
of sync by one tick, which means the game will resync further or later and be not able to recover,
|
||||
since it will run into the same issue over and over.
|
||||
*/
|
||||
|
||||
// The next tick, in our example it would be 30.0000 / 32.99999999998. In order to fix it, we quantize
|
||||
// it, so its now 30.0000 / 33.0000
|
||||
const nextTick = quantizeFloat(lastTick + tickRate);
|
||||
|
||||
// This check is safe, but its the only check where you may compare times. You always need to use
|
||||
// this method!
|
||||
return now >= nextTick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the game supports this browser
|
||||
*/
|
||||
export function isSupportedBrowser() {
|
||||
if (navigator.userAgent.toLowerCase().indexOf("firefox") >= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isSupportedBrowserForMultiplayer();
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/4565112/javascript-how-to-find-out-if-the-user-browser-is-chrome/13348618#13348618
|
||||
export function isSupportedBrowserForMultiplayer() {
|
||||
// please note,
|
||||
// that IE11 now returns undefined again for window.chrome
|
||||
// and new Opera 30 outputs true for window.chrome
|
||||
// but needs to check if window.opr is not undefined
|
||||
// and new IE Edge outputs to true now for window.chrome
|
||||
// and if not iOS Chrome check
|
||||
// so use the below updated condition
|
||||
|
||||
if (G_IS_MOBILE_APP || G_IS_STANDALONE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
var isChromium = window.chrome;
|
||||
var winNav = window.navigator;
|
||||
var vendorName = winNav.vendor;
|
||||
// @ts-ignore
|
||||
var isOpera = typeof window.opr !== "undefined";
|
||||
var isIEedge = winNav.userAgent.indexOf("Edge") > -1;
|
||||
var isIOSChrome = winNav.userAgent.match("CriOS");
|
||||
|
||||
if (isIOSChrome) {
|
||||
// is Google Chrome on IOS
|
||||
return false;
|
||||
} else if (
|
||||
isChromium !== null &&
|
||||
typeof isChromium !== "undefined" &&
|
||||
vendorName === "Google Inc." &&
|
||||
isIEedge === false
|
||||
) {
|
||||
// is Google Chrome
|
||||
return true;
|
||||
} else {
|
||||
// not Google Chrome
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a json schema object
|
||||
* @param {any} properties
|
||||
*/
|
||||
export function schemaObject(properties) {
|
||||
return {
|
||||
type: "object",
|
||||
required: Object.keys(properties).slice(),
|
||||
additionalProperties: false,
|
||||
properties,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quickly
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} deg
|
||||
* @returns {Vector}
|
||||
*/
|
||||
export function fastRotateMultipleOf90(x, y, deg) {
|
||||
switch (deg) {
|
||||
case 0: {
|
||||
return new Vector(x, y);
|
||||
}
|
||||
case 90: {
|
||||
return new Vector(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
665
src/js/core/vector.js
Normal file
665
src/js/core/vector.js
Normal file
@@ -0,0 +1,665 @@
|
||||
import { globalConfig } from "./config";
|
||||
import {
|
||||
Math_abs,
|
||||
Math_floor,
|
||||
Math_PI,
|
||||
Math_max,
|
||||
Math_min,
|
||||
Math_round,
|
||||
Math_hypot,
|
||||
Math_atan2,
|
||||
Math_sin,
|
||||
Math_cos,
|
||||
} from "./builtins";
|
||||
|
||||
const tileSize = globalConfig.tileSize;
|
||||
const halfTileSize = globalConfig.halfTileSize;
|
||||
|
||||
/**
|
||||
* @enum {string}
|
||||
*/
|
||||
export const enumDirection = {
|
||||
top: "top",
|
||||
right: "right",
|
||||
bottom: "bottom",
|
||||
left: "left",
|
||||
};
|
||||
|
||||
/**
|
||||
* @enum {string}
|
||||
*/
|
||||
export const enumInvertedDirections = {
|
||||
[enumDirection.top]: enumDirection.bottom,
|
||||
[enumDirection.right]: enumDirection.left,
|
||||
[enumDirection.bottom]: enumDirection.top,
|
||||
[enumDirection.left]: enumDirection.right,
|
||||
};
|
||||
|
||||
/**
|
||||
* @enum {number}
|
||||
*/
|
||||
export const enumDirectionToAngle = {
|
||||
[enumDirection.top]: 0,
|
||||
[enumDirection.right]: 90,
|
||||
[enumDirection.bottom]: 180,
|
||||
[enumDirection.left]: 270,
|
||||
};
|
||||
|
||||
/**
|
||||
* @enum {enumDirection}
|
||||
*/
|
||||
export const enumAngleToDirection = {
|
||||
0: enumDirection.top,
|
||||
90: enumDirection.right,
|
||||
180: enumDirection.bottom,
|
||||
270: enumDirection.left,
|
||||
};
|
||||
|
||||
export class Vector {
|
||||
/**
|
||||
*
|
||||
* @param {number=} x
|
||||
* @param {number=} y
|
||||
*/
|
||||
constructor(x, y) {
|
||||
this.x = x || 0;
|
||||
this.y = y || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* return a copy of the vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
copy() {
|
||||
return new Vector(this.x, this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a vector and return a new vector
|
||||
* @param {Vector} other
|
||||
* @returns {Vector}
|
||||
*/
|
||||
add(other) {
|
||||
return new Vector(this.x + other.x, this.y + other.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a vector
|
||||
* @param {Vector} other
|
||||
* @returns {Vector}
|
||||
*/
|
||||
addInplace(other) {
|
||||
this.x += other.x;
|
||||
this.y += other.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substracts a vector and return a new vector
|
||||
* @param {Vector} other
|
||||
* @returns {Vector}
|
||||
*/
|
||||
sub(other) {
|
||||
return new Vector(this.x - other.x, this.y - other.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies with a vector and return a new vector
|
||||
* @param {Vector} other
|
||||
* @returns {Vector}
|
||||
*/
|
||||
mul(other) {
|
||||
return new Vector(this.x * other.x, this.y * other.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds two scalars and return a new vector
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {Vector}
|
||||
*/
|
||||
addScalars(x, y) {
|
||||
return new Vector(this.x + x, this.y + y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Substracts a scalar and return a new vector
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
subScalar(f) {
|
||||
return new Vector(this.x - f, this.y - f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Substracts two scalars and return a new vector
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {Vector}
|
||||
*/
|
||||
subScalars(x, y) {
|
||||
return new Vector(this.x - x, this.y - y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the euclidian length
|
||||
* @returns {number}
|
||||
*/
|
||||
length() {
|
||||
return Math_hypot(this.x, this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the square length
|
||||
* @returns {number}
|
||||
*/
|
||||
lengthSquare() {
|
||||
return this.x * this.x + this.y * this.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divides both components by a scalar and return a new vector
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
divideScalar(f) {
|
||||
return new Vector(this.x / f, this.y / f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divides both components by the given scalars and return a new vector
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {Vector}
|
||||
*/
|
||||
divideScalars(a, b) {
|
||||
return new Vector(this.x / a, this.y / b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Divides both components by a scalar
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
divideScalarInplace(f) {
|
||||
this.x /= f;
|
||||
this.y /= f;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies both components with a scalar and return a new vector
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
multiplyScalar(f) {
|
||||
return new Vector(this.x * f, this.y * f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies both components with two scalars and returns a new vector
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {Vector}
|
||||
*/
|
||||
multiplyScalars(a, b) {
|
||||
return new Vector(this.x * a, this.y * b);
|
||||
}
|
||||
|
||||
/**
|
||||
* For both components, compute the maximum of each component and the given scalar, and return a new vector.
|
||||
* For example:
|
||||
* - new Vector(-1, 5).maxScalar(0) -> Vector(0, 5)
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
maxScalar(f) {
|
||||
return new Vector(Math_max(f, this.x), Math_max(f, this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a scalar to both components and return a new vector
|
||||
* @param {number} f
|
||||
* @returns {Vector}
|
||||
*/
|
||||
addScalar(f) {
|
||||
return new Vector(this.x + f, this.y + f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the component wise minimum and return a new vector
|
||||
* @param {Vector} v
|
||||
* @returns {Vector}
|
||||
*/
|
||||
min(v) {
|
||||
return new Vector(Math_min(v.x, this.x), Math_min(v.y, this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the component wise maximum and return a new vector
|
||||
* @param {Vector} v
|
||||
* @returns {Vector}
|
||||
*/
|
||||
max(v) {
|
||||
return new Vector(Math_max(v.x, this.x), Math_max(v.y, this.y));
|
||||
}
|
||||
/**
|
||||
* Computes the component wise absolute
|
||||
* @returns {Vector}
|
||||
*/
|
||||
abs() {
|
||||
return new Vector(Math_abs(this.x), Math_abs(this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the scalar product
|
||||
* @param {Vector} v
|
||||
* @returns {number}
|
||||
*/
|
||||
dot(v) {
|
||||
return this.x * v.x + this.y * v.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the distance to a given vector
|
||||
* @param {Vector} v
|
||||
* @returns {number}
|
||||
*/
|
||||
distance(v) {
|
||||
return Math_hypot(this.x - v.x, this.y - v.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the square distance to a given vectort
|
||||
* @param {Vector} v
|
||||
* @returns {number}
|
||||
*/
|
||||
distanceSquare(v) {
|
||||
const dx = this.x - v.x;
|
||||
const dy = this.y - v.y;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes and returns the center between both points
|
||||
* @param {Vector} v
|
||||
* @returns {Vector}
|
||||
*/
|
||||
centerPoint(v) {
|
||||
const cx = this.x + v.x;
|
||||
const cy = this.y + v.y;
|
||||
return new Vector(cx / 2, cy / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes componentwise floor and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
floor() {
|
||||
return new Vector(Math_floor(this.x), Math_floor(this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes componentwise round and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
round() {
|
||||
return new Vector(Math_round(this.x), Math_round(this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this vector from world to tile space and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
toTileSpace() {
|
||||
return new Vector(Math_floor(this.x / tileSize), Math_floor(this.y / tileSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this vector from world to street space and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
toStreetSpace() {
|
||||
return new Vector(Math_floor(this.x / halfTileSize + 0.25), Math_floor(this.y / halfTileSize + 0.25));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this vector to world space and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
toWorldSpace() {
|
||||
return new Vector(this.x * tileSize, this.y * tileSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this vector to world space and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
toWorldSpaceCenterOfTile() {
|
||||
return new Vector(this.x * tileSize + halfTileSize, this.y * tileSize + halfTileSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the top left tile position of this vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
snapWorldToTile() {
|
||||
return new Vector(Math_floor(this.x / tileSize) * tileSize, Math_floor(this.y / tileSize) * tileSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the vector, dividing by the length(), and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
normalize() {
|
||||
const len = Math_max(1e-5, Math_hypot(this.x, this.y));
|
||||
return new Vector(this.x / len, this.y / len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the vector, dividing by the length(), and return a new vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
normalizeIfGreaterOne() {
|
||||
const len = Math_max(1, Math_hypot(this.x, this.y));
|
||||
return new Vector(this.x / len, this.y / len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized vector to the other point
|
||||
* @param {Vector} v
|
||||
* @returns {Vector}
|
||||
*/
|
||||
normalizedDirection(v) {
|
||||
const dx = v.x - this.x;
|
||||
const dy = v.y - this.y;
|
||||
const len = Math_max(1e-5, Math_hypot(dx, dy));
|
||||
return new Vector(dx / len, dy / len);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a perpendicular vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
findPerpendicular() {
|
||||
return new Vector(-this.y, this.x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unnormalized direction to the other point
|
||||
* @param {Vector} v
|
||||
* @returns {Vector}
|
||||
*/
|
||||
direction(v) {
|
||||
return new Vector(v.x - this.x, v.y - this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representation of the vector
|
||||
* @returns {string}
|
||||
*/
|
||||
toString() {
|
||||
return this.x + "," + this.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares both vectors for exact equality. Does not do an epsilon compare
|
||||
* @param {Vector} v
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
equals(v) {
|
||||
return this.x === v.x && this.y === v.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates this vector
|
||||
* @param {number} angle
|
||||
* @returns {Vector} new vector
|
||||
*/
|
||||
rotated(angle) {
|
||||
const sin = Math_sin(angle);
|
||||
const cos = Math_cos(angle);
|
||||
return new Vector(this.x * cos - this.y * sin, this.x * sin + this.y * cos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates this vector
|
||||
* @param {number} angle
|
||||
* @returns {Vector} this vector
|
||||
*/
|
||||
rotateInplaceFastMultipleOf90(angle) {
|
||||
// const sin = Math_sin(angle);
|
||||
// const cos = Math_cos(angle);
|
||||
// let sin = 0, cos = 1;
|
||||
assert(angle >= 0 && angle <= 360, "Invalid angle, please clamp first: " + angle);
|
||||
|
||||
switch (angle) {
|
||||
case 0:
|
||||
case 360: {
|
||||
return this;
|
||||
}
|
||||
case 90: {
|
||||
// sin = 1;
|
||||
// cos = 0;
|
||||
|
||||
const x = this.x;
|
||||
this.x = -this.y;
|
||||
this.y = x;
|
||||
return this;
|
||||
}
|
||||
case 180: {
|
||||
// sin = 0
|
||||
// cos = -1
|
||||
this.x = -this.x;
|
||||
this.y = -this.y;
|
||||
return this;
|
||||
}
|
||||
case 270: {
|
||||
// sin = -1
|
||||
// cos = 0
|
||||
const x = this.x;
|
||||
this.x = this.y;
|
||||
this.y = -x;
|
||||
return this;
|
||||
}
|
||||
default: {
|
||||
assertAlways(false, "Invalid fast inplace rotation: " + angle);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
// return new Vector(this.x * cos - this.y * sin, this.x * sin + this.y * cos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates this vector
|
||||
* @param {number} angle
|
||||
* @returns {Vector} new vector
|
||||
*/
|
||||
rotateFastMultipleOf90(angle) {
|
||||
assert(angle >= 0 && angle <= 360, "Invalid angle, please clamp first: " + angle);
|
||||
|
||||
switch (angle) {
|
||||
case 360:
|
||||
case 0: {
|
||||
return new Vector(this.x, this.y);
|
||||
}
|
||||
case 90: {
|
||||
return new Vector(-this.y, this.x);
|
||||
}
|
||||
case 180: {
|
||||
return new Vector(-this.x, -this.y);
|
||||
}
|
||||
case 270: {
|
||||
return new Vector(this.y, -this.x);
|
||||
}
|
||||
default: {
|
||||
assertAlways(false, "Invalid fast inplace rotation: " + angle);
|
||||
return new Vector();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to rotate a direction
|
||||
* @param {enumDirection} direction
|
||||
* @param {number} angle
|
||||
* @returns {enumDirection}
|
||||
*/
|
||||
static transformDirectionFromMultipleOf90(direction, angle) {
|
||||
if (angle === 0 || angle === 360) {
|
||||
return direction;
|
||||
}
|
||||
assert(angle >= 0 && angle <= 360, "Invalid angle: " + angle);
|
||||
switch (direction) {
|
||||
case enumDirection.top: {
|
||||
switch (angle) {
|
||||
case 90:
|
||||
return enumDirection.right;
|
||||
case 180:
|
||||
return enumDirection.bottom;
|
||||
case 270:
|
||||
return enumDirection.left;
|
||||
default:
|
||||
assertAlways(false, "Invalid angle: " + angle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
case enumDirection.right: {
|
||||
switch (angle) {
|
||||
case 90:
|
||||
return enumDirection.bottom;
|
||||
case 180:
|
||||
return enumDirection.left;
|
||||
case 270:
|
||||
return enumDirection.top;
|
||||
default:
|
||||
assertAlways(false, "Invalid angle: " + angle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
case enumDirection.bottom: {
|
||||
switch (angle) {
|
||||
case 90:
|
||||
return enumDirection.left;
|
||||
case 180:
|
||||
return enumDirection.top;
|
||||
case 270:
|
||||
return enumDirection.right;
|
||||
default:
|
||||
assertAlways(false, "Invalid angle: " + angle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
case enumDirection.left: {
|
||||
switch (angle) {
|
||||
case 90:
|
||||
return enumDirection.top;
|
||||
case 180:
|
||||
return enumDirection.right;
|
||||
case 270:
|
||||
return enumDirection.bottom;
|
||||
default:
|
||||
assertAlways(false, "Invalid angle: " + angle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
default:
|
||||
assertAlways(false, "Invalid angle: " + angle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares both vectors for epsilon equality
|
||||
* @param {Vector} v
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
equalsEpsilon(v, epsilon = 1e-5) {
|
||||
return Math_abs(this.x - v.x) < 1e-5 && Math_abs(this.y - v.y) < epsilon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the angle
|
||||
* @returns {number} 0 .. 2 PI
|
||||
*/
|
||||
angle() {
|
||||
return Math_atan2(this.y, this.x) + Math_PI / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the vector to a string
|
||||
* @returns {string}
|
||||
*/
|
||||
serializeTile() {
|
||||
return String.fromCharCode(33 + this.x) + String.fromCharCode(33 + this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simple representation of the vector
|
||||
*/
|
||||
serializeSimple() {
|
||||
return { x: this.x, y: this.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
serializeTileToInt() {
|
||||
return this.x + this.y * 256;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} i
|
||||
* @returns {Vector}
|
||||
*/
|
||||
static deserializeTileFromInt(i) {
|
||||
const x = i % 256;
|
||||
const y = Math_floor(i / 256);
|
||||
return new Vector(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a vector from a string
|
||||
* @param {string} s
|
||||
* @returns {Vector}
|
||||
*/
|
||||
static deserializeTile(s) {
|
||||
return new Vector(s.charCodeAt(0) - 33, s.charCodeAt(1) - 33);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes a vector from a serialized json object
|
||||
* @param {object} obj
|
||||
* @returns {Vector}
|
||||
*/
|
||||
static fromSerializedObject(obj) {
|
||||
if (obj) {
|
||||
return new Vector(obj.x || 0, obj.y || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates two vectors, for a = 0, returns v1 and for a = 1 return v2, otherwise interpolate
|
||||
* @param {Vector} v1
|
||||
* @param {Vector} v2
|
||||
* @param {number} a
|
||||
*/
|
||||
export function mixVector(v1, v2, a) {
|
||||
return new Vector(v1.x * (1 - a) + v2.x * a, v1.y * (1 - a) + v2.y * a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from string direction to actual vector
|
||||
* @enum {Vector}
|
||||
*/
|
||||
export const enumDirectionToVector = {
|
||||
top: new Vector(0, -1),
|
||||
right: new Vector(1, 0),
|
||||
bottom: new Vector(0, 1),
|
||||
left: new Vector(-1, 0),
|
||||
};
|
||||
80
src/js/game/automatic_save.js
Normal file
80
src/js/game/automatic_save.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { GameRoot } from "./root";
|
||||
import { globalConfig, IS_DEBUG } from "../core/config";
|
||||
import { Math_max } from "../core/builtins";
|
||||
|
||||
// How important it is that a savegame is created
|
||||
/**
|
||||
* @enum {number}
|
||||
*/
|
||||
export const enumSavePriority = {
|
||||
regular: 2,
|
||||
asap: 100,
|
||||
};
|
||||
|
||||
// Internals
|
||||
let MIN_INTERVAL_SECS = 15;
|
||||
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
// // Testing
|
||||
// MIN_INTERVAL_SECS = 1;
|
||||
// MAX_INTERVAL_SECS = 1;
|
||||
MIN_INTERVAL_SECS = 9999999;
|
||||
}
|
||||
|
||||
export class AutomaticSave {
|
||||
constructor(root) {
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
// Store the current maximum save importance
|
||||
this.saveImportance = enumSavePriority.regular;
|
||||
|
||||
this.lastSaveAttempt = -1000;
|
||||
}
|
||||
|
||||
setSaveImportance(importance) {
|
||||
this.saveImportance = Math_max(this.saveImportance, importance);
|
||||
}
|
||||
|
||||
doSave() {
|
||||
if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.root.gameState.doSave();
|
||||
this.saveImportance = enumSavePriority.regular;
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.root.gameInitialized) {
|
||||
// Bad idea
|
||||
return;
|
||||
}
|
||||
// Check when the last save was, but make sure that if it fails, we don't spam
|
||||
const lastSaveTime = Math_max(this.lastSaveAttempt, this.root.savegame.getRealLastUpdate());
|
||||
|
||||
let secondsSinceLastSave = (Date.now() - lastSaveTime) / 1000.0;
|
||||
let shouldSave = false;
|
||||
|
||||
switch (this.saveImportance) {
|
||||
case enumSavePriority.asap:
|
||||
// High always should save
|
||||
shouldSave = true;
|
||||
break;
|
||||
|
||||
case enumSavePriority.regular:
|
||||
// Could determine if there is a good / bad point here
|
||||
shouldSave = secondsSinceLastSave > MIN_INTERVAL_SECS;
|
||||
break;
|
||||
|
||||
default:
|
||||
assert(false, "Unknown save prio: " + this.saveImportance);
|
||||
break;
|
||||
}
|
||||
if (shouldSave) {
|
||||
// log(this, "Saving automatically");
|
||||
this.lastSaveAttempt = Date.now();
|
||||
this.doSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/js/game/base_item.js
Normal file
33
src/js/game/base_item.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
|
||||
/**
|
||||
* Class for items on belts etc. Not an entity for performance reasons
|
||||
*/
|
||||
export class BaseItem extends BasicSerializableObject {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
static getId() {
|
||||
return "base_item";
|
||||
}
|
||||
|
||||
/** @returns {object} */
|
||||
static getSchema() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the item at the given position
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {number=} size
|
||||
*/
|
||||
draw(x, y, parameters, size) {}
|
||||
|
||||
getBackgroundColorAsResource() {
|
||||
return "#eaebec";
|
||||
}
|
||||
}
|
||||
204
src/js/game/buildings/belt_base.js
Normal file
204
src/js/game/buildings/belt_base.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Loader } from "../../core/loader";
|
||||
import { enumAngleToDirection, enumDirection, Vector } from "../../core/vector";
|
||||
import { BeltComponent } from "../components/belt";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { ReplaceableMapEntityComponent } from "../components/replaceable_map_entity";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export const arrayBeltVariantToRotation = [enumDirection.top, enumDirection.left, enumDirection.right];
|
||||
|
||||
export class MetaBeltBaseBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("belt");
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#777";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new BeltComponent({
|
||||
direction: enumDirection.top, // updated later
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
direction: enumDirection.top, // updated later
|
||||
},
|
||||
],
|
||||
instantEject: true,
|
||||
})
|
||||
);
|
||||
// Make this entity replaceabel
|
||||
entity.addComponent(new ReplaceableMapEntityComponent());
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
* @param {number} rotationVariant
|
||||
*/
|
||||
updateRotationVariant(entity, rotationVariant) {
|
||||
entity.components.Belt.direction = arrayBeltVariantToRotation[rotationVariant];
|
||||
entity.components.ItemEjector.slots[0].direction = arrayBeltVariantToRotation[rotationVariant];
|
||||
|
||||
entity.components.StaticMapEntity.spriteKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes optimal belt rotation variant
|
||||
* @param {GameRoot} root
|
||||
* @param {Vector} tile
|
||||
* @param {number} rotation
|
||||
* @return {{ rotation: number, rotationVariant: number }}
|
||||
*/
|
||||
computeOptimalDirectionAndRotationVariantAtTile(root, tile, rotation) {
|
||||
const topDirection = enumAngleToDirection[rotation];
|
||||
const rightDirection = enumAngleToDirection[(rotation + 90) % 360];
|
||||
const bottomDirection = enumAngleToDirection[(rotation + 180) % 360];
|
||||
const leftDirection = enumAngleToDirection[(rotation + 270) % 360];
|
||||
|
||||
const { ejectors, acceptors } = root.logic.getEjectorsAndAcceptorsAtTile(tile);
|
||||
|
||||
let hasBottomEjector = false;
|
||||
let hasLeftEjector = false;
|
||||
let hasRightEjector = false;
|
||||
|
||||
let hasTopAcceptor = false;
|
||||
let hasLeftAcceptor = false;
|
||||
let hasRightAcceptor = false;
|
||||
|
||||
// Check all ejectors
|
||||
for (let i = 0; i < ejectors.length; ++i) {
|
||||
const ejector = ejectors[i];
|
||||
|
||||
if (ejector.toDirection === topDirection) {
|
||||
hasBottomEjector = true;
|
||||
} else if (ejector.toDirection === leftDirection) {
|
||||
hasLeftEjector = true;
|
||||
} else if (ejector.toDirection === rightDirection) {
|
||||
hasRightEjector = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check all acceptors
|
||||
for (let i = 0; i < acceptors.length; ++i) {
|
||||
const acceptor = acceptors[i];
|
||||
if (acceptor.fromDirection === bottomDirection) {
|
||||
hasTopAcceptor = true;
|
||||
} else if (acceptor.fromDirection === rightDirection) {
|
||||
hasLeftAcceptor = true;
|
||||
} else if (acceptor.fromDirection === leftDirection) {
|
||||
hasRightAcceptor = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Soo .. if there is any ejector below us we always prioritize
|
||||
// this ejector
|
||||
if (!hasBottomEjector) {
|
||||
// When something ejects to us from the left and nothing from the right,
|
||||
// do a curve from the left to the top
|
||||
if (hasLeftEjector && !hasRightEjector) {
|
||||
return {
|
||||
rotation: (rotation + 270) % 360,
|
||||
rotationVariant: 2,
|
||||
};
|
||||
}
|
||||
|
||||
// When something ejects to us from the right and nothing from the left,
|
||||
// do a curve from the right to the top
|
||||
if (hasRightEjector && !hasLeftEjector) {
|
||||
return {
|
||||
rotation: (rotation + 90) % 360,
|
||||
rotationVariant: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// When there is a top acceptor, ignore sides
|
||||
// NOTICE: This makes the belt prefer side turns *way* too much!
|
||||
// if (!hasTopAcceptor) {
|
||||
// // When there is an acceptor to the right but no acceptor to the left,
|
||||
// // do a turn to the right
|
||||
// if (hasRightAcceptor && !hasLeftAcceptor) {
|
||||
// return {
|
||||
// rotation,
|
||||
// rotationVariant: 2,
|
||||
// };
|
||||
// }
|
||||
|
||||
// // When there is an acceptor to the left but no acceptor to the right,
|
||||
// // do a turn to the left
|
||||
// if (hasLeftAcceptor && !hasRightAcceptor) {
|
||||
// return {
|
||||
// rotation,
|
||||
// rotationVariant: 1,
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
return {
|
||||
rotation,
|
||||
rotationVariant: 0,
|
||||
};
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Belt";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Transports items, hold and drag to place multiple, press 'R' to rotate.";
|
||||
}
|
||||
|
||||
getPreviewSprite(rotationVariant) {
|
||||
switch (arrayBeltVariantToRotation[rotationVariant]) {
|
||||
case enumDirection.top: {
|
||||
return Loader.getSprite("sprites/belt/forward_0.png");
|
||||
}
|
||||
case enumDirection.left: {
|
||||
return Loader.getSprite("sprites/belt/left_0.png");
|
||||
}
|
||||
case enumDirection.right: {
|
||||
return Loader.getSprite("sprites/belt/right_0.png");
|
||||
}
|
||||
default: {
|
||||
assertAlways(false, "Invalid belt rotation variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStayInPlacementMode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be overridden
|
||||
*/
|
||||
internalGetBeltDirection(rotationVariant) {
|
||||
return enumDirection.top;
|
||||
}
|
||||
}
|
||||
71
src/js/game/buildings/cutter.js
Normal file
71
src/js/game/buildings/cutter.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { enumItemAcceptorItemFilter, ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaCutterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("cutter");
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#7dcda2";
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Cut Half";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Cuts shapes from top to bottom and outputs both halfs. <strong>If you use only one part, be sure to destroy the other part or it will stall!</strong>";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_cutter_and_trash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.cutter,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [
|
||||
{ pos: new Vector(0, 0), direction: enumDirection.top },
|
||||
{ pos: new Vector(1, 0), direction: enumDirection.top },
|
||||
],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
125
src/js/game/buildings/hub.js
Normal file
125
src/js/game/buildings/hub.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { enumItemAcceptorItemFilter, ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { ItemProcessorComponent, enumItemProcessorTypes } from "../components/item_processor";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { UnremovableComponent } from "../components/unremovable";
|
||||
import { HubComponent } from "../components/hub";
|
||||
|
||||
export class MetaHubBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("hub");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(4, 4);
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#eb5555";
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Hub";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Your central hub, deliver shapes to it to unlock new buildings.";
|
||||
}
|
||||
|
||||
isRotateable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(new HubComponent());
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.hub,
|
||||
})
|
||||
);
|
||||
entity.addComponent(new UnremovableComponent());
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.top, enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.top],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(2, 0),
|
||||
directions: [enumDirection.top],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 0),
|
||||
directions: [enumDirection.top, enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 3),
|
||||
directions: [enumDirection.bottom, enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 3),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(2, 3),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 3),
|
||||
directions: [enumDirection.bottom, enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 1),
|
||||
directions: [enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 2),
|
||||
directions: [enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(0, 3),
|
||||
directions: [enumDirection.left],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 1),
|
||||
directions: [enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 2),
|
||||
directions: [enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(3, 3),
|
||||
directions: [enumDirection.right],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/js/game/buildings/miner.js
Normal file
36
src/js/game/buildings/miner.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { MinerComponent } from "../components/miner";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
|
||||
export class MetaMinerBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("miner");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Extract";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#b37dcd";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Place over a shape or color to extract it. Six extractors fill exactly one belt.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(new MinerComponent({}));
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/mixer.js
Normal file
73
src/js/game/buildings/mixer.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent, enumItemAcceptorItemFilter } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaMixerBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("mixer");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Mix Colors";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Mixes two colors using additive blending.";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#cdbb7d";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_mixer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 2,
|
||||
processorType: enumItemProcessorTypes.mixer,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.color,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.color,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/painter.js
Normal file
73
src/js/game/buildings/painter.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { enumItemAcceptorItemFilter, ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export class MetaPainterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("painter");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Dye";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Colors the whole shape on the left input with the color from the right input.";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#cd9b7d";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_painter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 2,
|
||||
processorType: enumItemProcessorTypes.painter,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.color,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/js/game/buildings/rotater.js
Normal file
64
src/js/game/buildings/rotater.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent, enumItemAcceptorItemFilter } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export class MetaRotaterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("rotater");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Rotate";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Rotates shapes clockwise by 90 degrees.";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#7dc6cd";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_rotater);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.rotater,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/js/game/buildings/splitter.js
Normal file
80
src/js/game/buildings/splitter.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaSplitterBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("splitter");
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Distribute";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#444";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Accepts up to two inputs and evenly distributes them on the outputs. Can also be used to merge two inputs into one output.";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_splitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.splitter,
|
||||
|
||||
beltUnderlays: [
|
||||
{ pos: new Vector(0, 0), direction: enumDirection.top },
|
||||
{ pos: new Vector(1, 0), direction: enumDirection.top },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [
|
||||
{ pos: new Vector(0, 0), direction: enumDirection.top },
|
||||
{ pos: new Vector(1, 0), direction: enumDirection.top },
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/stacker.js
Normal file
73
src/js/game/buildings/stacker.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent, enumItemAcceptorItemFilter } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
export class MetaStackerBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("stacker");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Combine";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#9fcd7d";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Combines both items. If they can not be merged, the right item is placed above the left item.";
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return new Vector(2, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_stacker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 2,
|
||||
processorType: enumItemProcessorTypes.stacker,
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }],
|
||||
})
|
||||
);
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
{
|
||||
pos: new Vector(1, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
filter: enumItemAcceptorItemFilter.shape,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/js/game/buildings/trash.js
Normal file
73
src/js/game/buildings/trash.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
export class MetaTrashBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("trash");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Destroyer";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Accepts inputs from all sides and destroys them. Forever.";
|
||||
}
|
||||
|
||||
isRotateable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#cd7d86";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_cutter_and_trash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
entity.addComponent(
|
||||
new ItemProcessorComponent({
|
||||
inputsPerCharge: 1,
|
||||
processorType: enumItemProcessorTypes.trash,
|
||||
})
|
||||
);
|
||||
|
||||
// Required, since the item processor needs this.
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [
|
||||
enumDirection.top,
|
||||
enumDirection.right,
|
||||
enumDirection.bottom,
|
||||
enumDirection.left,
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
158
src/js/game/buildings/underground_belt.js
Normal file
158
src/js/game/buildings/underground_belt.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Loader } from "../../core/loader";
|
||||
import { enumDirection, Vector, enumAngleToDirection, enumDirectionToVector } from "../../core/vector";
|
||||
import { ItemAcceptorComponent } from "../components/item_acceptor";
|
||||
import { ItemEjectorComponent } from "../components/item_ejector";
|
||||
import { enumUndergroundBeltMode, UndergroundBeltComponent } from "../components/underground_belt";
|
||||
import { Entity } from "../entity";
|
||||
import { MetaBuilding } from "../meta_building";
|
||||
import { GameRoot } from "../root";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { enumHubGoalRewards } from "../tutorial_goals";
|
||||
|
||||
/** @enum {string} */
|
||||
export const arrayUndergroundRotationVariantToMode = [
|
||||
enumUndergroundBeltMode.sender,
|
||||
enumUndergroundBeltMode.receiver,
|
||||
];
|
||||
|
||||
export class MetaUndergroundBeltBuilding extends MetaBuilding {
|
||||
constructor() {
|
||||
super("underground_belt");
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "Tunnel";
|
||||
}
|
||||
|
||||
getSilhouetteColor() {
|
||||
return "#555";
|
||||
}
|
||||
|
||||
getDescription() {
|
||||
return "Allows to tunnel resources under buildings and belts.";
|
||||
}
|
||||
|
||||
getFlipOrientationAfterPlacement() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getStayInPlacementMode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getPreviewSprite(rotationVariant) {
|
||||
switch (arrayUndergroundRotationVariantToMode[rotationVariant]) {
|
||||
case enumUndergroundBeltMode.sender:
|
||||
return Loader.getSprite("sprites/buildings/underground_belt_entry.png");
|
||||
case enumUndergroundBeltMode.receiver:
|
||||
return Loader.getSprite("sprites/buildings/underground_belt_exit.png");
|
||||
default:
|
||||
assertAlways(false, "Invalid rotation variant");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_tunnel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setupEntityComponents(entity) {
|
||||
// Required, since the item processor needs this.
|
||||
entity.addComponent(
|
||||
new ItemEjectorComponent({
|
||||
slots: [],
|
||||
})
|
||||
);
|
||||
|
||||
entity.addComponent(new UndergroundBeltComponent({}));
|
||||
entity.addComponent(
|
||||
new ItemAcceptorComponent({
|
||||
slots: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
* @param {Vector} tile
|
||||
* @param {number} rotation
|
||||
* @return {{ rotation: number, rotationVariant: number, connectedEntities?: Array<Entity> }}
|
||||
*/
|
||||
computeOptimalDirectionAndRotationVariantAtTile(root, tile, rotation) {
|
||||
const searchDirection = enumAngleToDirection[rotation];
|
||||
const searchVector = enumDirectionToVector[searchDirection];
|
||||
|
||||
const targetRotation = (rotation + 180) % 360;
|
||||
|
||||
for (let searchOffset = 1; searchOffset <= globalConfig.undergroundBeltMaxTiles; ++searchOffset) {
|
||||
tile = tile.addScalars(searchVector.x, searchVector.y);
|
||||
|
||||
const contents = root.map.getTileContent(tile);
|
||||
if (contents) {
|
||||
const undergroundComp = contents.components.UndergroundBelt;
|
||||
if (undergroundComp) {
|
||||
const staticComp = contents.components.StaticMapEntity;
|
||||
if (staticComp.rotationDegrees === targetRotation) {
|
||||
if (undergroundComp.mode !== enumUndergroundBeltMode.sender) {
|
||||
// If we encounter an underground receiver on our way which is also faced in our direction, we don't accept that
|
||||
break;
|
||||
}
|
||||
// console.log("GOT IT! rotation is", rotation, "and target is", staticComp.rotationDegrees);
|
||||
|
||||
return {
|
||||
rotation: targetRotation,
|
||||
rotationVariant: 1,
|
||||
connectedEntities: [contents],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rotation,
|
||||
rotationVariant: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Entity} entity
|
||||
* @param {number} rotationVariant
|
||||
*/
|
||||
updateRotationVariant(entity, rotationVariant) {
|
||||
entity.components.StaticMapEntity.spriteKey = this.getPreviewSprite(rotationVariant).spriteName;
|
||||
|
||||
switch (arrayUndergroundRotationVariantToMode[rotationVariant]) {
|
||||
case enumUndergroundBeltMode.sender: {
|
||||
entity.components.UndergroundBelt.mode = enumUndergroundBeltMode.sender;
|
||||
entity.components.ItemEjector.setSlots([]);
|
||||
entity.components.ItemAcceptor.setSlots([
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
directions: [enumDirection.bottom],
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
case enumUndergroundBeltMode.receiver: {
|
||||
entity.components.UndergroundBelt.mode = enumUndergroundBeltMode.receiver;
|
||||
entity.components.ItemAcceptor.setSlots([]);
|
||||
entity.components.ItemEjector.setSlots([
|
||||
{
|
||||
pos: new Vector(0, 0),
|
||||
direction: enumDirection.top,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
assertAlways(false, "Invalid rotation variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
870
src/js/game/camera.js
Normal file
870
src/js/game/camera.js
Normal file
@@ -0,0 +1,870 @@
|
||||
import {
|
||||
Math_abs,
|
||||
Math_ceil,
|
||||
Math_floor,
|
||||
Math_min,
|
||||
Math_random,
|
||||
performanceNow,
|
||||
Math_max,
|
||||
} from "../core/builtins";
|
||||
import { Rectangle } from "../core/rectangle";
|
||||
import { Signal, STOP_PROPAGATION } from "../core/signal";
|
||||
import { clamp, lerp } from "../core/utils";
|
||||
import { mixVector, Vector } from "../core/vector";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { GameRoot } from "./root";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
import { clickDetectorGlobals } from "../core/click_detector";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("camera");
|
||||
|
||||
export const USER_INTERACT_MOVE = "move";
|
||||
export const USER_INTERACT_ZOOM = "zoom";
|
||||
export const USER_INTERACT_TOUCHEND = "touchend";
|
||||
|
||||
const velocitySmoothing = 0.5;
|
||||
const velocityFade = 0.98;
|
||||
const velocityStrength = 0.4;
|
||||
const velocityMax = 20;
|
||||
|
||||
export class Camera extends BasicSerializableObject {
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
// Zoom level, 2 means double size
|
||||
|
||||
// Find optimal initial zoom
|
||||
|
||||
this.zoomLevel = this.findInitialZoom();
|
||||
this.clampZoomLevel();
|
||||
|
||||
/** @type {Vector} */
|
||||
this.center = new Vector(0, 0);
|
||||
|
||||
// Input handling
|
||||
this.currentlyMoving = false;
|
||||
this.lastMovingPosition = null;
|
||||
this.cameraUpdateTimeBucket = 0.0;
|
||||
this.didMoveSinceTouchStart = false;
|
||||
this.currentlyPinching = false;
|
||||
this.lastPinchPositions = null;
|
||||
|
||||
this.keyboardForce = new Vector();
|
||||
|
||||
// Signal which gets emitted once the user changed something
|
||||
this.userInteraction = new Signal();
|
||||
|
||||
/** @type {Vector} */
|
||||
this.currentShake = new Vector(0, 0);
|
||||
|
||||
/** @type {Vector} */
|
||||
this.currentPan = new Vector(0, 0);
|
||||
|
||||
// Set desired pan (camera movement)
|
||||
/** @type {Vector} */
|
||||
this.desiredPan = new Vector(0, 0);
|
||||
|
||||
// Set desired camera center
|
||||
/** @type {Vector} */
|
||||
this.desiredCenter = null;
|
||||
|
||||
// Set desired camera zoom
|
||||
/** @type {number} */
|
||||
this.desiredZoom = null;
|
||||
|
||||
/** @type {Vector} */
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
|
||||
// Handlers
|
||||
this.downPreHandler = new Signal(/* pos */);
|
||||
this.movePreHandler = new Signal(/* pos */);
|
||||
this.pinchPreHandler = new Signal(/* pos */);
|
||||
this.upPostHandler = new Signal(/* pos */);
|
||||
|
||||
this.internalInitEvents();
|
||||
this.clampZoomLevel();
|
||||
this.bindKeys();
|
||||
}
|
||||
|
||||
// Serialization
|
||||
static getId() {
|
||||
return "Camera";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
zoomLevel: types.float,
|
||||
center: types.vector,
|
||||
};
|
||||
}
|
||||
|
||||
deserialize(data) {
|
||||
const errorCode = super.deserialize(data);
|
||||
if (errorCode) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
// Safety
|
||||
this.clampZoomLevel();
|
||||
}
|
||||
|
||||
// Simple geters & setters
|
||||
|
||||
addScreenShake(amount) {
|
||||
const currentShakeAmount = this.currentShake.length();
|
||||
const scale = 1 / (1 + 3 * currentShakeAmount);
|
||||
this.currentShake.x = this.currentShake.x + 2 * (Math_random() - 0.5) * scale * amount;
|
||||
this.currentShake.y = this.currentShake.y + 2 * (Math_random() - 0.5) * scale * amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a point in world space to focus on
|
||||
* @param {Vector} center
|
||||
*/
|
||||
setDesiredCenter(center) {
|
||||
this.desiredCenter = center.copy();
|
||||
this.currentlyMoving = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this camera is currently moving by a non-user interaction
|
||||
*/
|
||||
isCurrentlyMovingToDesiredCenter() {
|
||||
return this.desiredCenter !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the camera pan, every frame the camera will move by this amount
|
||||
* @param {Vector} pan
|
||||
*/
|
||||
setPan(pan) {
|
||||
this.desiredPan = pan.copy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a good initial zoom level
|
||||
*/
|
||||
findInitialZoom() {
|
||||
return 3;
|
||||
const desiredWorldSpaceWidth = 20 * globalConfig.tileSize;
|
||||
const zoomLevelX = this.root.gameWidth / desiredWorldSpaceWidth;
|
||||
const zoomLevelY = this.root.gameHeight / desiredWorldSpaceWidth;
|
||||
|
||||
const finalLevel = Math_min(zoomLevelX, zoomLevelY);
|
||||
assert(
|
||||
Number.isFinite(finalLevel) && finalLevel > 0,
|
||||
"Invalid zoom level computed for initial zoom: " + finalLevel
|
||||
);
|
||||
return finalLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all animations
|
||||
*/
|
||||
clearAnimations() {
|
||||
this.touchPostMoveVelocity.x = 0;
|
||||
this.touchPostMoveVelocity.y = 0;
|
||||
this.desiredCenter = null;
|
||||
this.desiredPan.x = 0;
|
||||
this.desiredPan.y = 0;
|
||||
this.currentPan.x = 0;
|
||||
this.currentPan.y = 0;
|
||||
this.currentlyPinching = false;
|
||||
this.currentlyMoving = false;
|
||||
this.lastMovingPosition = null;
|
||||
this.didMoveSinceTouchStart = false;
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the user is currently interacting with the camera
|
||||
* @returns {boolean} true if the user interacts
|
||||
*/
|
||||
isCurrentlyInteracting() {
|
||||
if (this.currentlyPinching) {
|
||||
return true;
|
||||
}
|
||||
if (this.currentlyMoving) {
|
||||
// Only interacting if moved at least once
|
||||
return this.didMoveSinceTouchStart;
|
||||
}
|
||||
if (this.touchPostMoveVelocity.lengthSquare() > 1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if in the next frame the viewport will change
|
||||
* @returns {boolean} true if it willchange
|
||||
*/
|
||||
viewportWillChange() {
|
||||
return this.desiredCenter !== null || this.desiredZoom !== null || this.isCurrentlyInteracting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all interactions, that is user interaction and non user interaction
|
||||
*/
|
||||
cancelAllInteractions() {
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
this.desiredCenter = null;
|
||||
this.currentlyMoving = false;
|
||||
this.currentlyPinching = false;
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective viewport width
|
||||
*/
|
||||
getViewportWidth() {
|
||||
return this.root.gameWidth / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective viewport height
|
||||
*/
|
||||
getViewportHeight() {
|
||||
return this.root.gameHeight / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport left
|
||||
*/
|
||||
getViewportLeft() {
|
||||
return this.center.x - this.getViewportWidth() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport right
|
||||
*/
|
||||
getViewportRight() {
|
||||
return this.center.x + this.getViewportWidth() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport top
|
||||
*/
|
||||
getViewportTop() {
|
||||
return this.center.y - this.getViewportHeight() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns effective world space viewport bottom
|
||||
*/
|
||||
getViewportBottom() {
|
||||
return this.center.y + this.getViewportHeight() / 2 + (this.currentShake.x * 10) / this.zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the visible world space rect
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
getVisibleRect() {
|
||||
return Rectangle.fromTRBL(
|
||||
Math_floor(this.getViewportTop()),
|
||||
Math_ceil(this.getViewportRight()),
|
||||
Math_ceil(this.getViewportBottom()),
|
||||
Math_floor(this.getViewportLeft())
|
||||
);
|
||||
}
|
||||
|
||||
getIsMapOverlayActive() {
|
||||
return this.zoomLevel < globalConfig.mapChunkOverviewMinZoom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches all event listeners
|
||||
*/
|
||||
internalInitEvents() {
|
||||
this.eventListenerTouchStart = this.onTouchStart.bind(this);
|
||||
this.eventListenerTouchEnd = this.onTouchEnd.bind(this);
|
||||
this.eventListenerTouchMove = this.onTouchMove.bind(this);
|
||||
this.eventListenerMousewheel = this.onMouseWheel.bind(this);
|
||||
this.eventListenerMouseDown = this.onMouseDown.bind(this);
|
||||
this.eventListenerMouseMove = this.onMouseMove.bind(this);
|
||||
this.eventListenerMouseUp = this.onMouseUp.bind(this);
|
||||
|
||||
this.root.canvas.addEventListener("touchstart", this.eventListenerTouchStart);
|
||||
this.root.canvas.addEventListener("touchend", this.eventListenerTouchEnd);
|
||||
this.root.canvas.addEventListener("touchcancel", this.eventListenerTouchEnd);
|
||||
this.root.canvas.addEventListener("touchmove", this.eventListenerTouchMove);
|
||||
|
||||
this.root.canvas.addEventListener("wheel", this.eventListenerMousewheel);
|
||||
this.root.canvas.addEventListener("mousedown", this.eventListenerMouseDown);
|
||||
this.root.canvas.addEventListener("mousemove", this.eventListenerMouseMove);
|
||||
this.root.canvas.addEventListener("mouseup", this.eventListenerMouseUp);
|
||||
this.root.canvas.addEventListener("mouseout", this.eventListenerMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all event listeners
|
||||
*/
|
||||
cleanup() {
|
||||
this.root.canvas.removeEventListener("touchstart", this.eventListenerTouchStart);
|
||||
this.root.canvas.removeEventListener("touchend", this.eventListenerTouchEnd);
|
||||
this.root.canvas.removeEventListener("touchcancel", this.eventListenerTouchEnd);
|
||||
this.root.canvas.removeEventListener("touchmove", this.eventListenerTouchMove);
|
||||
|
||||
this.root.canvas.removeEventListener("wheel", this.eventListenerMousewheel);
|
||||
this.root.canvas.removeEventListener("mousedown", this.eventListenerMouseDown);
|
||||
this.root.canvas.removeEventListener("mousemove", this.eventListenerMouseMove);
|
||||
this.root.canvas.removeEventListener("mouseup", this.eventListenerMouseUp);
|
||||
this.root.canvas.removeEventListener("mouseout", this.eventListenerMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the arrow keys
|
||||
*/
|
||||
bindKeys() {
|
||||
const mapper = this.root.gameState.keyActionMapper;
|
||||
mapper.getBinding("map_move_up").add(() => (this.keyboardForce.y = -1));
|
||||
mapper.getBinding("map_move_down").add(() => (this.keyboardForce.y = 1));
|
||||
mapper.getBinding("map_move_right").add(() => (this.keyboardForce.x = 1));
|
||||
mapper.getBinding("map_move_left").add(() => (this.keyboardForce.x = -1));
|
||||
|
||||
mapper.getBinding("center_map").add(() => (this.desiredCenter = new Vector(0, 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from screen to world space
|
||||
* @param {Vector} screen
|
||||
* @returns {Vector} world space
|
||||
*/
|
||||
screenToWorld(screen) {
|
||||
const centerSpace = screen.subScalars(this.root.gameWidth / 2, this.root.gameHeight / 2);
|
||||
return centerSpace.divideScalar(this.zoomLevel).add(this.center);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from world to screen space
|
||||
* @param {Vector} world
|
||||
* @returns {Vector} screen space
|
||||
*/
|
||||
worldToScreen(world) {
|
||||
const screenSpace = world.sub(this.center).multiplyScalar(this.zoomLevel);
|
||||
return screenSpace.addScalars(this.root.gameWidth / 2, this.root.gameHeight / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if a point is on screen
|
||||
* @param {Vector} point
|
||||
* @returns {boolean} true if its on screen
|
||||
*/
|
||||
isWorldPointOnScreen(point) {
|
||||
const rect = this.getVisibleRect();
|
||||
return rect.containsPoint(point.x, point.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if we can further zoom in
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canZoomIn() {
|
||||
const maxLevel = this.root.app.platformWrapper.getMaximumZoom();
|
||||
return this.zoomLevel <= maxLevel - 0.01;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if we can further zoom out
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canZoomOut() {
|
||||
const minLevel = this.root.app.platformWrapper.getMinimumZoom();
|
||||
return this.zoomLevel >= minLevel + 0.01;
|
||||
}
|
||||
|
||||
// EVENTS
|
||||
|
||||
/**
|
||||
* Checks if the mouse event is too close after a touch event and thus
|
||||
* should get ignored
|
||||
*/
|
||||
checkPreventDoubleMouse() {
|
||||
if (performanceNow() - clickDetectorGlobals.lastTouchTime < 1000.0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mousedown handler
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
onMouseDown(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
if (!this.checkPreventDoubleMouse()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
if (event.which === 1) {
|
||||
this.combinedSingleTouchStartHandler(event.clientX, event.clientY);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mousemove handler
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
onMouseMove(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
if (!this.checkPreventDoubleMouse()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.which === 1) {
|
||||
this.combinedSingleTouchMoveHandler(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
// Clamp everything afterwards
|
||||
this.clampZoomLevel();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouseup handler
|
||||
* @param {MouseEvent=} event
|
||||
*/
|
||||
onMouseUp(event) {
|
||||
if (event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.checkPreventDoubleMouse()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.combinedSingleTouchStopHandler(event.clientX, event.clientY);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mousewheel event
|
||||
* @param {WheelEvent} event
|
||||
*/
|
||||
onMouseWheel(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
const delta = Math.sign(event.deltaY) * -0.15;
|
||||
assert(Number.isFinite(delta), "Got invalid delta in mouse wheel event: " + event.deltaY);
|
||||
assert(Number.isFinite(this.zoomLevel), "Got invalid zoom level *before* wheel: " + this.zoomLevel);
|
||||
this.zoomLevel *= 1 + delta;
|
||||
assert(Number.isFinite(this.zoomLevel), "Got invalid zoom level *after* wheel: " + this.zoomLevel);
|
||||
|
||||
this.clampZoomLevel();
|
||||
this.desiredZoom = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch start handler
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
onTouchStart(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
this.combinedSingleTouchStartHandler(touch.clientX, touch.clientY);
|
||||
} else if (event.touches.length === 2) {
|
||||
if (this.pinchPreHandler.dispatch() === STOP_PROPAGATION) {
|
||||
// Something prevented pinching
|
||||
return false;
|
||||
}
|
||||
|
||||
const touch1 = event.touches[0];
|
||||
const touch2 = event.touches[1];
|
||||
this.currentlyMoving = false;
|
||||
this.currentlyPinching = true;
|
||||
this.lastPinchPositions = [
|
||||
new Vector(touch1.clientX, touch1.clientY),
|
||||
new Vector(touch2.clientX, touch2.clientY),
|
||||
];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch move handler
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
onTouchMove(event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
this.combinedSingleTouchMoveHandler(touch.clientX, touch.clientY);
|
||||
} else if (event.touches.length === 2) {
|
||||
if (this.currentlyPinching) {
|
||||
const touch1 = event.touches[0];
|
||||
const touch2 = event.touches[1];
|
||||
|
||||
const newPinchPositions = [
|
||||
new Vector(touch1.clientX, touch1.clientY),
|
||||
new Vector(touch2.clientX, touch2.clientY),
|
||||
];
|
||||
|
||||
// Get distance of taps last time and now
|
||||
const lastDistance = this.lastPinchPositions[0].distance(this.lastPinchPositions[1]);
|
||||
const thisDistance = newPinchPositions[0].distance(newPinchPositions[1]);
|
||||
|
||||
// IMPORTANT to do math max here to avoid NaN and causing an invalid zoom level
|
||||
const difference = thisDistance / Math_max(0.001, lastDistance);
|
||||
|
||||
// Find old center of zoom
|
||||
let oldCenter = this.lastPinchPositions[0].centerPoint(this.lastPinchPositions[1]);
|
||||
|
||||
// Find new center of zoom
|
||||
let center = newPinchPositions[0].centerPoint(newPinchPositions[1]);
|
||||
|
||||
// Compute movement
|
||||
let movement = oldCenter.sub(center);
|
||||
this.center.x += movement.x / this.zoomLevel;
|
||||
this.center.y += movement.y / this.zoomLevel;
|
||||
|
||||
// Compute zoom
|
||||
center = center.sub(new Vector(this.root.gameWidth / 2, this.root.gameHeight / 2));
|
||||
|
||||
// Apply zoom
|
||||
assert(
|
||||
Number.isFinite(difference),
|
||||
"Invalid pinch difference: " +
|
||||
difference +
|
||||
"(last=" +
|
||||
lastDistance +
|
||||
", new = " +
|
||||
thisDistance +
|
||||
")"
|
||||
);
|
||||
this.zoomLevel *= difference;
|
||||
|
||||
// Stick to pivot point
|
||||
const correcture = center.multiplyScalar(difference - 1).divideScalar(this.zoomLevel);
|
||||
|
||||
this.center = this.center.add(correcture);
|
||||
this.lastPinchPositions = newPinchPositions;
|
||||
this.userInteraction.dispatch(USER_INTERACT_MOVE);
|
||||
|
||||
// Since we zoomed, abort any programmed zooming
|
||||
if (this.desiredZoom) {
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp everything afterwards
|
||||
this.clampZoomLevel();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch end and cancel handler
|
||||
* @param {TouchEvent=} event
|
||||
*/
|
||||
onTouchEnd(event) {
|
||||
if (event) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
clickDetectorGlobals.lastTouchTime = performanceNow();
|
||||
if (event.changedTouches.length === 0) {
|
||||
logger.warn("Touch end without changed touches");
|
||||
}
|
||||
|
||||
const touch = event.changedTouches[0];
|
||||
this.combinedSingleTouchStopHandler(touch.clientX, touch.clientY);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch start handler
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
combinedSingleTouchStartHandler(x, y) {
|
||||
const pos = new Vector(x, y);
|
||||
if (this.downPreHandler.dispatch(pos) === STOP_PROPAGATION) {
|
||||
// Somebody else captured it
|
||||
return;
|
||||
}
|
||||
|
||||
this.touchPostMoveVelocity = new Vector(0, 0);
|
||||
this.currentlyMoving = true;
|
||||
this.lastMovingPosition = pos;
|
||||
this.didMoveSinceTouchStart = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch move handler
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
combinedSingleTouchMoveHandler(x, y) {
|
||||
const pos = new Vector(x, y);
|
||||
if (this.movePreHandler.dispatch(pos) === STOP_PROPAGATION) {
|
||||
// Somebody else captured it
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.currentlyMoving) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let delta = this.lastMovingPosition.sub(pos).divideScalar(this.zoomLevel);
|
||||
if (G_IS_DEV && globalConfig.debug.testCulling) {
|
||||
// When testing culling, we see everything from the same distance
|
||||
delta = delta.multiplyScalar(this.zoomLevel * -2);
|
||||
}
|
||||
|
||||
this.didMoveSinceTouchStart = this.didMoveSinceTouchStart || delta.length() > 0;
|
||||
this.center = this.center.add(delta);
|
||||
|
||||
this.touchPostMoveVelocity = this.touchPostMoveVelocity
|
||||
.multiplyScalar(velocitySmoothing)
|
||||
.add(delta.multiplyScalar(1 - velocitySmoothing));
|
||||
|
||||
this.lastMovingPosition = pos;
|
||||
this.userInteraction.dispatch(USER_INTERACT_MOVE);
|
||||
|
||||
// Since we moved, abort any programmed moving
|
||||
if (this.desiredCenter) {
|
||||
this.desiredCenter = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal touch stop handler
|
||||
*/
|
||||
combinedSingleTouchStopHandler(x, y) {
|
||||
if (this.currentlyMoving || this.currentlyPinching) {
|
||||
this.currentlyMoving = false;
|
||||
this.currentlyPinching = false;
|
||||
this.lastMovingPosition = null;
|
||||
this.lastPinchPositions = null;
|
||||
this.userInteraction.dispatch(USER_INTERACT_TOUCHEND);
|
||||
this.didMoveSinceTouchStart = false;
|
||||
}
|
||||
this.upPostHandler.dispatch(new Vector(x, y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps the camera zoom level within the allowed range
|
||||
*/
|
||||
clampZoomLevel() {
|
||||
if (G_IS_DEV && globalConfig.debug.disableZoomLimits) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = this.root.app.platformWrapper;
|
||||
|
||||
assert(Number.isFinite(this.zoomLevel), "Invalid zoom level *before* clamp: " + this.zoomLevel);
|
||||
this.zoomLevel = clamp(this.zoomLevel, wrapper.getMinimumZoom(), wrapper.getMaximumZoom());
|
||||
assert(Number.isFinite(this.zoomLevel), "Invalid zoom level *after* clamp: " + this.zoomLevel);
|
||||
|
||||
if (this.desiredZoom) {
|
||||
this.desiredZoom = clamp(this.desiredZoom, wrapper.getMinimumZoom(), wrapper.getMaximumZoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the camera
|
||||
* @param {number} dt Delta time in milliseconds
|
||||
*/
|
||||
update(dt) {
|
||||
dt = Math_min(dt, 33);
|
||||
this.cameraUpdateTimeBucket += dt;
|
||||
|
||||
// Simulate movement of N FPS
|
||||
const updatesPerFrame = 4;
|
||||
const physicsStepSizeMs = 1000.0 / (60.0 * updatesPerFrame);
|
||||
|
||||
let now = this.root.time.systemNow() - 3 * physicsStepSizeMs;
|
||||
|
||||
while (this.cameraUpdateTimeBucket > physicsStepSizeMs) {
|
||||
now += physicsStepSizeMs;
|
||||
this.cameraUpdateTimeBucket -= physicsStepSizeMs;
|
||||
|
||||
this.internalUpdatePanning(now, physicsStepSizeMs);
|
||||
this.internalUpdateZooming(now, physicsStepSizeMs);
|
||||
this.internalUpdateCentering(now, physicsStepSizeMs);
|
||||
this.internalUpdateShake(now, physicsStepSizeMs);
|
||||
this.internalUpdateKeyboardForce(now, physicsStepSizeMs);
|
||||
}
|
||||
this.clampZoomLevel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a context to transform it
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
*/
|
||||
transform(context) {
|
||||
if (G_IS_DEV && globalConfig.debug.testCulling) {
|
||||
context.transform(1, 0, 0, 1, 100, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clampZoomLevel();
|
||||
const zoom = this.zoomLevel;
|
||||
|
||||
context.transform(
|
||||
// Scale, skew, rotate
|
||||
zoom,
|
||||
0,
|
||||
0,
|
||||
zoom,
|
||||
|
||||
// Translate
|
||||
-zoom * this.getViewportLeft(),
|
||||
-zoom * this.getViewportTop()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal shake handler
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateShake(now, dt) {
|
||||
this.currentShake = this.currentShake.multiplyScalar(0.92);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pan handler
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdatePanning(now, dt) {
|
||||
const baseStrength = velocityStrength * this.root.app.platformWrapper.getTouchPanStrength();
|
||||
|
||||
this.touchPostMoveVelocity = this.touchPostMoveVelocity.multiplyScalar(velocityFade);
|
||||
|
||||
// Check influence of past points
|
||||
if (!this.currentlyMoving && !this.currentlyPinching) {
|
||||
const len = this.touchPostMoveVelocity.length();
|
||||
if (len >= velocityMax) {
|
||||
this.touchPostMoveVelocity.x = (this.touchPostMoveVelocity.x * velocityMax) / len;
|
||||
this.touchPostMoveVelocity.y = (this.touchPostMoveVelocity.y * velocityMax) / len;
|
||||
}
|
||||
|
||||
this.center = this.center.add(this.touchPostMoveVelocity.multiplyScalar(baseStrength));
|
||||
|
||||
// Panning
|
||||
this.currentPan = mixVector(this.currentPan, this.desiredPan, 0.06);
|
||||
this.center = this.center.add(this.currentPan.multiplyScalar((0.5 * dt) / this.zoomLevel));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the non user interaction zooming
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateZooming(now, dt) {
|
||||
if (!this.currentlyPinching && this.desiredZoom !== null) {
|
||||
const diff = this.zoomLevel - this.desiredZoom;
|
||||
if (Math_abs(diff) > 0.05) {
|
||||
let fade = 0.94;
|
||||
if (diff > 0) {
|
||||
// Zoom out faster than in
|
||||
fade = 0.9;
|
||||
}
|
||||
|
||||
assert(Number.isFinite(this.desiredZoom), "Desired zoom is NaN: " + this.desiredZoom);
|
||||
assert(Number.isFinite(fade), "Zoom fade is NaN: " + fade);
|
||||
this.zoomLevel = this.zoomLevel * fade + this.desiredZoom * (1 - fade);
|
||||
assert(Number.isFinite(this.zoomLevel), "Zoom level is NaN after fade: " + this.zoomLevel);
|
||||
} else {
|
||||
this.desiredZoom = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the non user interaction centering
|
||||
* @param {number} now Time now in seconds
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateCentering(now, dt) {
|
||||
if (!this.currentlyMoving && this.desiredCenter !== null) {
|
||||
const diff = this.center.direction(this.desiredCenter);
|
||||
const length = diff.length();
|
||||
const tolerance = 1 / this.zoomLevel;
|
||||
if (length > tolerance) {
|
||||
const movement = diff.multiplyScalar(Math_min(1, dt * 0.008));
|
||||
this.center.x += movement.x;
|
||||
this.center.y += movement.y;
|
||||
} else {
|
||||
this.desiredCenter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the keyboard forces
|
||||
* @param {number} now
|
||||
* @param {number} dt Delta time
|
||||
*/
|
||||
internalUpdateKeyboardForce(now, dt) {
|
||||
if (!this.currentlyMoving && this.desiredCenter == null) {
|
||||
const limitingDimension = Math_min(this.root.gameWidth, this.root.gameHeight);
|
||||
|
||||
const moveAmount = ((limitingDimension / 2048) * dt) / this.zoomLevel;
|
||||
|
||||
let forceX = 0;
|
||||
let forceY = 0;
|
||||
|
||||
const actionMapper = this.root.gameState.keyActionMapper;
|
||||
if (actionMapper.getBinding("map_move_up").currentlyDown) {
|
||||
forceY -= 1;
|
||||
}
|
||||
|
||||
if (actionMapper.getBinding("map_move_down").currentlyDown) {
|
||||
forceY += 1;
|
||||
}
|
||||
|
||||
if (actionMapper.getBinding("map_move_left").currentlyDown) {
|
||||
forceX -= 1;
|
||||
}
|
||||
|
||||
if (actionMapper.getBinding("map_move_right").currentlyDown) {
|
||||
forceX += 1;
|
||||
}
|
||||
|
||||
this.center.x += moveAmount * forceX;
|
||||
this.center.y += moveAmount * forceY;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/js/game/canvas_click_interceptor.js
Normal file
70
src/js/game/canvas_click_interceptor.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { STOP_PROPAGATION } from "../core/signal";
|
||||
import { GameRoot } from "./root";
|
||||
import { ClickDetector } from "../core/click_detector";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("canvas_click_interceptor");
|
||||
|
||||
export class CanvasClickInterceptor {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
this.root.signals.postLoadHook.add(this.initialize, this);
|
||||
this.root.signals.aboutToDestruct.add(this.cleanup, this);
|
||||
|
||||
/** @type {Array<object>} */
|
||||
this.interceptors = [];
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.clickDetector = new ClickDetector(this.root.canvas, {
|
||||
applyCssClass: null,
|
||||
captureTouchmove: false,
|
||||
targetOnly: true,
|
||||
preventDefault: true,
|
||||
maxDistance: 13,
|
||||
clickSound: null,
|
||||
});
|
||||
this.clickDetector.click.add(this.onCanvasClick, this);
|
||||
this.clickDetector.rightClick.add(this.onCanvasRightClick, this);
|
||||
|
||||
if (this.root.hud.parts.buildingPlacer) {
|
||||
this.interceptors.push(this.root.hud.parts.buildingPlacer);
|
||||
}
|
||||
|
||||
logger.log("Registered", this.interceptors.length, "interceptors");
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.clickDetector) {
|
||||
this.clickDetector.cleanup();
|
||||
}
|
||||
this.interceptors = [];
|
||||
}
|
||||
|
||||
onCanvasClick(position, event, cancelAction = false) {
|
||||
if (!this.root.gameInitialized) {
|
||||
logger.warn("Skipping click outside of game initiaization!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.root.hud.hasBlockingOverlayOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.interceptors.length; ++i) {
|
||||
const interceptor = this.interceptors[i];
|
||||
if (interceptor.onCanvasClick(position, cancelAction) === STOP_PROPAGATION) {
|
||||
// log(this, "Interceptor", interceptor.constructor.name, "catched click");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCanvasRightClick(position, event) {
|
||||
this.onCanvasClick(position, event, true);
|
||||
}
|
||||
}
|
||||
167
src/js/game/colors.js
Normal file
167
src/js/game/colors.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/** @enum {string} */
|
||||
export const enumColors = {
|
||||
red: "red",
|
||||
green: "green",
|
||||
blue: "blue",
|
||||
|
||||
yellow: "yellow",
|
||||
purple: "purple",
|
||||
cyan: "cyan",
|
||||
|
||||
white: "white",
|
||||
uncolored: "uncolored",
|
||||
};
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumColorToShortcode = {
|
||||
[enumColors.red]: "r",
|
||||
[enumColors.green]: "g",
|
||||
[enumColors.blue]: "b",
|
||||
|
||||
[enumColors.yellow]: "y",
|
||||
[enumColors.purple]: "p",
|
||||
[enumColors.cyan]: "c",
|
||||
|
||||
[enumColors.white]: "w",
|
||||
[enumColors.uncolored]: "u",
|
||||
};
|
||||
|
||||
/** @enum {enumColors} */
|
||||
export const enumShortcodeToColor = {};
|
||||
for (const key in enumColorToShortcode) {
|
||||
enumShortcodeToColor[enumColorToShortcode[key]] = key;
|
||||
}
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumColorsToHexCode = {
|
||||
[enumColors.red]: "#ff666a",
|
||||
[enumColors.green]: "#78ff66",
|
||||
[enumColors.blue]: "#66a7ff",
|
||||
|
||||
// red + green
|
||||
[enumColors.yellow]: "#fcf52a",
|
||||
|
||||
// red + blue
|
||||
[enumColors.purple]: "#dd66ff",
|
||||
|
||||
// blue + green
|
||||
[enumColors.cyan]: "#87fff5",
|
||||
|
||||
// blue + green + red
|
||||
[enumColors.white]: "#ffffff",
|
||||
|
||||
[enumColors.uncolored]: "#aaaaaa",
|
||||
};
|
||||
|
||||
const c = enumColors;
|
||||
/** @enum {Object.<string, string>} */
|
||||
export const enumColorMixingResults = {
|
||||
// 255, 0, 0
|
||||
[c.red]: {
|
||||
[c.green]: c.yellow,
|
||||
[c.blue]: c.purple,
|
||||
|
||||
[c.yellow]: c.yellow,
|
||||
[c.purple]: c.purple,
|
||||
[c.cyan]: c.white,
|
||||
|
||||
[c.white]: c.white,
|
||||
},
|
||||
|
||||
// 0, 255, 0
|
||||
[c.green]: {
|
||||
[c.blue]: c.cyan,
|
||||
|
||||
[c.yellow]: c.yellow,
|
||||
[c.purple]: c.white,
|
||||
[c.cyan]: c.cyan,
|
||||
|
||||
[c.white]: c.white,
|
||||
},
|
||||
|
||||
// 0, 255, 0
|
||||
[c.blue]: {
|
||||
[c.yellow]: c.white,
|
||||
[c.purple]: c.purple,
|
||||
[c.cyan]: c.cyan,
|
||||
|
||||
[c.white]: c.white,
|
||||
},
|
||||
|
||||
// 255, 255, 0
|
||||
[c.yellow]: {
|
||||
[c.purple]: c.white,
|
||||
[c.cyan]: c.white,
|
||||
},
|
||||
|
||||
// 255, 0, 255
|
||||
[c.purple]: {
|
||||
[c.cyan]: c.white,
|
||||
},
|
||||
|
||||
// 0, 255, 255
|
||||
[c.cyan]: {},
|
||||
|
||||
//// SPECIAL COLORS
|
||||
|
||||
// 255, 255, 255
|
||||
[c.white]: {
|
||||
// auto
|
||||
},
|
||||
|
||||
// X, X, X
|
||||
[c.uncolored]: {
|
||||
// auto
|
||||
},
|
||||
};
|
||||
|
||||
// Create same color lookups
|
||||
for (const color in enumColors) {
|
||||
enumColorMixingResults[color][color] = color;
|
||||
|
||||
// Anything with white is white again
|
||||
enumColorMixingResults[color][c.white] = c.white;
|
||||
|
||||
// Anything with uncolored is the same color
|
||||
enumColorMixingResults[color][c.uncolored] = color;
|
||||
}
|
||||
|
||||
// Create reverse lookup and check color mixing lookups
|
||||
for (const colorA in enumColorMixingResults) {
|
||||
for (const colorB in enumColorMixingResults[colorA]) {
|
||||
const resultColor = enumColorMixingResults[colorA][colorB];
|
||||
if (!enumColorMixingResults[colorB]) {
|
||||
enumColorMixingResults[colorB] = {
|
||||
[colorA]: resultColor,
|
||||
};
|
||||
} else {
|
||||
const existingResult = enumColorMixingResults[colorB][colorA];
|
||||
if (existingResult && existingResult !== resultColor) {
|
||||
assertAlways(
|
||||
false,
|
||||
"invalid color mixing configuration, " +
|
||||
colorA +
|
||||
" + " +
|
||||
colorB +
|
||||
" is " +
|
||||
resultColor +
|
||||
" but " +
|
||||
colorB +
|
||||
" + " +
|
||||
colorA +
|
||||
" is " +
|
||||
existingResult
|
||||
);
|
||||
}
|
||||
enumColorMixingResults[colorB][colorA] = resultColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const colorA in enumColorMixingResults) {
|
||||
for (const colorB in enumColorMixingResults) {
|
||||
if (!enumColorMixingResults[colorA][colorB]) {
|
||||
assertAlways(false, "Color mixing of", colorA, "with", colorB, "is not defined");
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/js/game/component.js
Normal file
38
src/js/game/component.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { BasicSerializableObject } from "../savegame/serialization";
|
||||
|
||||
export class Component extends BasicSerializableObject {
|
||||
/**
|
||||
* Returns the components unique id
|
||||
* @returns {string}
|
||||
*/
|
||||
static getId() {
|
||||
abstract;
|
||||
return "unknown-component";
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the schema used for serialization
|
||||
*/
|
||||
static getSchema() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/* dev:start */
|
||||
|
||||
/**
|
||||
* Fixes typeof DerivedComponent is not assignable to typeof Component, compiled out
|
||||
* in non-dev builds
|
||||
*/
|
||||
constructor(...args) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representing the components data, only in dev builds
|
||||
* @returns {string}
|
||||
*/
|
||||
getDebugString() {
|
||||
return null;
|
||||
}
|
||||
/* dev:end */
|
||||
}
|
||||
38
src/js/game/component_registry.js
Normal file
38
src/js/game/component_registry.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { gComponentRegistry } from "../core/global_registries";
|
||||
import { StaticMapEntityComponent } from "./components/static_map_entity";
|
||||
import { BeltComponent } from "./components/belt";
|
||||
import { ItemEjectorComponent } from "./components/item_ejector";
|
||||
import { ItemAcceptorComponent } from "./components/item_acceptor";
|
||||
import { MinerComponent } from "./components/miner";
|
||||
import { ItemProcessorComponent } from "./components/item_processor";
|
||||
import { ReplaceableMapEntityComponent } from "./components/replaceable_map_entity";
|
||||
import { UndergroundBeltComponent } from "./components/underground_belt";
|
||||
import { UnremovableComponent } from "./components/unremovable";
|
||||
import { HubComponent } from "./components/hub";
|
||||
|
||||
export function initComponentRegistry() {
|
||||
gComponentRegistry.register(StaticMapEntityComponent);
|
||||
gComponentRegistry.register(BeltComponent);
|
||||
gComponentRegistry.register(ItemEjectorComponent);
|
||||
gComponentRegistry.register(ItemAcceptorComponent);
|
||||
gComponentRegistry.register(MinerComponent);
|
||||
gComponentRegistry.register(ItemProcessorComponent);
|
||||
gComponentRegistry.register(ReplaceableMapEntityComponent);
|
||||
gComponentRegistry.register(UndergroundBeltComponent);
|
||||
gComponentRegistry.register(UnremovableComponent);
|
||||
gComponentRegistry.register(HubComponent);
|
||||
|
||||
// IMPORTANT ^^^^^ REGENERATE SAVEGAME SCHEMA AFTERWARDS
|
||||
// IMPORTANT ^^^^^ ALSO UPDATE ENTITY COMPONENT STORAG
|
||||
|
||||
// Sanity check - If this is thrown, you (=me, lol) forgot to add a new component here
|
||||
|
||||
assert(
|
||||
// @ts-ignore
|
||||
require.context("./components", false, /.*\.js/i).keys().length ===
|
||||
gComponentRegistry.getNumEntries(),
|
||||
"Not all components are registered"
|
||||
);
|
||||
|
||||
console.log("📦 There are", gComponentRegistry.getNumEntries(), "components");
|
||||
}
|
||||
92
src/js/game/components/belt.js
Normal file
92
src/js/game/components/belt.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Component } from "../component";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { gItemRegistry } from "../../core/global_registries";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Vector, enumDirection } from "../../core/vector";
|
||||
import { Math_PI, Math_sin, Math_cos } from "../../core/builtins";
|
||||
import { globalConfig } from "../../core/config";
|
||||
|
||||
export class BeltComponent extends Component {
|
||||
static getId() {
|
||||
return "Belt";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
direction: types.string,
|
||||
sortedItems: types.array(types.pair(types.ufloat, types.obj(gItemRegistry))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {enumDirection=} param0.direction The direction of the belt
|
||||
*/
|
||||
constructor({ direction = enumDirection.top }) {
|
||||
super();
|
||||
|
||||
this.direction = direction;
|
||||
|
||||
/** @type {Array<[number, BaseItem]>} */
|
||||
this.sortedItems = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts from belt space (0 = start of belt ... 1 = end of belt) to the local
|
||||
* belt coordinates (-0.5|-0.5 to 0.5|0.5)
|
||||
* @param {number} progress
|
||||
* @returns {Vector}
|
||||
*/
|
||||
transformBeltToLocalSpace(progress) {
|
||||
switch (this.direction) {
|
||||
case enumDirection.top:
|
||||
return new Vector(0, 0.5 - progress);
|
||||
|
||||
case enumDirection.right: {
|
||||
const arcProgress = progress * 0.5 * Math_PI;
|
||||
return new Vector(0.5 - 0.5 * Math_cos(arcProgress), 0.5 - 0.5 * Math_sin(arcProgress));
|
||||
}
|
||||
case enumDirection.left: {
|
||||
const arcProgress = progress * 0.5 * Math_PI;
|
||||
return new Vector(-0.5 + 0.5 * Math_cos(arcProgress), 0.5 - 0.5 * Math_sin(arcProgress));
|
||||
}
|
||||
default:
|
||||
assertAlways(false, "Invalid belt direction: " + this.direction);
|
||||
return new Vector(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the belt can currently accept an item from the given direction
|
||||
* @param {enumDirection} direction
|
||||
*/
|
||||
canAcceptNewItem(direction) {
|
||||
const firstItem = this.sortedItems[0];
|
||||
if (!firstItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return firstItem[0] > globalConfig.itemSpacingOnBelts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes a new item to the belt
|
||||
* @param {BaseItem} item
|
||||
* @param {enumDirection} direction
|
||||
*/
|
||||
takeNewItem(item, direction) {
|
||||
this.sortedItems.unshift([0, item]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how much space there is to the first item
|
||||
*/
|
||||
getDistanceToFirstItemCenter() {
|
||||
const firstItem = this.sortedItems[0];
|
||||
if (!firstItem) {
|
||||
return 1;
|
||||
}
|
||||
return firstItem[0];
|
||||
}
|
||||
}
|
||||
25
src/js/game/components/hub.js
Normal file
25
src/js/game/components/hub.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Component } from "../component";
|
||||
import { ShapeDefinition } from "../shape_definition";
|
||||
|
||||
export class HubComponent extends Component {
|
||||
static getId() {
|
||||
return "Hub";
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
/**
|
||||
* Shape definitions in queue to be analyzed and counted towards the goal
|
||||
* @type {Array<ShapeDefinition>}
|
||||
*/
|
||||
this.definitionsToAnalyze = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ShapeDefinition} definition
|
||||
*/
|
||||
queueShapeDefinition(definition) {
|
||||
this.definitionsToAnalyze.push(definition);
|
||||
}
|
||||
}
|
||||
129
src/js/game/components/item_acceptor.js
Normal file
129
src/js/game/components/item_acceptor.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Component } from "../component";
|
||||
import { Vector, enumDirection, enumDirectionToAngle, enumInvertedDirections } from "../../core/vector";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { ShapeItem } from "../items/shape_item";
|
||||
import { ColorItem } from "../items/color_item";
|
||||
|
||||
/**
|
||||
* @enum {string?}
|
||||
*/
|
||||
export const enumItemAcceptorItemFilter = {
|
||||
shape: "shape",
|
||||
color: "color",
|
||||
none: null,
|
||||
};
|
||||
|
||||
/** @typedef {{
|
||||
* pos: Vector,
|
||||
* directions: enumDirection[],
|
||||
* filter?: enumItemAcceptorItemFilter
|
||||
* }} ItemAcceptorSlot */
|
||||
|
||||
export class ItemAcceptorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemAcceptor";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
// slots: "TODO",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Array<{pos: Vector, directions: enumDirection[], filter?: enumItemAcceptorItemFilter}>} param0.slots The slots from which we accept items
|
||||
*/
|
||||
constructor({ slots }) {
|
||||
super();
|
||||
|
||||
this.setSlots(slots);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<{pos: Vector, directions: enumDirection[], filter?: enumItemAcceptorItemFilter}>} slots
|
||||
*/
|
||||
setSlots(slots) {
|
||||
/** @type {Array<{pos: Vector, directions: enumDirection[], filter?: enumItemAcceptorItemFilter}>} */
|
||||
this.slots = [];
|
||||
for (let i = 0; i < slots.length; ++i) {
|
||||
const slot = slots[i];
|
||||
this.slots.push({
|
||||
pos: slot.pos,
|
||||
directions: slot.directions,
|
||||
|
||||
// Which type of item to accept (shape | color | all) @see enumItemAcceptorItemFilter
|
||||
filter: slot.filter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if this acceptor can accept a new item at slot N
|
||||
* @param {number} slotIndex
|
||||
* @param {BaseItem=} item
|
||||
*/
|
||||
canAcceptItem(slotIndex, item) {
|
||||
const slot = this.slots[slotIndex];
|
||||
switch (slot.filter) {
|
||||
case enumItemAcceptorItemFilter.shape: {
|
||||
return item instanceof ShapeItem;
|
||||
}
|
||||
case enumItemAcceptorItemFilter.color: {
|
||||
return item instanceof ColorItem;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find a slot which accepts the current item
|
||||
* @param {Vector} targetLocalTile
|
||||
* @param {enumDirection} fromLocalDirection
|
||||
* @returns {{
|
||||
* slot: ItemAcceptorSlot,
|
||||
* index: number,
|
||||
* acceptedDirection: enumDirection
|
||||
* }|null}
|
||||
*/
|
||||
findMatchingSlot(targetLocalTile, fromLocalDirection) {
|
||||
// We need to invert our direction since the acceptor specifies *from* which direction
|
||||
// it accepts items, but the ejector specifies *into* which direction it ejects items.
|
||||
// E.g.: Ejector ejects into "right" direction but acceptor accepts from "left" direction.
|
||||
const desiredDirection = enumInvertedDirections[fromLocalDirection];
|
||||
|
||||
// Go over all slots and try to find a target slot
|
||||
for (let slotIndex = 0; slotIndex < this.slots.length; ++slotIndex) {
|
||||
const slot = this.slots[slotIndex];
|
||||
|
||||
// const acceptorLocalPosition = targetStaticComp.applyRotationToVector(
|
||||
// slot.pos
|
||||
// );
|
||||
|
||||
// const acceptorGlobalPosition = acceptorLocalPosition.add(targetStaticComp.origin);
|
||||
|
||||
// Make sure the acceptor slot is on the right position
|
||||
if (!slot.pos.equals(targetLocalTile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the acceptor slot accepts items from our direction
|
||||
for (let i = 0; i < slot.directions.length; ++i) {
|
||||
// const localDirection = targetStaticComp.localDirectionToWorld(slot.directions[l]);
|
||||
if (desiredDirection === slot.directions[i]) {
|
||||
return {
|
||||
slot,
|
||||
index: slotIndex,
|
||||
acceptedDirection: desiredDirection,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// && this.canAcceptItem(slotIndex, ejectingItem)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
162
src/js/game/components/item_ejector.js
Normal file
162
src/js/game/components/item_ejector.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { Vector, enumDirection, enumDirectionToVector } from "../../core/vector";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* pos: Vector,
|
||||
* direction: enumDirection,
|
||||
* item: BaseItem,
|
||||
* progress: number?
|
||||
* }} ItemEjectorSlot
|
||||
*/
|
||||
|
||||
export class ItemEjectorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemEjector";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
// slots: "TODO"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Array<{pos: Vector, direction: enumDirection}>} param0.slots The slots to eject on
|
||||
* @param {boolean=} param0.instantEject If the ejection is instant
|
||||
*/
|
||||
constructor({ slots, instantEject = false }) {
|
||||
super();
|
||||
|
||||
// How long items take to eject
|
||||
this.instantEject = instantEject;
|
||||
|
||||
this.setSlots(slots);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{pos: Vector, direction: enumDirection}>} slots The slots to eject on
|
||||
*/
|
||||
setSlots(slots) {
|
||||
/** @type {Array<ItemEjectorSlot>} */
|
||||
this.slots = [];
|
||||
for (let i = 0; i < slots.length; ++i) {
|
||||
const slot = slots[i];
|
||||
this.slots.push({
|
||||
pos: slot.pos,
|
||||
direction: slot.direction,
|
||||
item: null,
|
||||
progress: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of slots
|
||||
*/
|
||||
getNumSlots() {
|
||||
return this.slots.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns where this slot ejects to
|
||||
* @param {number} index
|
||||
* @returns {Vector}
|
||||
*/
|
||||
getSlotTargetLocalTile(index) {
|
||||
const slot = this.slots[index];
|
||||
const directionVector = enumDirectionToVector[slot.direction];
|
||||
return slot.pos.add(directionVector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether any slot ejects to the given local tile
|
||||
* @param {Vector} tile
|
||||
*/
|
||||
anySlotEjectsToLocalTile(tile) {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.getSlotTargetLocalTile(i).equals(tile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if slot # is currently ejecting
|
||||
* @param {number} slotIndex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSlotEjecting(slotIndex) {
|
||||
assert(slotIndex >= 0 && slotIndex < this.slots.length, "Invalid ejector slot: " + slotIndex);
|
||||
return !!this.slots[slotIndex].item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if we can eject on a given slot
|
||||
* @param {number} slotIndex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canEjectOnSlot(slotIndex) {
|
||||
assert(slotIndex >= 0 && slotIndex < this.slots.length, "Invalid ejector slot: " + slotIndex);
|
||||
return !this.slots[slotIndex].item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first free slot on this ejector or null if there is none
|
||||
* @returns {number?}
|
||||
*/
|
||||
getFirstFreeSlot() {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.canEjectOnSlot(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if any slot is ejecting
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAnySlotEjecting() {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.slots[i].item) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if any slot is free
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasAnySlotFree() {
|
||||
for (let i = 0; i < this.slots.length; ++i) {
|
||||
if (this.canEjectOnSlot(i)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to eject a given item
|
||||
* @param {number} slotIndex
|
||||
* @param {BaseItem} item
|
||||
* @returns {boolean}
|
||||
*/
|
||||
tryEject(slotIndex, item) {
|
||||
if (!this.canEjectOnSlot(slotIndex)) {
|
||||
return false;
|
||||
}
|
||||
this.slots[slotIndex].item = item;
|
||||
this.slots[slotIndex].progress = this.instantEject ? 1 : 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
106
src/js/game/components/item_processor.js
Normal file
106
src/js/game/components/item_processor.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumItemProcessorTypes = {
|
||||
splitter: "splitter",
|
||||
cutter: "cutter",
|
||||
rotater: "rotater",
|
||||
stacker: "stacker",
|
||||
trash: "trash",
|
||||
mixer: "mixer",
|
||||
painter: "painter",
|
||||
hub: "hub",
|
||||
};
|
||||
|
||||
export class ItemProcessorComponent extends Component {
|
||||
static getId() {
|
||||
return "ItemProcessor";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
// TODO
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {enumItemProcessorTypes} param0.processorType Which type of processor this is
|
||||
* @param {number} param0.inputsPerCharge How many items this machine needs until it can start working
|
||||
* @param {Array<{pos: Vector, direction: enumDirection}>=} param0.beltUnderlays Where to render belt underlays
|
||||
*
|
||||
*/
|
||||
constructor({ processorType = enumItemProcessorTypes.splitter, inputsPerCharge, beltUnderlays = [] }) {
|
||||
super();
|
||||
|
||||
// Which slot to emit next, this is only a preference and if it can't emit
|
||||
// it will take the other one. Some machines ignore this (e.g. the splitter) to make
|
||||
// sure the outputs always match
|
||||
this.nextOutputSlot = 0;
|
||||
|
||||
// Type of the processor
|
||||
this.type = processorType;
|
||||
|
||||
// How many inputs we need for one charge
|
||||
this.inputsPerCharge = inputsPerCharge;
|
||||
|
||||
// Which belt underlays to render
|
||||
this.beltUnderlays = beltUnderlays;
|
||||
|
||||
/**
|
||||
* Our current inputs
|
||||
* @type {Array<{ item: BaseItem, sourceSlot: number }>}
|
||||
*/
|
||||
this.inputSlots = [];
|
||||
|
||||
/**
|
||||
* What we are currently processing, empty if we don't produce anything rn
|
||||
* requiredSlot: Item *must* be ejected on this slot
|
||||
* preferredSlot: Item *can* be ejected on this slot, but others are fine too if the one is not usable
|
||||
* @type {Array<{item: BaseItem, requiredSlot?: number, preferredSlot?: number}>}
|
||||
*/
|
||||
this.itemsToEject = [];
|
||||
|
||||
/**
|
||||
* How long it takes until we are done with the current items
|
||||
*/
|
||||
this.secondsUntilEject = 0;
|
||||
|
||||
/**
|
||||
* Fixes belt animations
|
||||
* @type {Array<{ item: BaseItem, slotIndex: number, animProgress: number, direction: enumDirection}>}
|
||||
*/
|
||||
this.itemConsumptionAnimations = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to take the item
|
||||
* @param {BaseItem} item
|
||||
*/
|
||||
tryTakeItem(item, sourceSlot, sourceDirection) {
|
||||
if (this.inputSlots.length >= this.inputsPerCharge) {
|
||||
// Already full
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that we only take one item per slot
|
||||
for (let i = 0; i < this.inputSlots.length; ++i) {
|
||||
const slot = this.inputSlots[i];
|
||||
if (slot.sourceSlot === sourceSlot) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.inputSlots.push({ item, sourceSlot });
|
||||
this.itemConsumptionAnimations.push({
|
||||
item,
|
||||
slotIndex: sourceSlot,
|
||||
direction: sourceDirection,
|
||||
animProgress: 0.0,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
23
src/js/game/components/miner.js
Normal file
23
src/js/game/components/miner.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { Component } from "../component";
|
||||
|
||||
export class MinerComponent extends Component {
|
||||
static getId() {
|
||||
return "Miner";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
lastMiningTime: types.ufloat,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} param0
|
||||
*/
|
||||
constructor({}) {
|
||||
super();
|
||||
this.lastMiningTime = 0;
|
||||
}
|
||||
}
|
||||
11
src/js/game/components/replaceable_map_entity.js
Normal file
11
src/js/game/components/replaceable_map_entity.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component } from "../component";
|
||||
|
||||
/**
|
||||
* Marks an entity as replaceable, so that when other buildings are placed above him it
|
||||
* simply gets deleted
|
||||
*/
|
||||
export class ReplaceableMapEntityComponent extends Component {
|
||||
static getId() {
|
||||
return "ReplaceableMapEntity";
|
||||
}
|
||||
}
|
||||
184
src/js/game/components/static_map_entity.js
Normal file
184
src/js/game/components/static_map_entity.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Math_radians } from "../../core/builtins";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { Rectangle } from "../../core/rectangle";
|
||||
import { AtlasSprite } from "../../core/sprites";
|
||||
import { enumDirection, Vector } from "../../core/vector";
|
||||
import { types } from "../../savegame/serialization";
|
||||
import { Component } from "../component";
|
||||
|
||||
export class StaticMapEntityComponent extends Component {
|
||||
static getId() {
|
||||
return "StaticMapEntity";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
origin: types.tileVector,
|
||||
tileSize: types.tileVector,
|
||||
rotationDegrees: types.uint,
|
||||
spriteKey: types.string,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Vector=} param0.origin Origin (Top Left corner) of the entity
|
||||
* @param {Vector=} param0.tileSize Size of the entity in tiles
|
||||
* @param {number=} param0.rotationDegrees Rotation in degrees. Must be multiple of 90
|
||||
* @param {string=} param0.spriteKey Optional sprite
|
||||
* @param {string=} param0.silhouetteColor Optional silhouette color override
|
||||
*/
|
||||
constructor({
|
||||
origin = new Vector(),
|
||||
tileSize = new Vector(1, 1),
|
||||
rotationDegrees = 0,
|
||||
spriteKey = null,
|
||||
silhouetteColor = null,
|
||||
}) {
|
||||
super();
|
||||
assert(
|
||||
rotationDegrees % 90 === 0,
|
||||
"Rotation of static map entity must be multiple of 90 (was " + rotationDegrees + ")"
|
||||
);
|
||||
|
||||
this.origin = origin;
|
||||
this.tileSize = tileSize;
|
||||
this.spriteKey = spriteKey;
|
||||
this.rotationDegrees = rotationDegrees;
|
||||
this.silhouetteColor = silhouetteColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective rectangle of this entity in tile space
|
||||
* @returns {Rectangle}
|
||||
*/
|
||||
getTileSpaceBounds() {
|
||||
switch (this.rotationDegrees) {
|
||||
case 0:
|
||||
return new Rectangle(this.origin.x, this.origin.y, this.tileSize.x, this.tileSize.y);
|
||||
case 90:
|
||||
return new Rectangle(
|
||||
this.origin.x - this.tileSize.y + 1,
|
||||
this.origin.y,
|
||||
this.tileSize.y,
|
||||
this.tileSize.x
|
||||
);
|
||||
case 180:
|
||||
return new Rectangle(
|
||||
this.origin.x - this.tileSize.x + 1,
|
||||
this.origin.y - this.tileSize.y + 1,
|
||||
this.tileSize.x,
|
||||
this.tileSize.y
|
||||
);
|
||||
case 270:
|
||||
return new Rectangle(
|
||||
this.origin.x,
|
||||
this.origin.y - this.tileSize.x + 1,
|
||||
this.tileSize.y,
|
||||
this.tileSize.x
|
||||
);
|
||||
default:
|
||||
assert(false, "Invalid rotation");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given vector/rotation from local space to world space
|
||||
* @param {Vector} vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
applyRotationToVector(vector) {
|
||||
return vector.rotateFastMultipleOf90(this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given vector/rotation from world space to local space
|
||||
* @param {Vector} vector
|
||||
* @returns {Vector}
|
||||
*/
|
||||
unapplyRotationToVector(vector) {
|
||||
return vector.rotateFastMultipleOf90(360 - this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given direction from local space
|
||||
* @param {enumDirection} direction
|
||||
* @returns {enumDirection}
|
||||
*/
|
||||
localDirectionToWorld(direction) {
|
||||
return Vector.transformDirectionFromMultipleOf90(direction, this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the given direction from world to local space
|
||||
* @param {enumDirection} direction
|
||||
* @returns {enumDirection}
|
||||
*/
|
||||
worldDirectionToLocal(direction) {
|
||||
return Vector.transformDirectionFromMultipleOf90(direction, 360 - this.rotationDegrees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms from local tile space to global tile space
|
||||
* @param {Vector} localTile
|
||||
* @returns {Vector}
|
||||
*/
|
||||
localTileToWorld(localTile) {
|
||||
const result = this.applyRotationToVector(localTile);
|
||||
result.addInplace(this.origin);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms from world space to local space
|
||||
* @param {Vector} worldTile
|
||||
*/
|
||||
worldToLocalTile(worldTile) {
|
||||
const localUnrotated = worldTile.sub(this.origin);
|
||||
return this.unapplyRotationToVector(localUnrotated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a sprite over the whole space of the entity
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {AtlasSprite} sprite
|
||||
* @param {number=} extrudePixels How many pixels to extrude the sprite
|
||||
* @param {boolean=} clipping Whether to clip
|
||||
*/
|
||||
drawSpriteOnFullEntityBounds(parameters, sprite, extrudePixels = 0, clipping = true) {
|
||||
const worldX = this.origin.x * globalConfig.tileSize;
|
||||
const worldY = this.origin.y * globalConfig.tileSize;
|
||||
|
||||
if (this.rotationDegrees === 0) {
|
||||
// Early out, is faster
|
||||
sprite.drawCached(
|
||||
parameters,
|
||||
worldX - extrudePixels * this.tileSize.x,
|
||||
worldY - extrudePixels * this.tileSize.y,
|
||||
globalConfig.tileSize * this.tileSize.x + 2 * extrudePixels * this.tileSize.x,
|
||||
globalConfig.tileSize * this.tileSize.y + 2 * extrudePixels * this.tileSize.y,
|
||||
clipping
|
||||
);
|
||||
} else {
|
||||
const rotationCenterX = worldX + globalConfig.halfTileSize;
|
||||
const rotationCenterY = worldY + globalConfig.halfTileSize;
|
||||
|
||||
parameters.context.translate(rotationCenterX, rotationCenterY);
|
||||
parameters.context.rotate(Math_radians(this.rotationDegrees));
|
||||
|
||||
sprite.drawCached(
|
||||
parameters,
|
||||
-globalConfig.halfTileSize - extrudePixels * this.tileSize.x,
|
||||
-globalConfig.halfTileSize - extrudePixels * this.tileSize.y,
|
||||
globalConfig.tileSize * this.tileSize.x + 2 * extrudePixels * this.tileSize.x,
|
||||
globalConfig.tileSize * this.tileSize.y + 2 * extrudePixels * this.tileSize.y,
|
||||
false
|
||||
);
|
||||
|
||||
parameters.context.rotate(-Math_radians(this.rotationDegrees));
|
||||
parameters.context.translate(-rotationCenterX, -rotationCenterY);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/js/game/components/underground_belt.js
Normal file
88
src/js/game/components/underground_belt.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { BaseItem } from "../base_item";
|
||||
import { Component } from "../component";
|
||||
import { globalConfig } from "../../core/config";
|
||||
|
||||
/** @enum {string} */
|
||||
export const enumUndergroundBeltMode = {
|
||||
sender: "sender",
|
||||
receiver: "receiver",
|
||||
};
|
||||
|
||||
export class UndergroundBeltComponent extends Component {
|
||||
static getId() {
|
||||
return "UndergroundBelt";
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {enumUndergroundBeltMode=} param0.mode As which type of belt the entity acts
|
||||
*/
|
||||
constructor({ mode = enumUndergroundBeltMode.sender }) {
|
||||
super();
|
||||
|
||||
this.mode = mode;
|
||||
|
||||
/**
|
||||
* Used on both receiver and sender.
|
||||
* Reciever: Used to store the next item to transfer, and to block input while doing this
|
||||
* Sender: Used to store which items are currently "travelling"
|
||||
* @type {Array<[BaseItem, number]>} Format is [Item, remaining seconds until transfer/ejection]
|
||||
*/
|
||||
this.pendingItems = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to accept an item from an external source like a regular belt or building
|
||||
* @param {BaseItem} item
|
||||
* @param {number} beltSpeed How fast this item travels
|
||||
*/
|
||||
tryAcceptExternalItem(item, beltSpeed) {
|
||||
if (this.mode !== enumUndergroundBeltMode.sender) {
|
||||
// Only senders accept external items
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.pendingItems.length > 0) {
|
||||
// We currently have a pending item
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Takes", 1 / beltSpeed);
|
||||
this.pendingItems.push([item, 1 / beltSpeed]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to accept a tunneled item
|
||||
* @param {BaseItem} item
|
||||
* @param {number} travelDistance How many tiles this item has to travel
|
||||
* @param {number} beltSpeed How fast this item travels
|
||||
*/
|
||||
tryAcceptTunneledItem(item, travelDistance, beltSpeed) {
|
||||
if (this.mode !== enumUndergroundBeltMode.receiver) {
|
||||
// Only receivers can accept tunneled items
|
||||
return false;
|
||||
}
|
||||
|
||||
// Notice: We assume that for all items the travel distance is the same
|
||||
const maxItemsInTunnel = (1 + travelDistance) / globalConfig.itemSpacingOnBelts;
|
||||
if (this.pendingItems.length >= maxItemsInTunnel) {
|
||||
// Simulate a real belt which gets full at some point
|
||||
return false;
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// This corresponds to the item ejector - it needs 0.5 additional tiles to eject the item.
|
||||
// So instead of adding 1 we add 0.5 only.
|
||||
const travelDuration = (travelDistance + 0.5) / beltSpeed;
|
||||
console.log(travelDistance, "->", travelDuration);
|
||||
|
||||
this.pendingItems.push([item, travelDuration]);
|
||||
|
||||
// Sort so we can only look at the first ones
|
||||
this.pendingItems.sort((a, b) => a[1] - b[1]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
7
src/js/game/components/unremovable.js
Normal file
7
src/js/game/components/unremovable.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Component } from "../component";
|
||||
|
||||
export class UnremovableComponent extends Component {
|
||||
static getId() {
|
||||
return "Unremovable";
|
||||
}
|
||||
}
|
||||
434
src/js/game/core.js
Normal file
434
src/js/game/core.js
Normal file
@@ -0,0 +1,434 @@
|
||||
/* typehints:start */
|
||||
import { InGameState } from "../states/ingame";
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { BufferMaintainer } from "../core/buffer_maintainer";
|
||||
import { disableImageSmoothing, enableImageSmoothing, registerCanvas } from "../core/buffer_utils";
|
||||
import { Math_random } from "../core/builtins";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { getDeviceDPI, resizeHighDPICanvas } from "../core/dpi_manager";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { gMetaBuildingRegistry } from "../core/global_registries";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { PerlinNoise } from "../core/perlin_noise";
|
||||
import { Vector } from "../core/vector";
|
||||
import { Savegame } from "../savegame/savegame";
|
||||
import { SavegameSerializer } from "../savegame/savegame_serializer";
|
||||
import { AutomaticSave } from "./automatic_save";
|
||||
import { MetaHubBuilding } from "./buildings/hub";
|
||||
import { Camera } from "./camera";
|
||||
import { CanvasClickInterceptor } from "./canvas_click_interceptor";
|
||||
import { EntityManager } from "./entity_manager";
|
||||
import { GameSystemManager } from "./game_system_manager";
|
||||
import { HubGoals } from "./hub_goals";
|
||||
import { GameHUD } from "./hud/hud";
|
||||
import { KeyActionMapper } from "./key_action_mapper";
|
||||
import { GameLogic } from "./logic";
|
||||
import { MapView } from "./map_view";
|
||||
import { GameRoot } from "./root";
|
||||
import { ShapeDefinitionManager } from "./shape_definition_manager";
|
||||
import { SoundProxy } from "./sound_proxy";
|
||||
import { GameTime } from "./time/game_time";
|
||||
|
||||
const logger = createLogger("ingame/core");
|
||||
|
||||
// Store the canvas so we can reuse it later
|
||||
/** @type {HTMLCanvasElement} */
|
||||
let lastCanvas = null;
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
let lastContext = null;
|
||||
|
||||
/**
|
||||
* The core manages the root and represents the whole game. It wraps the root, since
|
||||
* the root class is just a data holder.
|
||||
*/
|
||||
export class GameCore {
|
||||
/** @param {Application} app */
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
/** @type {GameRoot} */
|
||||
this.root = null;
|
||||
|
||||
/**
|
||||
* Time budget (seconds) for logic updates
|
||||
*/
|
||||
this.logicTimeBudget = 0;
|
||||
|
||||
/**
|
||||
* Time budget (seconds) for user interface updates
|
||||
*/
|
||||
this.uiTimeBudget = 0;
|
||||
|
||||
/**
|
||||
* Set to true at the beginning of a logic update and cleared when its finished.
|
||||
* This is to prevent doing a recursive logic update which can lead to unexpected
|
||||
* behaviour.
|
||||
*/
|
||||
this.duringLogicUpdate = false;
|
||||
|
||||
// Cached
|
||||
this.boundInternalTick = this.updateLogic.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the root object which stores all game related data. The state
|
||||
* is required as a back reference (used sometimes)
|
||||
* @param {InGameState} parentState
|
||||
* @param {Savegame} savegame
|
||||
*/
|
||||
initializeRoot(parentState, savegame) {
|
||||
// Construct the root element, this is the data representation of the game
|
||||
this.root = new GameRoot(this.app);
|
||||
this.root.gameState = parentState;
|
||||
this.root.savegame = savegame;
|
||||
this.root.gameWidth = this.app.screenWidth;
|
||||
this.root.gameHeight = this.app.screenHeight;
|
||||
|
||||
// Initialize canvas element & context
|
||||
this.internalInitCanvas();
|
||||
|
||||
// Members
|
||||
const root = this.root;
|
||||
|
||||
// This isn't nice, but we need it right here
|
||||
root.gameState.keyActionMapper = new KeyActionMapper(root, this.root.gameState.inputReciever);
|
||||
|
||||
// Init classes
|
||||
root.camera = new Camera(root);
|
||||
root.map = new MapView(root);
|
||||
root.logic = new GameLogic(root);
|
||||
root.hud = new GameHUD(root);
|
||||
root.time = new GameTime(root);
|
||||
root.canvasClickInterceptor = new CanvasClickInterceptor(root);
|
||||
root.automaticSave = new AutomaticSave(root);
|
||||
root.soundProxy = new SoundProxy(root);
|
||||
|
||||
// Init managers
|
||||
root.entityMgr = new EntityManager(root);
|
||||
root.systemMgr = new GameSystemManager(root);
|
||||
root.shapeDefinitionMgr = new ShapeDefinitionManager(root);
|
||||
root.mapNoiseGenerator = new PerlinNoise(Math.random()); // TODO: Save seed
|
||||
root.hubGoals = new HubGoals(root);
|
||||
root.buffers = new BufferMaintainer(root);
|
||||
|
||||
// root.particleMgr = new ParticleManager(root);
|
||||
// root.uiParticleMgr = new ParticleManager(root);
|
||||
|
||||
// Initialize the hud once everything is loaded
|
||||
this.root.hud.initialize();
|
||||
|
||||
// Initial resize event, it might be possible that the screen
|
||||
// resized later during init tho, which is why will emit it later
|
||||
// again anyways
|
||||
this.resize(this.app.screenWidth, this.app.screenHeight);
|
||||
|
||||
if (G_IS_DEV) {
|
||||
// @ts-ignore
|
||||
window.globalRoot = root;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new game, this means creating a new map and centering on the
|
||||
* plaerbase
|
||||
* */
|
||||
initNewGame() {
|
||||
logger.log("Initializing new game");
|
||||
this.root.gameIsFresh = true;
|
||||
|
||||
gMetaBuildingRegistry
|
||||
.findByClass(MetaHubBuilding)
|
||||
.createAndPlaceEntity(this.root, new Vector(-2, -2), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inits an existing game by loading the raw savegame data and deserializing it.
|
||||
* Also runs basic validity checks.
|
||||
*/
|
||||
initExistingGame() {
|
||||
logger.log("Initializing existing game");
|
||||
const serializer = new SavegameSerializer();
|
||||
|
||||
try {
|
||||
const status = serializer.deserialize(this.root.savegame.getCurrentDump(), this.root);
|
||||
if (!status.isGood()) {
|
||||
logger.error("savegame-deserialize-failed:" + status.reason);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
logger.error("Exception during deserialization:", ex);
|
||||
return false;
|
||||
}
|
||||
this.root.gameIsFresh = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the render canvas
|
||||
*/
|
||||
internalInitCanvas() {
|
||||
let canvas, context;
|
||||
if (!lastCanvas) {
|
||||
logger.log("Creating new canvas");
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.id = "ingame_Canvas";
|
||||
canvas.setAttribute("opaque", "true");
|
||||
canvas.setAttribute("webkitOpaque", "true");
|
||||
canvas.setAttribute("mozOpaque", "true");
|
||||
this.root.gameState.getDivElement().appendChild(canvas);
|
||||
context = canvas.getContext("2d", { alpha: false });
|
||||
|
||||
lastCanvas = canvas;
|
||||
lastContext = context;
|
||||
} else {
|
||||
logger.log("Reusing canvas");
|
||||
if (lastCanvas.parentElement) {
|
||||
lastCanvas.parentElement.removeChild(lastCanvas);
|
||||
}
|
||||
this.root.gameState.getDivElement().appendChild(lastCanvas);
|
||||
|
||||
canvas = lastCanvas;
|
||||
context = lastContext;
|
||||
|
||||
lastContext.clearRect(0, 0, lastCanvas.width, lastCanvas.height);
|
||||
}
|
||||
|
||||
// globalConfig.smoothing.smoothMainCanvas = getDeviceDPI() < 1.5;
|
||||
// globalConfig.smoothing.smoothMainCanvas = true;
|
||||
|
||||
canvas.classList.toggle("smoothed", globalConfig.smoothing.smoothMainCanvas);
|
||||
|
||||
// Oof, use :not() instead
|
||||
canvas.classList.toggle("unsmoothed", !globalConfig.smoothing.smoothMainCanvas);
|
||||
|
||||
if (globalConfig.smoothing.smoothMainCanvas) {
|
||||
enableImageSmoothing(context);
|
||||
} else {
|
||||
disableImageSmoothing(context);
|
||||
}
|
||||
|
||||
this.root.canvas = canvas;
|
||||
this.root.context = context;
|
||||
|
||||
registerCanvas(canvas, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructs the root, freeing all resources
|
||||
*/
|
||||
destruct() {
|
||||
if (lastCanvas && lastCanvas.parentElement) {
|
||||
lastCanvas.parentElement.removeChild(lastCanvas);
|
||||
}
|
||||
|
||||
this.root.destruct();
|
||||
delete this.root;
|
||||
this.root = null;
|
||||
this.app = null;
|
||||
}
|
||||
|
||||
tick(deltaMs) {
|
||||
const root = this.root;
|
||||
|
||||
if (root.hud.parts.processingOverlay.hasTasks() || root.hud.parts.processingOverlay.isRunning()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Extract current real time
|
||||
root.time.updateRealtimeNow();
|
||||
|
||||
// Camera is always updated, no matter what
|
||||
root.camera.update(deltaMs);
|
||||
|
||||
// Perform logic ticks
|
||||
this.root.time.performTicks(deltaMs, this.boundInternalTick);
|
||||
|
||||
// Update UI particles
|
||||
this.uiTimeBudget += deltaMs;
|
||||
const maxUiSteps = 3;
|
||||
if (this.uiTimeBudget > globalConfig.physicsDeltaMs * maxUiSteps) {
|
||||
this.uiTimeBudget = globalConfig.physicsDeltaMs;
|
||||
}
|
||||
while (this.uiTimeBudget >= globalConfig.physicsDeltaMs) {
|
||||
this.uiTimeBudget -= globalConfig.physicsDeltaMs;
|
||||
// root.uiParticleMgr.update();
|
||||
}
|
||||
|
||||
// Update automatic save after everything finished
|
||||
root.automaticSave.update();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldRender() {
|
||||
if (this.root.queue.requireRedraw) {
|
||||
return true;
|
||||
}
|
||||
if (this.root.hud.shouldPauseRendering()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do not render
|
||||
if (!this.app.isRenderable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
updateLogic() {
|
||||
const root = this.root;
|
||||
this.duringLogicUpdate = true;
|
||||
|
||||
// Update entities, this removes destroyed entities
|
||||
root.entityMgr.update();
|
||||
|
||||
// IMPORTANT: At this point, the game might be game over. Stop if this is the case
|
||||
if (!this.root) {
|
||||
logger.log("Root destructed, returning false");
|
||||
return false;
|
||||
}
|
||||
|
||||
root.systemMgr.update();
|
||||
// root.particleMgr.update();
|
||||
|
||||
this.duringLogicUpdate = false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
resize(w, h) {
|
||||
this.root.gameWidth = w;
|
||||
this.root.gameHeight = h;
|
||||
resizeHighDPICanvas(this.root.canvas, w, h, globalConfig.smoothing.smoothMainCanvas);
|
||||
this.root.signals.resized.dispatch(w, h);
|
||||
this.root.queue.requireRedraw = true;
|
||||
}
|
||||
|
||||
postLoadHook() {
|
||||
logger.log("Dispatching post load hook");
|
||||
this.root.signals.postLoadHook.dispatch();
|
||||
|
||||
if (!this.root.gameIsFresh) {
|
||||
// Also dispatch game restored hook on restored savegames
|
||||
this.root.signals.gameRestored.dispatch();
|
||||
}
|
||||
|
||||
this.root.gameInitialized = true;
|
||||
}
|
||||
|
||||
draw() {
|
||||
const root = this.root;
|
||||
const systems = root.systemMgr.systems;
|
||||
|
||||
const taskRunner = root.hud.parts.processingOverlay;
|
||||
if (taskRunner.hasTasks()) {
|
||||
if (!taskRunner.isRunning()) {
|
||||
taskRunner.process();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.shouldRender()) {
|
||||
// Always update hud tho
|
||||
root.hud.update();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update buffers as the very first
|
||||
root.buffers.update();
|
||||
|
||||
root.queue.requireRedraw = false;
|
||||
|
||||
// Gather context and save all state
|
||||
const context = root.context;
|
||||
context.save();
|
||||
|
||||
// Compute optimal zoom level and atlas scale
|
||||
const zoomLevel = root.camera.zoomLevel;
|
||||
const effectiveZoomLevel =
|
||||
(zoomLevel / globalConfig.assetsDpi) * getDeviceDPI() * globalConfig.assetsSharpness;
|
||||
|
||||
let desiredAtlasScale = "0.1";
|
||||
if (effectiveZoomLevel > 0.75) {
|
||||
desiredAtlasScale = "1";
|
||||
} else if (effectiveZoomLevel > 0.5) {
|
||||
desiredAtlasScale = "0.75";
|
||||
} else if (effectiveZoomLevel > 0.25) {
|
||||
desiredAtlasScale = "0.5";
|
||||
} else if (effectiveZoomLevel > 0.1) {
|
||||
desiredAtlasScale = "0.25";
|
||||
}
|
||||
|
||||
// Construct parameters required for drawing
|
||||
const params = new DrawParameters({
|
||||
context: context,
|
||||
visibleRect: root.camera.getVisibleRect(),
|
||||
desiredAtlasScale,
|
||||
zoomLevel,
|
||||
root: root,
|
||||
});
|
||||
|
||||
if (G_IS_DEV && (globalConfig.debug.testCulling || globalConfig.debug.hideFog)) {
|
||||
context.clearRect(0, 0, root.gameWidth, root.gameHeight);
|
||||
}
|
||||
|
||||
// Transform to world space
|
||||
root.camera.transform(context);
|
||||
|
||||
assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame start");
|
||||
|
||||
// Update hud
|
||||
root.hud.update();
|
||||
|
||||
// Main rendering order
|
||||
// -----
|
||||
|
||||
root.map.drawBackground(params);
|
||||
// systems.mapResources.draw(params);
|
||||
|
||||
if (!this.root.camera.getIsMapOverlayActive()) {
|
||||
systems.itemProcessor.drawUnderlays(params);
|
||||
systems.belt.draw(params);
|
||||
systems.itemEjector.draw(params);
|
||||
systems.itemProcessor.draw(params);
|
||||
}
|
||||
|
||||
root.map.drawForeground(params);
|
||||
if (!this.root.camera.getIsMapOverlayActive()) {
|
||||
systems.hub.draw(params);
|
||||
}
|
||||
|
||||
if (G_IS_DEV) {
|
||||
root.map.drawStaticEntities(params);
|
||||
}
|
||||
|
||||
// END OF GAME CONTENT
|
||||
// -----
|
||||
|
||||
// Finally, draw the hud. Nothing should come after that
|
||||
root.hud.draw(params);
|
||||
|
||||
assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame end before restore");
|
||||
|
||||
// Restore to screen space
|
||||
context.restore();
|
||||
|
||||
// Draw overlays, those are screen space
|
||||
root.hud.drawOverlays(params);
|
||||
|
||||
assert(context.globalAlpha === 1.0, "context.globalAlpha not 1 on frame end");
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.simulateSlowRendering) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 1e8; ++i) {
|
||||
sum += i;
|
||||
}
|
||||
if (Math_random() > 0.95) {
|
||||
console.log(sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
222
src/js/game/entity.js
Normal file
222
src/js/game/entity.js
Normal file
@@ -0,0 +1,222 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { Component } from "./component";
|
||||
/* typehints:end */
|
||||
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Vector, enumDirectionToVector, enumDirectionToAngle } from "../core/vector";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
import { EntityComponentStorage } from "./entity_components";
|
||||
import { Loader } from "../core/loader";
|
||||
import { drawRotatedSprite } from "../core/draw_utils";
|
||||
import { Math_radians } from "../core/builtins";
|
||||
// import { gFactionRegistry, gComponentRegistry } from "../core/global_registries";
|
||||
// import { EntityComponentStorage } from "./entity_components";
|
||||
|
||||
export class Entity extends BasicSerializableObject {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
/**
|
||||
* Handle to the global game root
|
||||
*/
|
||||
this.root = root;
|
||||
|
||||
/**
|
||||
* The metaclass of the entity, should be set by subclasses
|
||||
*/
|
||||
this.meta = null;
|
||||
|
||||
/**
|
||||
* The components of the entity
|
||||
*/
|
||||
this.components = new EntityComponentStorage();
|
||||
|
||||
/**
|
||||
* Whether this entity was registered on the @see EntityManager so far
|
||||
*/
|
||||
this.registered = false;
|
||||
|
||||
/**
|
||||
* Internal entity unique id, set by the @see EntityManager
|
||||
*/
|
||||
this.uid = 0;
|
||||
|
||||
/* typehints:start */
|
||||
|
||||
/**
|
||||
* Stores if this entity is destroyed, set by the @see EntityManager
|
||||
* @type {boolean} */
|
||||
this.destroyed;
|
||||
|
||||
/**
|
||||
* Stores if this entity is queued to get destroyed in the next tick
|
||||
* of the @see EntityManager
|
||||
* @type {boolean} */
|
||||
this.queuedForDestroy;
|
||||
|
||||
/**
|
||||
* Stores the reason why this entity was destroyed
|
||||
* @type {string} */
|
||||
this.destroyReason;
|
||||
|
||||
/* typehints:end */
|
||||
}
|
||||
|
||||
static getId() {
|
||||
return "Entity";
|
||||
}
|
||||
|
||||
/**
|
||||
* @see BasicSerializableObject.getSchema
|
||||
* @returns {import("../savegame/serialization").Schema}
|
||||
*/
|
||||
static getSchema() {
|
||||
return {
|
||||
uid: types.uint,
|
||||
// components: types.keyValueMap(types.objData(gComponentRegistry), false)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the entity is still alive
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAlive() {
|
||||
return !this.destroyed && !this.queuedForDestroy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the meta class of the entity.
|
||||
* @returns {object}
|
||||
*/
|
||||
getMetaclass() {
|
||||
assert(this.meta, "Entity has no metaclass");
|
||||
return this.meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal destroy callback
|
||||
*/
|
||||
internalDestroyCallback() {
|
||||
assert(!this.destroyed, "Can not destroy entity twice");
|
||||
this.destroyed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new component, only possible until the entity is registered on the entity manager,
|
||||
* after that use @see EntityManager.addDynamicComponent
|
||||
* @param {Component} componentInstance
|
||||
* @param {boolean} force Used by the entity manager. Internal parameter, do not change
|
||||
*/
|
||||
addComponent(componentInstance, force = false) {
|
||||
assert(force || !this.registered, "Entity already registered, use EntityManager.addDynamicComponent");
|
||||
const id = /** @type {typeof Component} */ (componentInstance.constructor).getId();
|
||||
assert(!this.components[id], "Component already present");
|
||||
this.components[id] = componentInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a given component, only possible until the entity is registered on the entity manager,
|
||||
* after that use @see EntityManager.removeDynamicComponent
|
||||
* @param {typeof Component} componentClass
|
||||
*/
|
||||
removeComponent(componentClass) {
|
||||
assert(!this.registered, "Entity already registered, use EntityManager.removeDynamicComponent");
|
||||
const id = componentClass.getId();
|
||||
assert(this.components[id], "Component does not exist on entity");
|
||||
delete this.components[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the entity, to override use @see Entity.drawImpl
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {
|
||||
const context = parameters.context;
|
||||
const staticComp = this.components.StaticMapEntity;
|
||||
|
||||
if (G_IS_DEV && staticComp && globalConfig.debug.showEntityBounds) {
|
||||
if (staticComp) {
|
||||
const transformed = staticComp.getTileSpaceBounds();
|
||||
context.strokeStyle = "rgba(255, 0, 0, 0.5)";
|
||||
context.lineWidth = 2;
|
||||
// const boundsSize = 20;
|
||||
context.beginPath();
|
||||
context.rect(
|
||||
transformed.x * globalConfig.tileSize,
|
||||
transformed.y * globalConfig.tileSize,
|
||||
transformed.w * globalConfig.tileSize,
|
||||
transformed.h * globalConfig.tileSize
|
||||
);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
if (G_IS_DEV && staticComp && globalConfig.debug.showAcceptorEjectors) {
|
||||
const ejectorComp = this.components.ItemEjector;
|
||||
|
||||
if (ejectorComp) {
|
||||
const ejectorSprite = Loader.getSprite("sprites/debug/ejector_slot.png");
|
||||
for (let i = 0; i < ejectorComp.slots.length; ++i) {
|
||||
const slot = ejectorComp.slots[i];
|
||||
const slotTile = staticComp.localTileToWorld(slot.pos);
|
||||
const direction = staticComp.localDirectionToWorld(slot.direction);
|
||||
const directionVector = enumDirectionToVector[direction];
|
||||
const angle = Math_radians(enumDirectionToAngle[direction]);
|
||||
|
||||
context.globalAlpha = slot.item ? 1 : 0.2;
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite: ejectorSprite,
|
||||
x: (slotTile.x + 0.5 + directionVector.x * 0.37) * globalConfig.tileSize,
|
||||
y: (slotTile.y + 0.5 + directionVector.y * 0.37) * globalConfig.tileSize,
|
||||
angle,
|
||||
size: globalConfig.tileSize * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
const acceptorComp = this.components.ItemAcceptor;
|
||||
|
||||
if (acceptorComp) {
|
||||
const acceptorSprite = Loader.getSprite("sprites/debug/acceptor_slot.png");
|
||||
for (let i = 0; i < acceptorComp.slots.length; ++i) {
|
||||
const slot = acceptorComp.slots[i];
|
||||
const slotTile = staticComp.localTileToWorld(slot.pos);
|
||||
for (let k = 0; k < slot.directions.length; ++k) {
|
||||
const direction = staticComp.localDirectionToWorld(slot.directions[k]);
|
||||
const directionVector = enumDirectionToVector[direction];
|
||||
const angle = Math_radians(enumDirectionToAngle[direction] + 180);
|
||||
context.globalAlpha = 0.4;
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite: acceptorSprite,
|
||||
x: (slotTile.x + 0.5 + directionVector.x * 0.37) * globalConfig.tileSize,
|
||||
y: (slotTile.y + 0.5 + directionVector.y * 0.37) * globalConfig.tileSize,
|
||||
angle,
|
||||
size: globalConfig.tileSize * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
// this.drawImpl(parameters);
|
||||
}
|
||||
|
||||
///// Helper interfaces
|
||||
|
||||
///// Interface to override by subclasses
|
||||
|
||||
/**
|
||||
* override, should draw the entity
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawImpl(parameters) {
|
||||
abstract;
|
||||
}
|
||||
}
|
||||
57
src/js/game/entity_components.js
Normal file
57
src/js/game/entity_components.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/* typehints:start */
|
||||
import { StaticMapEntityComponent } from "./components/static_map_entity";
|
||||
import { BeltComponent } from "./components/belt";
|
||||
import { ItemEjectorComponent } from "./components/item_ejector";
|
||||
import { ItemAcceptorComponent } from "./components/item_acceptor";
|
||||
import { MinerComponent } from "./components/miner";
|
||||
import { ItemProcessorComponent } from "./components/item_processor";
|
||||
import { ReplaceableMapEntityComponent } from "./components/replaceable_map_entity";
|
||||
import { UndergroundBeltComponent } from "./components/underground_belt";
|
||||
import { UnremovableComponent } from "./components/unremovable";
|
||||
import { HubComponent } from "./components/hub";
|
||||
/* typehints:end */
|
||||
|
||||
/**
|
||||
* Typedefs for all entity components. These are not actually present on the entity,
|
||||
* thus they are undefined by default
|
||||
*/
|
||||
export class EntityComponentStorage {
|
||||
constructor() {
|
||||
// TODO: Figure out if its faster to declare all components here and not
|
||||
// compile them out (In theory, should make it a fast object in V8 engine)
|
||||
|
||||
/* typehints:start */
|
||||
|
||||
/** @type {StaticMapEntityComponent} */
|
||||
this.StaticMapEntity;
|
||||
|
||||
/** @type {BeltComponent} */
|
||||
this.Belt;
|
||||
|
||||
/** @type {ItemEjectorComponent} */
|
||||
this.ItemEjector;
|
||||
|
||||
/** @type {ItemAcceptorComponent} */
|
||||
this.ItemAcceptor;
|
||||
|
||||
/** @type {MinerComponent} */
|
||||
this.Miner;
|
||||
|
||||
/** @type {ItemProcessorComponent} */
|
||||
this.ItemProcessor;
|
||||
|
||||
/** @type {ReplaceableMapEntityComponent} */
|
||||
this.ReplaceableMapEntity;
|
||||
|
||||
/** @type {UndergroundBeltComponent} */
|
||||
this.UndergroundBelt;
|
||||
|
||||
/** @type {UnremovableComponent} */
|
||||
this.Unremovable;
|
||||
|
||||
/** @type {HubComponent} */
|
||||
this.Hub;
|
||||
|
||||
/* typehints:end */
|
||||
}
|
||||
}
|
||||
239
src/js/game/entity_manager.js
Normal file
239
src/js/game/entity_manager.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import { arrayDeleteValue, newEmptyMap } from "../core/utils";
|
||||
import { Component } from "./component";
|
||||
import { GameRoot } from "./root";
|
||||
import { Entity } from "./entity";
|
||||
import { BasicSerializableObject, types } from "../savegame/serialization";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("entity_manager");
|
||||
|
||||
// Manages all entities
|
||||
|
||||
// TODO & NOTICE: We use arrayDeleteValue instead of fastArrayDeleteValue since that does not preserve the order
|
||||
// This is slower but we need it for the street path generation
|
||||
|
||||
export class EntityManager extends BasicSerializableObject {
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
/** @type {Array<Entity>} */
|
||||
this.entities = [];
|
||||
|
||||
// We store a seperate list with entities to destroy, since we don't destroy
|
||||
// them instantly
|
||||
/** @type {Array<Entity>} */
|
||||
this.destroyList = [];
|
||||
|
||||
// Store a map from componentid to entities - This is used by the game system
|
||||
// for faster processing
|
||||
/** @type {Object.<string, Array<Entity>>} */
|
||||
this.componentToEntity = newEmptyMap();
|
||||
|
||||
// Store the next uid to use
|
||||
this.nextUid = 10000;
|
||||
}
|
||||
|
||||
static getId() {
|
||||
return "EntityManager";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return {
|
||||
nextUid: types.uint,
|
||||
};
|
||||
}
|
||||
|
||||
getStatsText() {
|
||||
return this.entities.length + " entities [" + this.destroyList.length + " to kill]";
|
||||
}
|
||||
|
||||
// Main update
|
||||
update() {
|
||||
this.processDestroyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new entity
|
||||
* @param {Entity} entity
|
||||
* @param {number=} uid Optional predefined uid
|
||||
*/
|
||||
registerEntity(entity, uid = null) {
|
||||
assert(this.entities.indexOf(entity) < 0, `RegisterEntity() called twice for entity ${entity}`);
|
||||
assert(!entity.destroyed, `Attempting to register destroyed entity ${entity}`);
|
||||
|
||||
if (G_IS_DEV && uid !== null) {
|
||||
assert(!this.findByUid(uid, false), "Entity uid already taken: " + uid);
|
||||
}
|
||||
|
||||
if (uid !== null) {
|
||||
assert(uid >= 0 && uid < Number.MAX_SAFE_INTEGER, "Invalid uid passed: " + uid);
|
||||
}
|
||||
|
||||
this.entities.push(entity);
|
||||
|
||||
// Register into the componentToEntity map
|
||||
for (const componentId in entity.components) {
|
||||
if (entity.components[componentId]) {
|
||||
if (this.componentToEntity[componentId]) {
|
||||
this.componentToEntity[componentId].push(entity);
|
||||
} else {
|
||||
this.componentToEntity[componentId] = [entity];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Give each entity a unique id
|
||||
entity.uid = uid ? uid : this.generateUid();
|
||||
entity.registered = true;
|
||||
|
||||
this.root.signals.entityAdded.dispatch(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts all entitiy lists after a resync
|
||||
*/
|
||||
sortEntityLists() {
|
||||
this.entities.sort((a, b) => a.uid - b.uid);
|
||||
this.destroyList.sort((a, b) => a.uid - b.uid);
|
||||
|
||||
for (const key in this.componentToEntity) {
|
||||
this.componentToEntity[key].sort((a, b) => a.uid - b.uid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new uid
|
||||
* @returns {number}
|
||||
*/
|
||||
generateUid() {
|
||||
return this.nextUid++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call to attach a new component after the creation of the entity
|
||||
* @param {Entity} entity
|
||||
* @param {Component} component
|
||||
*/
|
||||
attachDynamicComponent(entity, component) {
|
||||
entity.addComponent(component, true);
|
||||
const componentId = /** @type {typeof Component} */ (component.constructor).getId();
|
||||
if (this.componentToEntity[componentId]) {
|
||||
this.componentToEntity[componentId].push(entity);
|
||||
} else {
|
||||
this.componentToEntity[componentId] = [entity];
|
||||
}
|
||||
this.root.signals.entityGotNewComponent.dispatch(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an entity buy its uid, kinda slow since it loops over all entities
|
||||
* @param {number} uid
|
||||
* @param {boolean=} errorWhenNotFound
|
||||
* @returns {Entity}
|
||||
*/
|
||||
findByUid(uid, errorWhenNotFound = true) {
|
||||
const arr = this.entities;
|
||||
for (let i = 0, len = arr.length; i < len; ++i) {
|
||||
const entity = arr[i];
|
||||
if (entity.uid === uid) {
|
||||
if (entity.queuedForDestroy || entity.destroyed) {
|
||||
if (errorWhenNotFound) {
|
||||
logger.warn("Entity with UID", uid, "not found (destroyed)");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
if (errorWhenNotFound) {
|
||||
logger.warn("Entity with UID", uid, "not found");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all entities having the given component
|
||||
* @param {typeof Component} componentHandle
|
||||
* @returns {Array<Entity>} entities
|
||||
*/
|
||||
getAllWithComponent(componentHandle) {
|
||||
return this.componentToEntity[componentHandle.getId()] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all of a given class. This is SLOW!
|
||||
* @param {object} entityClass
|
||||
* @returns {Array<Entity>} entities
|
||||
*/
|
||||
getAllOfClass(entityClass) {
|
||||
// FIXME: Slow
|
||||
const result = [];
|
||||
for (let i = 0; i < this.entities.length; ++i) {
|
||||
const entity = this.entities[i];
|
||||
if (entity instanceof entityClass) {
|
||||
result.push(entity);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters all components of an entity from the component to entity mapping
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
unregisterEntityComponents(entity) {
|
||||
for (const componentId in entity.components) {
|
||||
if (entity.components[componentId]) {
|
||||
arrayDeleteValue(this.componentToEntity[componentId], entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Processes the entities to destroy and actually destroys them
|
||||
/* eslint-disable max-statements */
|
||||
processDestroyList() {
|
||||
for (let i = 0; i < this.destroyList.length; ++i) {
|
||||
const entity = this.destroyList[i];
|
||||
|
||||
// Remove from entities list
|
||||
arrayDeleteValue(this.entities, entity);
|
||||
|
||||
// Remove from componentToEntity list
|
||||
this.unregisterEntityComponents(entity);
|
||||
|
||||
entity.registered = false;
|
||||
entity.internalDestroyCallback();
|
||||
|
||||
this.root.signals.entityDestroyed.dispatch(entity);
|
||||
}
|
||||
|
||||
this.destroyList = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues an entity for destruction
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
destroyEntity(entity) {
|
||||
if (entity.destroyed) {
|
||||
logger.error("Tried to destroy already destroyed entity:", entity.uid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.queuedForDestroy) {
|
||||
logger.error("Trying to destroy entity which is already queued for destroy!", entity.uid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.destroyList.indexOf(entity) < 0) {
|
||||
this.destroyList.push(entity);
|
||||
entity.queuedForDestroy = true;
|
||||
this.root.signals.entityQueuedForDestroy.dispatch(entity);
|
||||
} else {
|
||||
assert(false, "Trying to destroy entity twice");
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/js/game/game_loading_overlay.js
Normal file
57
src/js/game/game_loading_overlay.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/* typehints:start */
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
export class GameLoadingOverlay {
|
||||
/**
|
||||
*
|
||||
* @param {Application} app
|
||||
* @param {HTMLElement} parent
|
||||
*/
|
||||
constructor(app, parent) {
|
||||
this.app = app;
|
||||
this.parent = parent;
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
this.element = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the overlay if its currently visible
|
||||
*/
|
||||
removeIfAttached() {
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the loading overlay is attached
|
||||
*/
|
||||
isAttached() {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a super basic overlay
|
||||
*/
|
||||
showBasic() {
|
||||
assert(!this.element, "Loading overlay already visible, cant show again");
|
||||
this.element = document.createElement("div");
|
||||
this.element.classList.add("gameLoadingOverlay");
|
||||
this.parent.appendChild(this.element);
|
||||
this.internalAddSpinnerAndText(this.element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a text with 'loading' and a spinner
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
internalAddSpinnerAndText(element) {
|
||||
const inner = document.createElement("span");
|
||||
inner.classList.add("prefab_LoadingTextWithAnim");
|
||||
inner.innerText = "Loading";
|
||||
element.appendChild(inner);
|
||||
}
|
||||
}
|
||||
43
src/js/game/game_system.js
Normal file
43
src/js/game/game_system.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
/* typehints:end */
|
||||
|
||||
/**
|
||||
* A game system processes all entities which match a given schema, usually a list of
|
||||
* required components. This is the core of the game logic.
|
||||
*/
|
||||
export class GameSystem {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
///// PUBLIC API /////
|
||||
|
||||
/**
|
||||
* Updates the game system, override to perform logic
|
||||
*/
|
||||
update() {}
|
||||
|
||||
/**
|
||||
* Override, do not call this directly, use startDraw()
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {}
|
||||
|
||||
/**
|
||||
* Should refresh all caches
|
||||
*/
|
||||
refreshCaches() {}
|
||||
|
||||
/**
|
||||
* @see GameSystem.draw Wrapper arround the draw method
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
startDraw(parameters) {
|
||||
this.draw(parameters);
|
||||
}
|
||||
}
|
||||
104
src/js/game/game_system_manager.js
Normal file
104
src/js/game/game_system_manager.js
Normal file
@@ -0,0 +1,104 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
/* typehints:end */
|
||||
|
||||
import { createLogger } from "../core/logging";
|
||||
import { BeltSystem } from "./systems/belt";
|
||||
import { ItemEjectorSystem } from "./systems/item_ejector";
|
||||
import { MapResourcesSystem } from "./systems/map_resources";
|
||||
import { MinerSystem } from "./systems/miner";
|
||||
import { ItemProcessorSystem } from "./systems/item_processor";
|
||||
import { UndergroundBeltSystem } from "./systems/underground_belt";
|
||||
import { HubSystem } from "./systems/hub";
|
||||
import { StaticMapEntitySystem } from "./systems/static_map_entity";
|
||||
|
||||
const logger = createLogger("game_system_manager");
|
||||
|
||||
export class GameSystemManager {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
this.systems = {
|
||||
/* typehints:start */
|
||||
/** @type {BeltSystem} */
|
||||
belt: null,
|
||||
|
||||
/** @type {ItemEjectorSystem} */
|
||||
itemEjector: null,
|
||||
|
||||
/** @type {MapResourcesSystem} */
|
||||
mapResources: null,
|
||||
|
||||
/** @type {MinerSystem} */
|
||||
miner: null,
|
||||
|
||||
/** @type {ItemProcessorSystem} */
|
||||
itemProcessor: null,
|
||||
|
||||
/** @type {UndergroundBeltSystem} */
|
||||
undergroundBelt: null,
|
||||
|
||||
/** @type {HubSystem} */
|
||||
hub: null,
|
||||
|
||||
/** @type {StaticMapEntitySystem} */
|
||||
staticMapEntities: null,
|
||||
|
||||
/* typehints:end */
|
||||
};
|
||||
this.systemUpdateOrder = [];
|
||||
|
||||
this.internalInitSystems();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all systems
|
||||
*/
|
||||
internalInitSystems() {
|
||||
const add = (id, systemClass) => {
|
||||
this.systems[id] = new systemClass(this.root);
|
||||
this.systemUpdateOrder.push(id);
|
||||
};
|
||||
|
||||
// Order is important!
|
||||
|
||||
add("belt", BeltSystem);
|
||||
|
||||
add("itemEjector", ItemEjectorSystem);
|
||||
|
||||
add("miner", MinerSystem);
|
||||
|
||||
add("mapResources", MapResourcesSystem);
|
||||
|
||||
add("itemProcessor", ItemProcessorSystem);
|
||||
|
||||
add("undergroundBelt", UndergroundBeltSystem);
|
||||
|
||||
add("hub", HubSystem);
|
||||
|
||||
add("staticMapEntities", StaticMapEntitySystem);
|
||||
|
||||
logger.log("📦 There are", this.systemUpdateOrder.length, "game systems");
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all systems
|
||||
*/
|
||||
update() {
|
||||
for (let i = 0; i < this.systemUpdateOrder.length; ++i) {
|
||||
const system = this.systems[this.systemUpdateOrder[i]];
|
||||
system.update();
|
||||
}
|
||||
}
|
||||
|
||||
refreshCaches() {
|
||||
for (let i = 0; i < this.systemUpdateOrder.length; ++i) {
|
||||
const system = this.systems[this.systemUpdateOrder[i]];
|
||||
system.refreshCaches();
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/js/game/game_system_with_filter.js
Normal file
175
src/js/game/game_system_with_filter.js
Normal file
@@ -0,0 +1,175 @@
|
||||
/* typehints:start */
|
||||
import { Component } from "./component";
|
||||
import { GameRoot } from "./root";
|
||||
import { Entity } from "./entity";
|
||||
/* typehints:end */
|
||||
|
||||
import { GameSystem } from "./game_system";
|
||||
import { arrayDelete } from "../core/utils";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Math_floor, Math_ceil } from "../core/builtins";
|
||||
|
||||
export class GameSystemWithFilter extends GameSystem {
|
||||
/**
|
||||
* Constructs a new game system with the given component filter. It will process
|
||||
* all entities which have *all* of the passed components
|
||||
* @param {GameRoot} root
|
||||
* @param {Array<typeof Component>} requiredComponents
|
||||
*/
|
||||
constructor(root, requiredComponents) {
|
||||
super(root);
|
||||
this.requiredComponents = requiredComponents;
|
||||
this.requiredComponentIds = requiredComponents.map(component => component.getId());
|
||||
|
||||
/**
|
||||
* All entities which match the current components
|
||||
* @type {Array<Entity>}
|
||||
*/
|
||||
this.allEntities = [];
|
||||
|
||||
this.root.signals.entityAdded.add(this.internalPushEntityIfMatching, this);
|
||||
this.root.signals.entityGotNewComponent.add(this.internalReconsiderEntityToAdd, this);
|
||||
this.root.signals.entityQueuedForDestroy.add(this.internalPopEntityIfMatching, this);
|
||||
|
||||
this.root.signals.postLoadHook.add(this.internalPostLoadHook, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a function for each matching entity on the screen, useful for drawing them
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {function} callback
|
||||
*/
|
||||
forEachMatchingEntityOnScreen(parameters, callback) {
|
||||
const cullRange = parameters.visibleRect.toTileCullRectangle();
|
||||
if (this.allEntities.length < 100) {
|
||||
// So, its much quicker to simply perform per-entity checking
|
||||
|
||||
for (let i = 0; i < this.allEntities.length; ++i) {
|
||||
const entity = this.allEntities[i];
|
||||
if (cullRange.containsRect(entity.components.StaticMapEntity.getTileSpaceBounds())) {
|
||||
callback(parameters, entity);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const top = cullRange.top();
|
||||
const right = cullRange.right();
|
||||
const bottom = cullRange.bottom();
|
||||
const left = cullRange.left();
|
||||
|
||||
const border = 1;
|
||||
const minY = top - border;
|
||||
const maxY = bottom + border;
|
||||
const minX = left - border;
|
||||
const maxX = right + border - 1;
|
||||
|
||||
const map = this.root.map;
|
||||
|
||||
let seenUids = new Set();
|
||||
|
||||
const chunkStartX = Math_floor(minX / globalConfig.mapChunkSize);
|
||||
const chunkStartY = Math_floor(minY / globalConfig.mapChunkSize);
|
||||
|
||||
const chunkEndX = Math_ceil(maxX / globalConfig.mapChunkSize);
|
||||
const chunkEndY = Math_ceil(maxY / globalConfig.mapChunkSize);
|
||||
|
||||
const requiredComponents = this.requiredComponentIds;
|
||||
|
||||
// Render y from top down for proper blending
|
||||
for (let chunkX = chunkStartX; chunkX <= chunkEndX; ++chunkX) {
|
||||
for (let chunkY = chunkStartY; chunkY <= chunkEndY; ++chunkY) {
|
||||
const chunk = map.getChunk(chunkX, chunkY, false);
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// BIG TODO: CULLING ON AN ENTITY BASIS
|
||||
|
||||
const entities = chunk.containedEntities;
|
||||
entityLoop: for (let i = 0; i < entities.length; ++i) {
|
||||
const entity = entities[i];
|
||||
|
||||
// Avoid drawing twice
|
||||
if (seenUids.has(entity.uid)) {
|
||||
continue;
|
||||
}
|
||||
seenUids.add(entity.uid);
|
||||
|
||||
for (let i = 0; i < requiredComponents.length; ++i) {
|
||||
if (!entity.components[requiredComponents[i]]) {
|
||||
continue entityLoop;
|
||||
}
|
||||
}
|
||||
callback(parameters, entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
internalPushEntityIfMatching(entity) {
|
||||
for (let i = 0; i < this.requiredComponentIds.length; ++i) {
|
||||
if (!entity.components[this.requiredComponentIds[i]]) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
assert(this.allEntities.indexOf(entity) < 0, "entity already in list: " + entity);
|
||||
this.internalRegisterEntity(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
internalReconsiderEntityToAdd(entity) {
|
||||
for (let i = 0; i < this.requiredComponentIds.length; ++i) {
|
||||
if (!entity.components[this.requiredComponentIds[i]]) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.allEntities.indexOf(entity) >= 0) {
|
||||
return;
|
||||
}
|
||||
this.internalRegisterEntity(entity);
|
||||
}
|
||||
|
||||
refreshCaches() {
|
||||
this.allEntities.sort((a, b) => a.uid - b.uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes all target entities after the game has loaded
|
||||
*/
|
||||
internalPostLoadHook() {
|
||||
this.refreshCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
internalRegisterEntity(entity) {
|
||||
this.allEntities.push(entity);
|
||||
|
||||
if (this.root.gameInitialized) {
|
||||
// Sort entities by uid so behaviour is predictable
|
||||
this.allEntities.sort((a, b) => a.uid - b.uid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
internalPopEntityIfMatching(entity) {
|
||||
const index = this.allEntities.indexOf(entity);
|
||||
if (index >= 0) {
|
||||
arrayDelete(this.allEntities, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
330
src/js/game/hub_goals.js
Normal file
330
src/js/game/hub_goals.js
Normal file
@@ -0,0 +1,330 @@
|
||||
import { BasicSerializableObject } from "../savegame/serialization";
|
||||
import { GameRoot } from "./root";
|
||||
import { ShapeDefinition, enumSubShape } from "./shape_definition";
|
||||
import { enumColors } from "./colors";
|
||||
import { randomChoice, clamp, randomInt, findNiceIntegerValue } from "../core/utils";
|
||||
import { tutorialGoals, enumHubGoalRewards } from "./tutorial_goals";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Math_random } from "../core/builtins";
|
||||
import { UPGRADES } from "./upgrades";
|
||||
import { enumItemProcessorTypes } from "./components/item_processor";
|
||||
|
||||
const logger = createLogger("hub_goals");
|
||||
|
||||
export class HubGoals extends BasicSerializableObject {
|
||||
static getId() {
|
||||
return "HubGoals";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
super();
|
||||
|
||||
this.root = root;
|
||||
|
||||
this.level = 1;
|
||||
|
||||
/**
|
||||
* Which story rewards we already gained
|
||||
*/
|
||||
this.gainedRewards = {};
|
||||
|
||||
/**
|
||||
* Mapping from shape hash -> amount
|
||||
* @type {Object<string, number>}
|
||||
*/
|
||||
this.storedShapes = {};
|
||||
|
||||
/**
|
||||
* Stores the levels for all upgrades
|
||||
* @type {Object<string, number>}
|
||||
*/
|
||||
this.upgradeLevels = {};
|
||||
|
||||
/**
|
||||
* Stores the improvements for all upgrades
|
||||
* @type {Object<string, number>}
|
||||
*/
|
||||
this.upgradeImprovements = {};
|
||||
for (const key in UPGRADES) {
|
||||
this.upgradeImprovements[key] = UPGRADES[key].baseValue || 1;
|
||||
}
|
||||
|
||||
this.createNextGoal();
|
||||
|
||||
// Allow quickly switching goals in dev mode with key "C"
|
||||
if (G_IS_DEV) {
|
||||
this.root.gameState.inputReciever.keydown.add(key => {
|
||||
if (key.keyCode === 67) {
|
||||
// Key: c
|
||||
this.onGoalCompleted();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how much of the current shape is stored
|
||||
* @param {ShapeDefinition} definition
|
||||
* @returns {number}
|
||||
*/
|
||||
getShapesStored(definition) {
|
||||
return this.storedShapes[definition.getHash()] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how much of the current goal was already delivered
|
||||
*/
|
||||
getCurrentGoalDelivered() {
|
||||
return this.getShapesStored(this.currentGoal.definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current level of a given upgrade
|
||||
* @param {string} upgradeId
|
||||
*/
|
||||
getUpgradeLevel(upgradeId) {
|
||||
return this.upgradeLevels[upgradeId] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given reward is already unlocked
|
||||
* @param {enumHubGoalRewards} reward
|
||||
*/
|
||||
isRewardUnlocked(reward) {
|
||||
if (G_IS_DEV && globalConfig.debug.allBuildingsUnlocked) {
|
||||
return true;
|
||||
}
|
||||
return !!this.gainedRewards[reward];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the given definition, by either accounting it towards the
|
||||
* goal or otherwise granting some points
|
||||
* @param {ShapeDefinition} definition
|
||||
*/
|
||||
handleDefinitionDelivered(definition) {
|
||||
const hash = definition.getHash();
|
||||
this.storedShapes[hash] = (this.storedShapes[hash] || 0) + 1;
|
||||
|
||||
// Check if we have enough for the next level
|
||||
const targetHash = this.currentGoal.definition.getHash();
|
||||
if (
|
||||
this.storedShapes[targetHash] >= this.currentGoal.required ||
|
||||
(G_IS_DEV && globalConfig.debug.rewardsInstant)
|
||||
) {
|
||||
this.onGoalCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the next goal
|
||||
*/
|
||||
createNextGoal() {
|
||||
const storyIndex = this.level - 1;
|
||||
if (storyIndex < tutorialGoals.length) {
|
||||
const { shape, required, reward } = tutorialGoals[storyIndex];
|
||||
this.currentGoal = {
|
||||
/** @type {ShapeDefinition} */
|
||||
definition: this.root.shapeDefinitionMgr.registerOrReturnHandle(
|
||||
ShapeDefinition.fromShortKey(shape)
|
||||
),
|
||||
required,
|
||||
reward,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const reward = enumHubGoalRewards.no_reward;
|
||||
|
||||
this.currentGoal = {
|
||||
/** @type {ShapeDefinition} */
|
||||
definition: this.createRandomShape(),
|
||||
required: 1000 + findNiceIntegerValue(this.level * 47.5),
|
||||
reward,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the level was completed
|
||||
*/
|
||||
onGoalCompleted() {
|
||||
const reward = this.currentGoal.reward;
|
||||
this.gainedRewards[reward] = (this.gainedRewards[reward] || 0) + 1;
|
||||
this.root.signals.storyGoalCompleted.dispatch(this.level, reward);
|
||||
|
||||
this.root.app.gameAnalytics.handleLevelCompleted(this.level);
|
||||
++this.level;
|
||||
this.createNextGoal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a given upgrade can be unlocked
|
||||
* @param {string} upgradeId
|
||||
*/
|
||||
canUnlockUpgrade(upgradeId) {
|
||||
const handle = UPGRADES[upgradeId];
|
||||
const currentLevel = this.getUpgradeLevel(upgradeId);
|
||||
|
||||
if (currentLevel >= handle.tiers.length) {
|
||||
// Max level
|
||||
return false;
|
||||
}
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.upgradesNoCost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tierData = handle.tiers[currentLevel];
|
||||
|
||||
for (let i = 0; i < tierData.required.length; ++i) {
|
||||
const requirement = tierData.required[i];
|
||||
if ((this.storedShapes[requirement.shape] || 0) < requirement.amount) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to unlock the given upgrade
|
||||
* @param {string} upgradeId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
tryUnlockUgprade(upgradeId) {
|
||||
if (!this.canUnlockUpgrade(upgradeId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const handle = UPGRADES[upgradeId];
|
||||
const currentLevel = this.getUpgradeLevel(upgradeId);
|
||||
|
||||
const tierData = handle.tiers[currentLevel];
|
||||
if (!tierData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.upgradesNoCost) {
|
||||
// Dont take resources
|
||||
} else {
|
||||
for (let i = 0; i < tierData.required.length; ++i) {
|
||||
const requirement = tierData.required[i];
|
||||
|
||||
// Notice: Don't have to check for hash here
|
||||
this.storedShapes[requirement.shape] -= requirement.amount;
|
||||
}
|
||||
}
|
||||
|
||||
this.upgradeLevels[upgradeId] = (this.upgradeLevels[upgradeId] || 0) + 1;
|
||||
this.upgradeImprovements[upgradeId] += tierData.improvement;
|
||||
|
||||
this.root.signals.upgradePurchased.dispatch(upgradeId);
|
||||
|
||||
this.root.app.gameAnalytics.handleUpgradeUnlocked(upgradeId, currentLevel);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ShapeDefinition}
|
||||
*/
|
||||
createRandomShape() {
|
||||
const layerCount = clamp(this.level / 50, 2, 4);
|
||||
/** @type {Array<import("./shape_definition").ShapeLayer>} */
|
||||
let layers = [];
|
||||
|
||||
// @ts-ignore
|
||||
const randomColor = () => randomChoice(Object.values(enumColors));
|
||||
// @ts-ignore
|
||||
const randomShape = () => randomChoice(Object.values(enumSubShape));
|
||||
|
||||
let anyIsMissingTwo = false;
|
||||
|
||||
for (let i = 0; i < layerCount; ++i) {
|
||||
/** @type {import("./shape_definition").ShapeLayer} */
|
||||
const layer = [null, null, null, null];
|
||||
|
||||
for (let quad = 0; quad < 4; ++quad) {
|
||||
layer[quad] = {
|
||||
subShape: randomShape(),
|
||||
color: randomColor(),
|
||||
};
|
||||
}
|
||||
|
||||
// Sometimes shapes are missing
|
||||
if (Math_random() > 0.85) {
|
||||
layer[randomInt(0, 3)] = null;
|
||||
}
|
||||
|
||||
// Sometimes they actually are missing *two* ones!
|
||||
// Make sure at max only one layer is missing it though, otherwise we could
|
||||
// create an uncreateable shape
|
||||
if (Math_random() > 0.95 && !anyIsMissingTwo) {
|
||||
layer[randomInt(0, 3)] = null;
|
||||
anyIsMissingTwo = true;
|
||||
}
|
||||
|
||||
layers.push(layer);
|
||||
}
|
||||
|
||||
const definition = new ShapeDefinition({ layers });
|
||||
return this.root.shapeDefinitionMgr.registerOrReturnHandle(definition);
|
||||
}
|
||||
|
||||
////////////// HELPERS
|
||||
|
||||
/**
|
||||
* Belt speed
|
||||
* @returns {number} items / sec
|
||||
*/
|
||||
getBeltBaseSpeed() {
|
||||
return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Underground belt speed
|
||||
* @returns {number} items / sec
|
||||
*/
|
||||
getUndergroundBeltBaseSpeed() {
|
||||
return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Miner speed
|
||||
* @returns {number} items / sec
|
||||
*/
|
||||
getMinerBaseSpeed() {
|
||||
return globalConfig.minerSpeedItemsPerSecond * this.upgradeImprovements.miner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processor speed
|
||||
* @param {enumItemProcessorTypes} processorType
|
||||
* @returns {number} items / sec
|
||||
*/
|
||||
getProcessorBaseSpeed(processorType) {
|
||||
switch (processorType) {
|
||||
case enumItemProcessorTypes.trash:
|
||||
return 1e30;
|
||||
case enumItemProcessorTypes.splitter:
|
||||
return (2 / globalConfig.beltSpeedItemsPerSecond) * this.upgradeImprovements.splitter;
|
||||
case enumItemProcessorTypes.cutter:
|
||||
case enumItemProcessorTypes.rotater:
|
||||
case enumItemProcessorTypes.stacker:
|
||||
case enumItemProcessorTypes.mixer:
|
||||
case enumItemProcessorTypes.painter:
|
||||
return (
|
||||
(1 / globalConfig.beltSpeedItemsPerSecond) *
|
||||
this.upgradeImprovements.processor *
|
||||
globalConfig.buildingSpeeds[processorType]
|
||||
);
|
||||
default:
|
||||
assertAlways(false, "invalid processor type");
|
||||
}
|
||||
|
||||
return 1 / globalConfig.beltSpeedItemsPerSecond;
|
||||
}
|
||||
}
|
||||
175
src/js/game/hud/base_hud_part.js
Normal file
175
src/js/game/hud/base_hud_part.js
Normal file
@@ -0,0 +1,175 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "../root";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
/* typehints:end */
|
||||
|
||||
import { ClickDetector } from "../../core/click_detector";
|
||||
import { KeyActionMapper } from "../key_action_mapper";
|
||||
|
||||
export class BaseHUDPart {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
/** @type {Array<ClickDetector>} */
|
||||
this.clickDetectors = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Should create all require elements
|
||||
* @param {HTMLElement} parent
|
||||
*/
|
||||
createElements(parent) {}
|
||||
|
||||
/**
|
||||
* Should initialize the element, called *after* the elements have been created
|
||||
*/
|
||||
initialize() {
|
||||
abstract;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should update any required logic
|
||||
*/
|
||||
update() {}
|
||||
|
||||
/**
|
||||
* Should draw the hud
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {}
|
||||
|
||||
/**
|
||||
* Should draw any overlays (screen space)
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawOverlays(parameters) {}
|
||||
|
||||
/**
|
||||
* Should return true if the widget has a modal dialog opened and thus
|
||||
* the game does not need to update / redraw
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldPauseRendering() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return false if the game should be paused
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldPauseGame() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return true if this overlay is open and currently blocking any user interaction
|
||||
*/
|
||||
isBlockingOverlay() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the hud element, if overridden make sure to call super.cleanups
|
||||
*/
|
||||
cleanup() {
|
||||
if (this.clickDetectors) {
|
||||
for (let i = 0; i < this.clickDetectors.length; ++i) {
|
||||
this.clickDetectors[i].cleanup();
|
||||
}
|
||||
this.clickDetectors = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Should close the element, in case its supported
|
||||
*/
|
||||
close() {}
|
||||
|
||||
// Helpers
|
||||
|
||||
/**
|
||||
* Calls closeMethod if an overlay is opened
|
||||
* @param {function=} closeMethod
|
||||
*/
|
||||
closeOnOverlayOpen(closeMethod = null) {
|
||||
this.root.hud.signals.overlayOpened.add(overlay => {
|
||||
if (overlay !== this) {
|
||||
(closeMethod || this.close).call(this);
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to construct a new click detector
|
||||
* @param {Element} element The element to listen on
|
||||
* @param {function} handler The handler to call on this object
|
||||
* @param {import("../../core/click_detector").ClickDetectorConstructorArgs=} args Click detector arguments
|
||||
*
|
||||
*/
|
||||
trackClicks(element, handler, args = {}) {
|
||||
const detector = new ClickDetector(element, args);
|
||||
detector.click.add(handler, this);
|
||||
this.registerClickDetector(detector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new click detector
|
||||
* @param {ClickDetector} detector
|
||||
*/
|
||||
registerClickDetector(detector) {
|
||||
this.clickDetectors.push(detector);
|
||||
if (G_IS_DEV) {
|
||||
// @ts-ignore
|
||||
detector._src = "hud-" + this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes this element when its background is clicked
|
||||
* @param {HTMLElement} element
|
||||
* @param {function} closeMethod
|
||||
*/
|
||||
closeOnBackgroundClick(element, closeMethod = null) {
|
||||
const bgClickDetector = new ClickDetector(element, {
|
||||
preventDefault: true,
|
||||
targetOnly: true,
|
||||
applyCssClass: null,
|
||||
consumeEvents: true,
|
||||
clickSound: null,
|
||||
});
|
||||
|
||||
// If the state defines a close method, use that as fallback
|
||||
// @ts-ignore
|
||||
bgClickDetector.touchend.add(closeMethod || this.close, this);
|
||||
this.registerClickDetector(bgClickDetector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards the game speed keybindings so you can toggle pause / Fastforward
|
||||
* in the building tooltip and such
|
||||
* @param {KeyActionMapper} sourceMapper
|
||||
*/
|
||||
forwardGameSpeedKeybindings(sourceMapper) {
|
||||
sourceMapper.forward(this.root.gameState.keyActionMapper, [
|
||||
"gamespeed_pause",
|
||||
"gamespeed_fastforward",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards the map movement keybindings so you can move the map with the
|
||||
* arrow keys
|
||||
* @param {KeyActionMapper} sourceMapper
|
||||
*/
|
||||
forwardMapMovementKeybindings(sourceMapper) {
|
||||
sourceMapper.forward(this.root.gameState.keyActionMapper, [
|
||||
"map_move_up",
|
||||
"map_move_right",
|
||||
"map_move_down",
|
||||
"map_move_left",
|
||||
]);
|
||||
}
|
||||
}
|
||||
79
src/js/game/hud/dynamic_dom_attach.js
Normal file
79
src/js/game/hud/dynamic_dom_attach.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { GameRoot } from "../root";
|
||||
|
||||
// Automatically attaches and detaches elements from the dom
|
||||
// Also supports detaching elements after a given time, useful if there is a
|
||||
// hide animation like for the tooltips
|
||||
|
||||
// Also attaches a class name if desired
|
||||
|
||||
export class DynamicDomAttach {
|
||||
constructor(root, element, { timeToKeepSeconds = 0, attachClass = null } = {}) {
|
||||
/** @type {GameRoot} */
|
||||
this.root = root;
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
this.element = element;
|
||||
this.parent = this.element.parentElement;
|
||||
|
||||
this.attachClass = attachClass;
|
||||
|
||||
this.timeToKeepSeconds = timeToKeepSeconds;
|
||||
this.lastVisibleTime = 0;
|
||||
|
||||
// We start attached, so detach the node first
|
||||
this.attached = true;
|
||||
this.internalDetach();
|
||||
|
||||
this.internalIsClassAttached = false;
|
||||
this.classAttachTimeout = null;
|
||||
}
|
||||
|
||||
internalAttach() {
|
||||
if (!this.attached) {
|
||||
this.parent.appendChild(this.element);
|
||||
assert(this.element.parentElement === this.parent, "Invalid parent #1");
|
||||
this.attached = true;
|
||||
}
|
||||
}
|
||||
|
||||
internalDetach() {
|
||||
if (this.attached) {
|
||||
assert(this.element.parentElement === this.parent, "Invalid parent #2");
|
||||
this.element.parentElement.removeChild(this.element);
|
||||
this.attached = false;
|
||||
}
|
||||
}
|
||||
|
||||
isAttached() {
|
||||
return this.attached;
|
||||
}
|
||||
|
||||
update(isVisible) {
|
||||
if (isVisible) {
|
||||
this.lastVisibleTime = this.root ? this.root.time.realtimeNow() : 0;
|
||||
this.internalAttach();
|
||||
} else {
|
||||
if (!this.root || this.root.time.realtimeNow() - this.lastVisibleTime >= this.timeToKeepSeconds) {
|
||||
this.internalDetach();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.attachClass && isVisible !== this.internalIsClassAttached) {
|
||||
// State changed
|
||||
this.internalIsClassAttached = isVisible;
|
||||
|
||||
if (this.classAttachTimeout) {
|
||||
clearTimeout(this.classAttachTimeout);
|
||||
this.classAttachTimeout = null;
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
this.classAttachTimeout = setTimeout(() => {
|
||||
this.element.classList.add(this.attachClass);
|
||||
}, 15);
|
||||
} else {
|
||||
this.element.classList.remove(this.attachClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
187
src/js/game/hud/hud.js
Normal file
187
src/js/game/hud/hud.js
Normal file
@@ -0,0 +1,187 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "../root";
|
||||
/* typehints:end */
|
||||
|
||||
import { Signal } from "../../core/signal";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { HUDProcessingOverlay } from "./parts/processing_overlay";
|
||||
import { HUDBuildingsToolbar } from "./parts/buildings_toolbar";
|
||||
import { HUDBuildingPlacer } from "./parts/building_placer";
|
||||
import { HUDBetaOverlay } from "./parts/beta_overlay";
|
||||
import { HUDKeybindingOverlay } from "./parts/keybinding_overlay";
|
||||
import { HUDUnlockNotification } from "./parts/unlock_notification";
|
||||
import { HUDGameMenu } from "./parts/game_menu";
|
||||
import { HUDShop } from "./parts/shop";
|
||||
import { IS_MOBILE } from "../../core/config";
|
||||
|
||||
export class GameHUD {
|
||||
/**
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the hud parts
|
||||
*/
|
||||
initialize() {
|
||||
this.signals = {
|
||||
overlayOpened: new Signal(/* overlay */),
|
||||
};
|
||||
|
||||
this.parts = {
|
||||
processingOverlay: new HUDProcessingOverlay(this.root),
|
||||
|
||||
buildingsToolbar: new HUDBuildingsToolbar(this.root),
|
||||
buildingPlacer: new HUDBuildingPlacer(this.root),
|
||||
|
||||
unlockNotification: new HUDUnlockNotification(this.root),
|
||||
|
||||
gameMenu: new HUDGameMenu(this.root),
|
||||
|
||||
shop: new HUDShop(this.root),
|
||||
|
||||
// betaOverlay: new HUDBetaOverlay(this.root),
|
||||
};
|
||||
|
||||
this.signals = {
|
||||
selectedPlacementBuildingChanged: new Signal(/* metaBuilding|null */),
|
||||
};
|
||||
|
||||
if (!IS_MOBILE) {
|
||||
this.parts.keybindingOverlay = new HUDKeybindingOverlay(this.root);
|
||||
}
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const key in this.parts) {
|
||||
this.parts[key].createElements(frag);
|
||||
}
|
||||
|
||||
document.body.appendChild(frag);
|
||||
|
||||
for (const key in this.parts) {
|
||||
this.parts[key].initialize();
|
||||
}
|
||||
this.internalInitSignalConnections();
|
||||
|
||||
this.root.gameState.keyActionMapper.getBinding("toggle_hud").add(this.toggleUi, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to close all overlays
|
||||
*/
|
||||
closeAllOverlays() {
|
||||
for (const key in this.parts) {
|
||||
this.parts[key].close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the game logic should be paused
|
||||
*/
|
||||
shouldPauseGame() {
|
||||
for (const key in this.parts) {
|
||||
if (this.parts[key].shouldPauseGame()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the rendering can be paused
|
||||
*/
|
||||
shouldPauseRendering() {
|
||||
for (const key in this.parts) {
|
||||
if (this.parts[key].shouldPauseRendering()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the rendering can be paused
|
||||
*/
|
||||
hasBlockingOverlayOpen() {
|
||||
if (this.root.camera.getIsMapOverlayActive()) {
|
||||
return true;
|
||||
}
|
||||
for (const key in this.parts) {
|
||||
if (this.parts[key].isBlockingOverlay()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the ui
|
||||
*/
|
||||
toggleUi() {
|
||||
document.body.classList.toggle("uiHidden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes connections between parts
|
||||
*/
|
||||
internalInitSignalConnections() {
|
||||
const p = this.parts;
|
||||
p.buildingsToolbar.sigBuildingSelected.add(p.buildingPlacer.startSelection, p.buildingPlacer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all parts
|
||||
*/
|
||||
update() {
|
||||
if (!this.root.gameInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key in this.parts) {
|
||||
this.parts[key].update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws all parts
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {
|
||||
const partsOrder = ["buildingPlacer"];
|
||||
|
||||
for (let i = 0; i < partsOrder.length; ++i) {
|
||||
if (this.parts[partsOrder[i]]) {
|
||||
this.parts[partsOrder[i]].draw(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws all part overlays
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawOverlays(parameters) {
|
||||
const partsOrder = [];
|
||||
|
||||
for (let i = 0; i < partsOrder.length; ++i) {
|
||||
if (this.parts[partsOrder[i]]) {
|
||||
this.parts[partsOrder[i]].drawOverlays(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up everything
|
||||
*/
|
||||
cleanup() {
|
||||
for (const key in this.parts) {
|
||||
this.parts[key].cleanup();
|
||||
}
|
||||
|
||||
for (const key in this.signals) {
|
||||
this.signals[key].removeAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/js/game/hud/parts/beta_overlay.js
Normal file
10
src/js/game/hud/parts/beta_overlay.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
|
||||
export class HUDBetaOverlay extends BaseHUDPart {
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(parent, "ingame_HUD_BetaOverlay", [], "CLOSED BETA");
|
||||
}
|
||||
|
||||
initialize() {}
|
||||
}
|
||||
492
src/js/game/hud/parts/building_placer.js
Normal file
492
src/js/game/hud/parts/building_placer.js
Normal file
@@ -0,0 +1,492 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { MetaBuilding } from "../../meta_building";
|
||||
import { DrawParameters } from "../../../core/draw_parameters";
|
||||
import { globalConfig } from "../../../core/config";
|
||||
import { StaticMapEntityComponent } from "../../components/static_map_entity";
|
||||
import { STOP_PROPAGATION, Signal } from "../../../core/signal";
|
||||
import {
|
||||
Vector,
|
||||
enumDirectionToAngle,
|
||||
enumInvertedDirections,
|
||||
enumDirectionToVector,
|
||||
} from "../../../core/vector";
|
||||
import { pulseAnimation, makeDiv } from "../../../core/utils";
|
||||
import { DynamicDomAttach } from "../dynamic_dom_attach";
|
||||
import { TrackedState } from "../../../core/tracked_state";
|
||||
import { Math_abs, Math_radians } from "../../../core/builtins";
|
||||
import { Loader } from "../../../core/loader";
|
||||
import { drawRotatedSprite } from "../../../core/draw_utils";
|
||||
import { Entity } from "../../entity";
|
||||
|
||||
export class HUDBuildingPlacer extends BaseHUDPart {
|
||||
initialize() {
|
||||
/** @type {TypedTrackedState<MetaBuilding?>} */
|
||||
this.currentMetaBuilding = new TrackedState(this.onSelectedMetaBuildingChanged, this);
|
||||
this.currentBaseRotation = 0;
|
||||
|
||||
/** @type {Entity} */
|
||||
this.fakeEntity = null;
|
||||
|
||||
const keyActionMapper = this.root.gameState.keyActionMapper;
|
||||
keyActionMapper.getBinding("building_abort_placement").add(() => this.currentMetaBuilding.set(null));
|
||||
keyActionMapper.getBinding("back").add(() => this.currentMetaBuilding.set(null));
|
||||
|
||||
keyActionMapper.getBinding("rotate_while_placing").add(this.tryRotate, this);
|
||||
|
||||
this.domAttach = new DynamicDomAttach(this.root, this.element, {});
|
||||
|
||||
this.root.camera.downPreHandler.add(this.onMouseDown, this);
|
||||
this.root.camera.movePreHandler.add(this.onMouseMove, this);
|
||||
this.root.camera.upPostHandler.add(this.abortDragging, this);
|
||||
|
||||
this.currentlyDragging = false;
|
||||
|
||||
/**
|
||||
* The tile we last dragged onto
|
||||
* @type {Vector}
|
||||
* */
|
||||
this.lastDragTile = null;
|
||||
|
||||
/**
|
||||
* The tile we initially dragged from
|
||||
* @type {Vector}
|
||||
*/
|
||||
this.initialDragTile = null;
|
||||
}
|
||||
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(parent, "ingame_HUD_building_placer", [], ``);
|
||||
|
||||
this.buildingLabel = makeDiv(this.element, null, ["buildingLabel"], "Extract");
|
||||
this.buildingDescription = makeDiv(this.element, null, ["description"], "");
|
||||
}
|
||||
|
||||
/**
|
||||
* mouse down pre handler
|
||||
* @param {Vector} pos
|
||||
*/
|
||||
onMouseDown(pos) {
|
||||
if (this.root.camera.getIsMapOverlayActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentMetaBuilding.get()) {
|
||||
this.currentlyDragging = true;
|
||||
this.lastDragTile = this.root.camera.screenToWorld(pos).toTileSpace();
|
||||
|
||||
// Place initial building
|
||||
this.tryPlaceCurrentBuildingAt(this.lastDragTile);
|
||||
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* mouse move pre handler
|
||||
* @param {Vector} pos
|
||||
*/
|
||||
onMouseMove(pos) {
|
||||
if (this.root.camera.getIsMapOverlayActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentMetaBuilding.get() && this.lastDragTile) {
|
||||
const oldPos = this.lastDragTile;
|
||||
const newPos = this.root.camera.screenToWorld(pos).toTileSpace();
|
||||
|
||||
if (!oldPos.equals(newPos)) {
|
||||
const delta = newPos.sub(oldPos);
|
||||
// - Using bresenhams algorithmus
|
||||
|
||||
let x0 = oldPos.x;
|
||||
let y0 = oldPos.y;
|
||||
let x1 = newPos.x;
|
||||
let y1 = newPos.y;
|
||||
|
||||
var dx = Math_abs(x1 - x0);
|
||||
var dy = Math_abs(y1 - y0);
|
||||
var sx = x0 < x1 ? 1 : -1;
|
||||
var sy = y0 < y1 ? 1 : -1;
|
||||
var err = dx - dy;
|
||||
|
||||
while (true) {
|
||||
this.tryPlaceCurrentBuildingAt(new Vector(x0, y0));
|
||||
if (x0 === x1 && y0 === y1) break;
|
||||
var e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
x0 += sx;
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
y0 += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.lastDragTile = newPos;
|
||||
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
// ALways update since the camera might have moved
|
||||
const mousePos = this.root.app.mousePosition;
|
||||
if (mousePos) {
|
||||
this.onMouseMove(mousePos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* aborts any dragging op
|
||||
*/
|
||||
abortDragging() {
|
||||
this.currentlyDragging = true;
|
||||
this.lastDragTile = null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MetaBuilding} metaBuilding
|
||||
*/
|
||||
startSelection(metaBuilding) {
|
||||
this.currentMetaBuilding.set(metaBuilding);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MetaBuilding} metaBuilding
|
||||
*/
|
||||
onSelectedMetaBuildingChanged(metaBuilding) {
|
||||
this.root.hud.signals.selectedPlacementBuildingChanged.dispatch(metaBuilding);
|
||||
if (metaBuilding) {
|
||||
this.buildingLabel.innerHTML = metaBuilding.getName();
|
||||
this.buildingDescription.innerHTML = metaBuilding.getDescription();
|
||||
|
||||
this.fakeEntity = new Entity(null);
|
||||
metaBuilding.setupEntityComponents(this.fakeEntity, null);
|
||||
this.fakeEntity.addComponent(
|
||||
new StaticMapEntityComponent({
|
||||
origin: new Vector(0, 0),
|
||||
rotationDegrees: 0,
|
||||
tileSize: metaBuilding.getDimensions().copy(),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.currentlyDragging = false;
|
||||
this.fakeEntity = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to rotate
|
||||
*/
|
||||
tryRotate() {
|
||||
const selectedBuilding = this.currentMetaBuilding.get();
|
||||
if (selectedBuilding) {
|
||||
this.currentBaseRotation = (this.currentBaseRotation + 90) % 360;
|
||||
const staticComp = this.fakeEntity.components.StaticMapEntity;
|
||||
staticComp.rotationDegrees = this.currentBaseRotation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to delete the building under the mouse
|
||||
*/
|
||||
deleteBelowCursor() {
|
||||
const mousePosition = this.root.app.mousePosition;
|
||||
if (!mousePosition) {
|
||||
// Not on screen
|
||||
return;
|
||||
}
|
||||
|
||||
const worldPos = this.root.camera.screenToWorld(mousePosition);
|
||||
const tile = worldPos.toTileSpace();
|
||||
const contents = this.root.map.getTileContent(tile);
|
||||
if (contents) {
|
||||
this.root.logic.tryDeleteBuilding(contents);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas click handler
|
||||
* @param {Vector} mousePos
|
||||
* @param {boolean} cancelAction
|
||||
*/
|
||||
onCanvasClick(mousePos, cancelAction = false) {
|
||||
if (cancelAction) {
|
||||
if (this.currentMetaBuilding.get()) {
|
||||
this.currentMetaBuilding.set(null);
|
||||
} else {
|
||||
this.deleteBelowCursor();
|
||||
}
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
|
||||
if (!this.currentMetaBuilding.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to place the current building at the given tile
|
||||
* @param {Vector} tile
|
||||
*/
|
||||
tryPlaceCurrentBuildingAt(tile) {
|
||||
if (this.root.camera.zoomLevel < globalConfig.mapChunkOverviewMinZoom) {
|
||||
// Dont allow placing in overview mode
|
||||
return;
|
||||
}
|
||||
// Transform to world space
|
||||
|
||||
const metaBuilding = this.currentMetaBuilding.get();
|
||||
|
||||
const { rotation, rotationVariant } = metaBuilding.computeOptimalDirectionAndRotationVariantAtTile(
|
||||
this.root,
|
||||
tile,
|
||||
this.currentBaseRotation
|
||||
);
|
||||
|
||||
if (
|
||||
this.root.logic.tryPlaceBuilding({
|
||||
origin: tile,
|
||||
rotation,
|
||||
rotationVariant,
|
||||
building: this.currentMetaBuilding.get(),
|
||||
})
|
||||
) {
|
||||
// Succesfully placed
|
||||
|
||||
if (metaBuilding.getFlipOrientationAfterPlacement()) {
|
||||
this.currentBaseRotation = (180 + this.currentBaseRotation) % 360;
|
||||
}
|
||||
|
||||
if (!metaBuilding.getStayInPlacementMode() && !this.root.app.inputMgr.shiftIsDown) {
|
||||
// Stop placement
|
||||
this.currentMetaBuilding.set(null);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(parameters) {
|
||||
if (this.root.camera.zoomLevel < globalConfig.mapChunkOverviewMinZoom) {
|
||||
// Dont allow placing in overview mode
|
||||
this.domAttach.update(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.domAttach.update(this.currentMetaBuilding.get());
|
||||
const metaBuilding = this.currentMetaBuilding.get();
|
||||
|
||||
if (!metaBuilding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mousePosition = this.root.app.mousePosition;
|
||||
if (!mousePosition) {
|
||||
// Not on screen
|
||||
return;
|
||||
}
|
||||
|
||||
const worldPos = this.root.camera.screenToWorld(mousePosition);
|
||||
const tile = worldPos.toTileSpace();
|
||||
|
||||
// Compute best rotation variant
|
||||
const {
|
||||
rotation,
|
||||
rotationVariant,
|
||||
connectedEntities,
|
||||
} = metaBuilding.computeOptimalDirectionAndRotationVariantAtTile(
|
||||
this.root,
|
||||
tile,
|
||||
this.currentBaseRotation
|
||||
);
|
||||
|
||||
// Check if there are connected entities
|
||||
if (connectedEntities) {
|
||||
for (let i = 0; i < connectedEntities.length; ++i) {
|
||||
const connectedEntity = connectedEntities[i];
|
||||
const connectedWsPoint = connectedEntity.components.StaticMapEntity.getTileSpaceBounds()
|
||||
.getCenter()
|
||||
.toWorldSpace();
|
||||
|
||||
const startWsPoint = tile.toWorldSpaceCenterOfTile();
|
||||
|
||||
const startOffset = connectedWsPoint
|
||||
.sub(startWsPoint)
|
||||
.normalize()
|
||||
.multiplyScalar(globalConfig.tileSize * 0.3);
|
||||
const effectiveStartPoint = startWsPoint.add(startOffset);
|
||||
const effectiveEndPoint = connectedWsPoint.sub(startOffset);
|
||||
|
||||
parameters.context.globalAlpha = 0.6;
|
||||
|
||||
// parameters.context.lineCap = "round";
|
||||
parameters.context.strokeStyle = "#7f7";
|
||||
parameters.context.lineWidth = 10;
|
||||
parameters.context.beginPath();
|
||||
parameters.context.moveTo(effectiveStartPoint.x, effectiveStartPoint.y);
|
||||
parameters.context.lineTo(effectiveEndPoint.x, effectiveEndPoint.y);
|
||||
parameters.context.stroke();
|
||||
parameters.context.globalAlpha = 1;
|
||||
// parameters.context.lineCap = "square";
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronize rotation and origin
|
||||
const staticComp = this.fakeEntity.components.StaticMapEntity;
|
||||
staticComp.origin = tile;
|
||||
staticComp.rotationDegrees = rotation;
|
||||
metaBuilding.updateRotationVariant(this.fakeEntity, rotationVariant);
|
||||
|
||||
// Check if we could place the buildnig
|
||||
const canBuild = this.root.logic.checkCanPlaceBuilding(tile, rotation, metaBuilding);
|
||||
|
||||
// Determine the bounds and visualize them
|
||||
const entityBounds = staticComp.getTileSpaceBounds();
|
||||
const drawBorder = 2;
|
||||
parameters.context.globalAlpha = 0.5;
|
||||
if (canBuild) {
|
||||
parameters.context.fillStyle = "rgba(0, 255, 0, 0.2)";
|
||||
} else {
|
||||
parameters.context.fillStyle = "rgba(255, 0, 0, 0.2)";
|
||||
}
|
||||
parameters.context.fillRect(
|
||||
entityBounds.x * globalConfig.tileSize - drawBorder,
|
||||
entityBounds.y * globalConfig.tileSize - drawBorder,
|
||||
entityBounds.w * globalConfig.tileSize + 2 * drawBorder,
|
||||
entityBounds.h * globalConfig.tileSize + 2 * drawBorder
|
||||
);
|
||||
|
||||
// Draw ejectors
|
||||
if (canBuild) {
|
||||
this.drawMatchingAcceptorsAndEjectors(parameters);
|
||||
}
|
||||
|
||||
// HACK to draw the entity sprite
|
||||
const previewSprite = metaBuilding.getPreviewSprite(rotationVariant);
|
||||
parameters.context.globalAlpha = 0.8 + pulseAnimation(this.root.time.realtimeNow(), 1) * 0.1;
|
||||
staticComp.origin = worldPos.divideScalar(globalConfig.tileSize).subScalars(0.5, 0.5);
|
||||
staticComp.drawSpriteOnFullEntityBounds(parameters, previewSprite);
|
||||
staticComp.origin = tile;
|
||||
parameters.context.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawMatchingAcceptorsAndEjectors(parameters) {
|
||||
const acceptorComp = this.fakeEntity.components.ItemAcceptor;
|
||||
const ejectorComp = this.fakeEntity.components.ItemEjector;
|
||||
const staticComp = this.fakeEntity.components.StaticMapEntity;
|
||||
|
||||
const goodArrowSprite = Loader.getSprite("sprites/misc/slot_good_arrow.png");
|
||||
const badArrowSprite = Loader.getSprite("sprites/misc/slot_bad_arrow.png");
|
||||
|
||||
// Just ignore this code ...
|
||||
|
||||
if (acceptorComp) {
|
||||
const slots = acceptorComp.slots;
|
||||
for (let acceptorSlotIndex = 0; acceptorSlotIndex < slots.length; ++acceptorSlotIndex) {
|
||||
const slot = slots[acceptorSlotIndex];
|
||||
const acceptorSlotWsTile = staticComp.localTileToWorld(slot.pos);
|
||||
const acceptorSlotWsPos = acceptorSlotWsTile.toWorldSpaceCenterOfTile();
|
||||
|
||||
for (
|
||||
let acceptorDirectionIndex = 0;
|
||||
acceptorDirectionIndex < slot.directions.length;
|
||||
++acceptorDirectionIndex
|
||||
) {
|
||||
const direction = slot.directions[acceptorDirectionIndex];
|
||||
const worldDirection = staticComp.localDirectionToWorld(direction);
|
||||
|
||||
const sourceTile = acceptorSlotWsTile.add(enumDirectionToVector[worldDirection]);
|
||||
const sourceEntity = this.root.map.getTileContent(sourceTile);
|
||||
|
||||
let sprite = goodArrowSprite;
|
||||
let alpha = 0.5;
|
||||
|
||||
if (sourceEntity) {
|
||||
sprite = badArrowSprite;
|
||||
const sourceEjector = sourceEntity.components.ItemEjector;
|
||||
const sourceStaticComp = sourceEntity.components.StaticMapEntity;
|
||||
const ejectorAcceptLocalTile = sourceStaticComp.worldToLocalTile(acceptorSlotWsTile);
|
||||
if (sourceEjector && sourceEjector.anySlotEjectsToLocalTile(ejectorAcceptLocalTile)) {
|
||||
sprite = goodArrowSprite;
|
||||
}
|
||||
alpha = 1.0;
|
||||
}
|
||||
|
||||
parameters.context.globalAlpha = alpha;
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite,
|
||||
x: acceptorSlotWsPos.x,
|
||||
y: acceptorSlotWsPos.y,
|
||||
angle: Math_radians(enumDirectionToAngle[enumInvertedDirections[worldDirection]]),
|
||||
size: 13,
|
||||
offsetY: 15,
|
||||
});
|
||||
parameters.context.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ejectorComp) {
|
||||
const slots = ejectorComp.slots;
|
||||
for (let ejectorSlotIndex = 0; ejectorSlotIndex < slots.length; ++ejectorSlotIndex) {
|
||||
const slot = ejectorComp.slots[ejectorSlotIndex];
|
||||
|
||||
const ejectorSlotWsTile = staticComp.localTileToWorld(
|
||||
ejectorComp.getSlotTargetLocalTile(ejectorSlotIndex)
|
||||
);
|
||||
const ejectorSLotWsPos = ejectorSlotWsTile.toWorldSpaceCenterOfTile();
|
||||
const ejectorSlotWsDirection = staticComp.localDirectionToWorld(slot.direction);
|
||||
|
||||
const destEntity = this.root.map.getTileContent(ejectorSlotWsTile);
|
||||
|
||||
let sprite = goodArrowSprite;
|
||||
let alpha = 0.5;
|
||||
if (destEntity) {
|
||||
alpha = 1;
|
||||
const destAcceptor = destEntity.components.ItemAcceptor;
|
||||
const destStaticComp = destEntity.components.StaticMapEntity;
|
||||
|
||||
if (destAcceptor) {
|
||||
const destLocalTile = destStaticComp.worldToLocalTile(ejectorSlotWsTile);
|
||||
const destLocalDir = destStaticComp.worldDirectionToLocal(ejectorSlotWsDirection);
|
||||
if (destAcceptor.findMatchingSlot(destLocalTile, destLocalDir)) {
|
||||
sprite = goodArrowSprite;
|
||||
} else {
|
||||
sprite = badArrowSprite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parameters.context.globalAlpha = alpha;
|
||||
drawRotatedSprite({
|
||||
parameters,
|
||||
sprite,
|
||||
x: ejectorSLotWsPos.x,
|
||||
y: ejectorSLotWsPos.y,
|
||||
angle: Math_radians(enumDirectionToAngle[ejectorSlotWsDirection]),
|
||||
size: 13,
|
||||
offsetY: 15,
|
||||
});
|
||||
parameters.context.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/js/game/hud/parts/buildings_toolbar.js
Normal file
128
src/js/game/hud/parts/buildings_toolbar.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
import { gMetaBuildingRegistry } from "../../../core/global_registries";
|
||||
import { MetaBuilding } from "../../meta_building";
|
||||
import { Signal } from "../../../core/signal";
|
||||
import { MetaSplitterBuilding } from "../../buildings/splitter";
|
||||
import { MetaMinerBuilding } from "../../buildings/miner";
|
||||
import { MetaCutterBuilding } from "../../buildings/cutter";
|
||||
import { MetaRotaterBuilding } from "../../buildings/rotater";
|
||||
import { MetaStackerBuilding } from "../../buildings/stacker";
|
||||
import { MetaMixerBuilding } from "../../buildings/mixer";
|
||||
import { MetaPainterBuilding } from "../../buildings/painter";
|
||||
import { MetaTrashBuilding } from "../../buildings/trash";
|
||||
import { MetaBeltBaseBuilding } from "../../buildings/belt_base";
|
||||
import { MetaUndergroundBeltBuilding } from "../../buildings/underground_belt";
|
||||
import { globalConfig } from "../../../core/config";
|
||||
import { TrackedState } from "../../../core/tracked_state";
|
||||
|
||||
const toolbarBuildings = [
|
||||
MetaBeltBaseBuilding,
|
||||
MetaMinerBuilding,
|
||||
MetaUndergroundBeltBuilding,
|
||||
MetaSplitterBuilding,
|
||||
MetaCutterBuilding,
|
||||
MetaRotaterBuilding,
|
||||
MetaStackerBuilding,
|
||||
MetaMixerBuilding,
|
||||
MetaPainterBuilding,
|
||||
MetaTrashBuilding,
|
||||
];
|
||||
|
||||
export class HUDBuildingsToolbar extends BaseHUDPart {
|
||||
constructor(root) {
|
||||
super(root);
|
||||
|
||||
/** @type {Object.<string, { metaBuilding: MetaBuilding, status: boolean, element: HTMLElement}>} */
|
||||
this.buildingUnlockStates = {};
|
||||
|
||||
this.sigBuildingSelected = new Signal();
|
||||
|
||||
this.trackedIsVisisible = new TrackedState(this.onVisibilityChanged, this);
|
||||
}
|
||||
|
||||
onVisibilityChanged(visible) {
|
||||
this.element.classList.toggle("visible", visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should create all require elements
|
||||
* @param {HTMLElement} parent
|
||||
*/
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(parent, "ingame_HUD_buildings_toolbar", [], "");
|
||||
}
|
||||
|
||||
initialize() {
|
||||
const actionMapper = this.root.gameState.keyActionMapper;
|
||||
|
||||
const items = makeDiv(this.element, null, ["buildings"]);
|
||||
const iconSize = 32;
|
||||
|
||||
for (let i = 0; i < toolbarBuildings.length; ++i) {
|
||||
const metaBuilding = gMetaBuildingRegistry.findByClass(toolbarBuildings[i]);
|
||||
const binding = actionMapper.getBinding("building_" + metaBuilding.getId());
|
||||
|
||||
const dimensions = metaBuilding.getDimensions();
|
||||
const itemContainer = makeDiv(items, null, ["building"]);
|
||||
itemContainer.setAttribute("data-tilewidth", dimensions.x);
|
||||
itemContainer.setAttribute("data-tileheight", dimensions.y);
|
||||
|
||||
const label = makeDiv(itemContainer, null, ["label"]);
|
||||
label.innerText = metaBuilding.getName();
|
||||
|
||||
const tooltip = makeDiv(
|
||||
itemContainer,
|
||||
null,
|
||||
["tooltip"],
|
||||
`
|
||||
<span class="title">${metaBuilding.getName()}</span>
|
||||
<span class="desc">${metaBuilding.getDescription()}</span>
|
||||
<span class="tutorialImage" data-icon="building_tutorials/${metaBuilding.getId()}.png"></span>
|
||||
`
|
||||
);
|
||||
|
||||
const sprite = metaBuilding.getPreviewSprite(0);
|
||||
|
||||
const spriteWrapper = makeDiv(itemContainer, null, ["iconWrap"]);
|
||||
spriteWrapper.innerHTML = sprite.getAsHTML(iconSize * dimensions.x, iconSize * dimensions.y);
|
||||
|
||||
binding.appendLabelToElement(itemContainer);
|
||||
binding.add(() => this.selectBuildingForPlacement(metaBuilding));
|
||||
|
||||
this.trackClicks(itemContainer, () => this.selectBuildingForPlacement(metaBuilding), {});
|
||||
|
||||
this.buildingUnlockStates[metaBuilding.id] = {
|
||||
metaBuilding,
|
||||
element: itemContainer,
|
||||
status: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
this.trackedIsVisisible.set(!this.root.camera.getIsMapOverlayActive());
|
||||
|
||||
for (const buildingId in this.buildingUnlockStates) {
|
||||
const handle = this.buildingUnlockStates[buildingId];
|
||||
const newStatus = handle.metaBuilding.getIsUnlocked(this.root);
|
||||
if (handle.status !== newStatus) {
|
||||
handle.status = newStatus;
|
||||
handle.element.classList.toggle("unlocked", newStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {MetaBuilding} metaBuilding
|
||||
*/
|
||||
selectBuildingForPlacement(metaBuilding) {
|
||||
if (!metaBuilding.getIsUnlocked(this.root)) {
|
||||
this.root.soundProxy.playUiError();
|
||||
return;
|
||||
}
|
||||
|
||||
this.sigBuildingSelected.dispatch(metaBuilding);
|
||||
}
|
||||
}
|
||||
37
src/js/game/hud/parts/game_menu.js
Normal file
37
src/js/game/hud/parts/game_menu.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
|
||||
export class HUDGameMenu extends BaseHUDPart {
|
||||
initialize() {}
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(parent, "ingame_HUD_GameMenu");
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
id: "shop",
|
||||
label: "Upgrades",
|
||||
handler: () => this.root.hud.parts.shop.show(),
|
||||
keybinding: "menu_open_shop",
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
label: "Stats",
|
||||
handler: () => null,
|
||||
keybinding: "menu_open_stats",
|
||||
},
|
||||
];
|
||||
|
||||
buttons.forEach(({ id, label, handler, keybinding }) => {
|
||||
const button = document.createElement("button");
|
||||
button.setAttribute("data-button-id", id);
|
||||
this.element.appendChild(button);
|
||||
this.trackClicks(button, handler);
|
||||
|
||||
if (keybinding) {
|
||||
const binding = this.root.gameState.keyActionMapper.getBinding(keybinding);
|
||||
binding.add(handler);
|
||||
binding.appendLabelToElement(button);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
73
src/js/game/hud/parts/keybinding_overlay.js
Normal file
73
src/js/game/hud/parts/keybinding_overlay.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
import { getStringForKeyCode } from "../../key_action_mapper";
|
||||
import { TrackedState } from "../../../core/tracked_state";
|
||||
|
||||
export class HUDKeybindingOverlay extends BaseHUDPart {
|
||||
initialize() {
|
||||
this.shiftDownTracker = new TrackedState(this.onShiftStateChanged, this);
|
||||
}
|
||||
|
||||
onShiftStateChanged(shiftDown) {
|
||||
this.element.classList.toggle("shiftDown", shiftDown);
|
||||
}
|
||||
|
||||
createElements(parent) {
|
||||
const mapper = this.root.gameState.keyActionMapper;
|
||||
|
||||
const getKeycode = id => {
|
||||
return getStringForKeyCode(mapper.getBinding(id).keyCode);
|
||||
};
|
||||
|
||||
this.element = makeDiv(
|
||||
parent,
|
||||
"ingame_HUD_KeybindingOverlay",
|
||||
[],
|
||||
`
|
||||
<div class="binding">
|
||||
<code class="keybinding">${getKeycode("center_map")}</code>
|
||||
<label>Center</label>
|
||||
</div>
|
||||
|
||||
<div class="binding">
|
||||
<code class="keybinding leftMouse"></code><i></i>
|
||||
<code class="keybinding">${getKeycode("map_move_up")}</code>
|
||||
<code class="keybinding">${getKeycode("map_move_left")}</code>
|
||||
<code class="keybinding">${getKeycode("map_move_down")}</code>
|
||||
<code class="keybinding">${getKeycode("map_move_right")}</code>
|
||||
<label>Move</label>
|
||||
</div>
|
||||
|
||||
<div class="binding noPlacementOnly">
|
||||
<code class="keybinding rightMouse"></code>
|
||||
<label>Delete</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="binding placementOnly">
|
||||
<code class="keybinding rightMouse"></code> <i></i>
|
||||
<code class="keybinding">${getKeycode("building_abort_placement")}</code>
|
||||
<label>Stop placement</label>
|
||||
</div>
|
||||
|
||||
<div class="binding placementOnly">
|
||||
<code class="keybinding">${getKeycode("rotate_while_placing")}</code>
|
||||
<label>Rotate Building</label>
|
||||
</div>
|
||||
|
||||
<div class="binding placementOnly shift">
|
||||
<code class="keybinding">SHIFT</code>
|
||||
<label>Place Multiple</label>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
onSelectedBuildingForPlacementChanged(selectedMetaBuilding) {
|
||||
this.element.classList.toggle("placementActive", !!selectedMetaBuilding);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.shiftDownTracker.set(this.root.app.inputMgr.shiftIsDown);
|
||||
}
|
||||
}
|
||||
117
src/js/game/hud/parts/processing_overlay.js
Normal file
117
src/js/game/hud/parts/processing_overlay.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { DynamicDomAttach } from "../dynamic_dom_attach";
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { performanceNow } from "../../../core/builtins";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
import { Signal } from "../../../core/signal";
|
||||
import { InputReceiver } from "../../../core/input_receiver";
|
||||
import { createLogger } from "../../../core/logging";
|
||||
|
||||
const logger = createLogger("hud/processing_overlay");
|
||||
|
||||
export class HUDProcessingOverlay extends BaseHUDPart {
|
||||
constructor(root) {
|
||||
super(root);
|
||||
this.tasks = [];
|
||||
this.computeTimeout = null;
|
||||
|
||||
this.root.signals.performAsync.add(this.queueTask, this);
|
||||
|
||||
this.allTasksFinished = new Signal();
|
||||
this.inputReceiver = new InputReceiver("processing-overlay");
|
||||
|
||||
this.root.signals.aboutToDestruct.add(() =>
|
||||
this.root.app.inputMgr.destroyReceiver(this.inputReceiver)
|
||||
);
|
||||
}
|
||||
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(
|
||||
parent,
|
||||
"rg_HUD_ProcessingOverlay",
|
||||
["hudElement"],
|
||||
`
|
||||
<span class="prefab_LoadingTextWithAnim">
|
||||
Computing
|
||||
</span>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.domWatcher = new DynamicDomAttach(this.root, this.element, {
|
||||
timeToKeepSeconds: 0,
|
||||
});
|
||||
}
|
||||
|
||||
queueTask(task, name) {
|
||||
if (!this.root.gameInitialized) {
|
||||
// Tasks before the game started can be done directlry
|
||||
task();
|
||||
return;
|
||||
}
|
||||
// if (name) {
|
||||
// console.warn("QUEUE", name);
|
||||
// }
|
||||
|
||||
task.__name = name;
|
||||
this.tasks.push(task);
|
||||
}
|
||||
|
||||
hasTasks() {
|
||||
return this.tasks.length > 0;
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return this.computeTimeout !== null;
|
||||
}
|
||||
|
||||
processSync() {
|
||||
const now = performanceNow();
|
||||
while (this.tasks.length > 0) {
|
||||
const workload = this.tasks[0];
|
||||
workload.call();
|
||||
this.tasks.shift();
|
||||
}
|
||||
const duration = performanceNow() - now;
|
||||
if (duration > 100) {
|
||||
logger.log("Tasks done slow (SYNC!) within", (performanceNow() - now).toFixed(2), "ms");
|
||||
}
|
||||
}
|
||||
|
||||
process() {
|
||||
this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReceiver);
|
||||
|
||||
this.domWatcher.update(true);
|
||||
if (this.tasks.length === 0) {
|
||||
logger.warn("No tasks but still called process");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.computeTimeout) {
|
||||
assert(false, "Double compute queued");
|
||||
clearTimeout(this.computeTimeout);
|
||||
}
|
||||
|
||||
this.computeTimeout = setTimeout(() => {
|
||||
const now = performanceNow();
|
||||
while (this.tasks.length > 0) {
|
||||
const workload = this.tasks[0];
|
||||
workload.call();
|
||||
this.tasks.shift();
|
||||
}
|
||||
const duration = performanceNow() - now;
|
||||
if (duration > 100) {
|
||||
logger.log("Tasks done slow within", (performanceNow() - now).toFixed(2), "ms");
|
||||
}
|
||||
|
||||
this.domWatcher.update(false);
|
||||
|
||||
this.root.app.inputMgr.makeSureDetached(this.inputReceiver);
|
||||
|
||||
clearTimeout(this.computeTimeout);
|
||||
this.computeTimeout = null;
|
||||
|
||||
this.allTasksFinished.dispatch();
|
||||
});
|
||||
}
|
||||
}
|
||||
181
src/js/game/hud/parts/shop.js
Normal file
181
src/js/game/hud/parts/shop.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv, removeAllChildren, formatBigNumber } from "../../../core/utils";
|
||||
import { UPGRADES, TIER_LABELS } from "../../upgrades";
|
||||
import { ShapeDefinition } from "../../shape_definition";
|
||||
import { DynamicDomAttach } from "../dynamic_dom_attach";
|
||||
import { InputReceiver } from "../../../core/input_receiver";
|
||||
import { KeyActionMapper } from "../../key_action_mapper";
|
||||
import { Math_min } from "../../../core/builtins";
|
||||
|
||||
export class HUDShop extends BaseHUDPart {
|
||||
createElements(parent) {
|
||||
this.background = makeDiv(parent, "ingame_HUD_Shop", ["ingameDialog"]);
|
||||
|
||||
// DIALOG Inner / Wrapper
|
||||
this.dialogInner = makeDiv(this.background, null, ["dialogInner"]);
|
||||
this.title = makeDiv(this.dialogInner, null, ["title"], `Upgrades`);
|
||||
this.closeButton = makeDiv(this.title, null, ["closeButton"]);
|
||||
this.trackClicks(this.closeButton, this.close);
|
||||
this.contentDiv = makeDiv(this.dialogInner, null, ["content"]);
|
||||
|
||||
this.upgradeToElements = {};
|
||||
|
||||
// Upgrades
|
||||
for (const upgradeId in UPGRADES) {
|
||||
const { label } = UPGRADES[upgradeId];
|
||||
const handle = {};
|
||||
handle.requireIndexToElement = [];
|
||||
|
||||
// Wrapper
|
||||
handle.elem = makeDiv(this.contentDiv, null, ["upgrade"]);
|
||||
handle.elem.setAttribute("data-upgrade-id", upgradeId);
|
||||
|
||||
// Title
|
||||
const title = makeDiv(handle.elem, null, ["title"], label);
|
||||
|
||||
// Title > Tier
|
||||
handle.elemTierLabel = makeDiv(title, null, ["tier"], "Tier ?");
|
||||
|
||||
// Icon
|
||||
handle.icon = makeDiv(handle.elem, null, ["icon"]);
|
||||
handle.icon.setAttribute("data-icon", "upgrades/" + upgradeId + ".png");
|
||||
|
||||
// Description
|
||||
handle.elemDescription = makeDiv(handle.elem, null, ["description"], "??");
|
||||
handle.elemRequirements = makeDiv(handle.elem, null, ["requirements"]);
|
||||
|
||||
// Buy button
|
||||
handle.buyButton = document.createElement("button");
|
||||
handle.buyButton.classList.add("buy", "styledButton");
|
||||
handle.buyButton.innerText = "Upgrade";
|
||||
handle.elem.appendChild(handle.buyButton);
|
||||
|
||||
this.trackClicks(handle.buyButton, () => this.tryUnlockNextTier(upgradeId));
|
||||
|
||||
// Assign handle
|
||||
this.upgradeToElements[upgradeId] = handle;
|
||||
}
|
||||
}
|
||||
|
||||
rerenderFull() {
|
||||
for (const upgradeId in this.upgradeToElements) {
|
||||
const handle = this.upgradeToElements[upgradeId];
|
||||
const { description, tiers } = UPGRADES[upgradeId];
|
||||
// removeAllChildren(handle.elem);
|
||||
|
||||
const currentTier = this.root.hubGoals.getUpgradeLevel(upgradeId);
|
||||
const tierHandle = tiers[currentTier];
|
||||
|
||||
// Set tier
|
||||
handle.elemTierLabel.innerText = "Tier " + TIER_LABELS[currentTier];
|
||||
handle.elemTierLabel.setAttribute("data-tier", currentTier);
|
||||
|
||||
// Cleanup
|
||||
handle.requireIndexToElement = [];
|
||||
removeAllChildren(handle.elemRequirements);
|
||||
|
||||
handle.elem.classList.toggle("maxLevel", !tierHandle);
|
||||
|
||||
if (!tierHandle) {
|
||||
// Max level
|
||||
handle.elemDescription.innerText = "Maximum level";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set description
|
||||
handle.elemDescription.innerText = description(tierHandle.improvement);
|
||||
|
||||
tierHandle.required.forEach(({ shape, amount }) => {
|
||||
const requireDiv = makeDiv(handle.elemRequirements, null, ["requirement"]);
|
||||
|
||||
const shapeDef = this.root.shapeDefinitionMgr.registerOrReturnHandle(
|
||||
ShapeDefinition.fromShortKey(shape)
|
||||
);
|
||||
const shapeCanvas = shapeDef.generateAsCanvas(120);
|
||||
shapeCanvas.classList.add();
|
||||
requireDiv.appendChild(shapeCanvas);
|
||||
|
||||
const progressContainer = makeDiv(requireDiv, null, ["amount"]);
|
||||
const progressBar = document.createElement("label");
|
||||
progressBar.classList.add("progressBar");
|
||||
progressContainer.appendChild(progressBar);
|
||||
|
||||
const progressLabel = document.createElement("label");
|
||||
progressContainer.appendChild(progressLabel);
|
||||
|
||||
handle.requireIndexToElement.push({
|
||||
progressLabel,
|
||||
progressBar,
|
||||
definition: shapeDef,
|
||||
required: amount,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderCountsAndStatus() {
|
||||
for (const upgradeId in this.upgradeToElements) {
|
||||
const handle = this.upgradeToElements[upgradeId];
|
||||
for (let i = 0; i < handle.requireIndexToElement.length; ++i) {
|
||||
const { progressLabel, progressBar, definition, required } = handle.requireIndexToElement[i];
|
||||
|
||||
const haveAmount = this.root.hubGoals.getShapesStored(definition);
|
||||
const progress = Math_min(haveAmount / required, 1.0);
|
||||
|
||||
progressLabel.innerText = formatBigNumber(haveAmount) + " / " + formatBigNumber(required);
|
||||
progressBar.style.width = progress * 100.0 + "%";
|
||||
progressBar.classList.toggle("complete", progress >= 1.0);
|
||||
}
|
||||
|
||||
handle.buyButton.classList.toggle("buyable", this.root.hubGoals.canUnlockUpgrade(upgradeId));
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.domAttach = new DynamicDomAttach(this.root, this.background, {
|
||||
attachClass: "visible",
|
||||
});
|
||||
|
||||
this.inputReciever = new InputReceiver("shop");
|
||||
this.keyActionMapper = new KeyActionMapper(this.root, this.inputReciever);
|
||||
|
||||
this.keyActionMapper.getBinding("back").add(this.close, this);
|
||||
this.keyActionMapper.getBinding("menu_open_shop").add(this.close, this);
|
||||
|
||||
this.close();
|
||||
|
||||
this.rerenderFull();
|
||||
this.root.signals.upgradePurchased.add(this.rerenderFull, this);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
document.body.classList.remove("ingameDialogOpen");
|
||||
}
|
||||
|
||||
show() {
|
||||
this.visible = true;
|
||||
document.body.classList.add("ingameDialogOpen");
|
||||
// this.background.classList.add("visible");
|
||||
this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever);
|
||||
this.update();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false;
|
||||
document.body.classList.remove("ingameDialogOpen");
|
||||
this.root.app.inputMgr.makeSureDetached(this.inputReciever);
|
||||
this.update();
|
||||
}
|
||||
|
||||
update() {
|
||||
this.domAttach.update(this.visible);
|
||||
if (this.visible) {
|
||||
this.renderCountsAndStatus();
|
||||
}
|
||||
}
|
||||
|
||||
tryUnlockNextTier(upgradeId) {
|
||||
// Nothing
|
||||
this.root.hubGoals.tryUnlockUgprade(upgradeId);
|
||||
}
|
||||
}
|
||||
122
src/js/game/hud/parts/unlock_notification.js
Normal file
122
src/js/game/hud/parts/unlock_notification.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { BaseHUDPart } from "../base_hud_part";
|
||||
import { makeDiv } from "../../../core/utils";
|
||||
import { DynamicDomAttach } from "../dynamic_dom_attach";
|
||||
import { gMetaBuildingRegistry } from "../../../core/global_registries";
|
||||
import { MetaBuilding } from "../../meta_building";
|
||||
import { MetaSplitterBuilding } from "../../buildings/splitter";
|
||||
import { MetaCutterBuilding } from "../../buildings/cutter";
|
||||
import { enumHubGoalRewards } from "../../tutorial_goals";
|
||||
import { MetaTrashBuilding } from "../../buildings/trash";
|
||||
import { MetaMinerBuilding } from "../../buildings/miner";
|
||||
import { MetaPainterBuilding } from "../../buildings/painter";
|
||||
import { MetaMixerBuilding } from "../../buildings/mixer";
|
||||
import { MetaRotaterBuilding } from "../../buildings/rotater";
|
||||
import { MetaStackerBuilding } from "../../buildings/stacker";
|
||||
import { MetaUndergroundBeltBuilding } from "../../buildings/underground_belt";
|
||||
import { globalConfig } from "../../../core/config";
|
||||
|
||||
export class HUDUnlockNotification extends BaseHUDPart {
|
||||
initialize() {
|
||||
this.visible = false;
|
||||
|
||||
this.domAttach = new DynamicDomAttach(this.root, this.element, {
|
||||
timeToKeepSeconds: 0,
|
||||
});
|
||||
|
||||
if (!(G_IS_DEV && globalConfig.debug.disableUnlockDialog)) {
|
||||
this.root.signals.storyGoalCompleted.add(this.showForLevel, this);
|
||||
}
|
||||
}
|
||||
|
||||
shouldPauseGame() {
|
||||
return this.visible;
|
||||
}
|
||||
|
||||
createElements(parent) {
|
||||
this.element = makeDiv(parent, "ingame_HUD_UnlockNotification", []);
|
||||
|
||||
const dialog = makeDiv(this.element, null, ["dialog"]);
|
||||
|
||||
this.elemTitle = makeDiv(dialog, null, ["title"], ``);
|
||||
this.elemSubTitle = makeDiv(dialog, null, ["subTitle"], `Completed`);
|
||||
|
||||
this.elemContents = makeDiv(
|
||||
dialog,
|
||||
null,
|
||||
["contents"],
|
||||
`
|
||||
Ready for the next one?
|
||||
`
|
||||
);
|
||||
|
||||
this.btnClose = document.createElement("button");
|
||||
this.btnClose.classList.add("close", "styledButton");
|
||||
this.btnClose.innerText = "Next level";
|
||||
dialog.appendChild(this.btnClose);
|
||||
|
||||
this.trackClicks(this.btnClose, this.close);
|
||||
}
|
||||
|
||||
showForLevel(level, reward) {
|
||||
this.elemTitle.innerText = "Level " + ("" + level).padStart(2, "0");
|
||||
|
||||
let html = `<span class='reward'>Unlocked ${reward}!</span>`;
|
||||
|
||||
const addBuildingExplanation = metaBuildingClass => {
|
||||
const metaBuilding = gMetaBuildingRegistry.findByClass(metaBuildingClass);
|
||||
html += `<div class="buildingExplanation" data-icon="building_tutorials/${metaBuilding.getId()}.png"></div>`;
|
||||
};
|
||||
|
||||
switch (reward) {
|
||||
case enumHubGoalRewards.reward_cutter_and_trash: {
|
||||
addBuildingExplanation(MetaCutterBuilding);
|
||||
addBuildingExplanation(MetaTrashBuilding);
|
||||
break;
|
||||
}
|
||||
case enumHubGoalRewards.reward_mixer: {
|
||||
addBuildingExplanation(MetaMixerBuilding);
|
||||
break;
|
||||
}
|
||||
|
||||
case enumHubGoalRewards.reward_painter: {
|
||||
addBuildingExplanation(MetaPainterBuilding);
|
||||
break;
|
||||
}
|
||||
|
||||
case enumHubGoalRewards.reward_rotater: {
|
||||
addBuildingExplanation(MetaRotaterBuilding);
|
||||
break;
|
||||
}
|
||||
|
||||
case enumHubGoalRewards.reward_splitter: {
|
||||
addBuildingExplanation(MetaSplitterBuilding);
|
||||
break;
|
||||
}
|
||||
|
||||
case enumHubGoalRewards.reward_stacker: {
|
||||
addBuildingExplanation(MetaStackerBuilding);
|
||||
break;
|
||||
}
|
||||
|
||||
case enumHubGoalRewards.reward_tunnel: {
|
||||
addBuildingExplanation(MetaUndergroundBeltBuilding);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// addBuildingExplanation(MetaSplitterBuilding);
|
||||
// addBuildingExplanation(MetaCutterBuilding);
|
||||
|
||||
this.elemContents.innerHTML = html;
|
||||
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.domAttach.update(this.visible);
|
||||
}
|
||||
}
|
||||
6
src/js/game/item_registry.js
Normal file
6
src/js/game/item_registry.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { gItemRegistry } from "../core/global_registries";
|
||||
import { ShapeItem } from "./items/shape_item";
|
||||
|
||||
export function initItemRegistry() {
|
||||
gItemRegistry.register(ShapeItem);
|
||||
}
|
||||
90
src/js/game/items/color_item.js
Normal file
90
src/js/game/items/color_item.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { createLogger } from "../../core/logging";
|
||||
import { extendSchema } from "../../savegame/serialization";
|
||||
import { BaseItem } from "../base_item";
|
||||
import { enumColorsToHexCode, enumColors } from "../colors";
|
||||
import { makeOffscreenBuffer } from "../../core/buffer_utils";
|
||||
import { globalConfig } from "../../core/config";
|
||||
import { round1Digit } from "../../core/utils";
|
||||
import { Math_max, Math_round } from "../../core/builtins";
|
||||
import { smoothenDpi } from "../../core/dpi_manager";
|
||||
|
||||
/** @enum {string} */
|
||||
const enumColorToMapBackground = {
|
||||
[enumColors.red]: "#ffbfc1",
|
||||
[enumColors.green]: "#cbffc4",
|
||||
[enumColors.blue]: "#bfdaff",
|
||||
};
|
||||
|
||||
export class ColorItem extends BaseItem {
|
||||
static getId() {
|
||||
return "color";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return extendSchema(BaseItem.getCachedSchema(), {
|
||||
// TODO
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} color
|
||||
*/
|
||||
constructor(color) {
|
||||
super();
|
||||
this.color = color;
|
||||
|
||||
this.bufferGenerator = this.internalGenerateColorBuffer.bind(this);
|
||||
}
|
||||
|
||||
getBackgroundColorAsResource() {
|
||||
return enumColorToMapBackground[this.color];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} size
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
draw(x, y, parameters, size = 12) {
|
||||
const dpi = smoothenDpi(globalConfig.shapesSharpness * parameters.zoomLevel);
|
||||
|
||||
const key = size + "/" + dpi;
|
||||
const canvas = parameters.root.buffers.getForKey(
|
||||
key,
|
||||
this.color,
|
||||
size,
|
||||
size,
|
||||
dpi,
|
||||
this.bufferGenerator
|
||||
);
|
||||
parameters.context.drawImage(canvas, x - size / 2, y - size / 2, size, size);
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {number} dpi
|
||||
*/
|
||||
internalGenerateColorBuffer(canvas, context, w, h, dpi) {
|
||||
context.translate((w * dpi) / 2, (h * dpi) / 2);
|
||||
context.scale((dpi * w) / 12, (dpi * h) / 12);
|
||||
|
||||
context.fillStyle = enumColorsToHexCode[this.color];
|
||||
context.strokeStyle = "rgba(100,102, 110, 1)";
|
||||
context.lineWidth = 2;
|
||||
context.beginCircle(2, -1, 3);
|
||||
context.stroke();
|
||||
context.fill();
|
||||
context.beginCircle(-2, -1, 3);
|
||||
context.stroke();
|
||||
context.fill();
|
||||
context.beginCircle(0, 2, 3);
|
||||
context.closePath();
|
||||
context.stroke();
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
42
src/js/game/items/shape_item.js
Normal file
42
src/js/game/items/shape_item.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BaseItem } from "../base_item";
|
||||
import { DrawParameters } from "../../core/draw_parameters";
|
||||
import { extendSchema } from "../../savegame/serialization";
|
||||
import { ShapeDefinition } from "../shape_definition";
|
||||
import { createLogger } from "../../core/logging";
|
||||
|
||||
const logger = createLogger("shape_item");
|
||||
|
||||
export class ShapeItem extends BaseItem {
|
||||
static getId() {
|
||||
return "shape";
|
||||
}
|
||||
|
||||
static getSchema() {
|
||||
return extendSchema(BaseItem.getCachedSchema(), {
|
||||
// TODO
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ShapeDefinition} definition
|
||||
*/
|
||||
constructor(definition) {
|
||||
super();
|
||||
// logger.log("New shape item for shape definition", definition.generateId(), "created");
|
||||
|
||||
/**
|
||||
* This property must not be modified on runtime, you have to clone the class in order to change the definition
|
||||
*/
|
||||
this.definition = definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {DrawParameters} parameters
|
||||
* @param {number=} size
|
||||
*/
|
||||
draw(x, y, parameters, size) {
|
||||
this.definition.draw(x, y, parameters, size);
|
||||
}
|
||||
}
|
||||
383
src/js/game/key_action_mapper.js
Normal file
383
src/js/game/key_action_mapper.js
Normal file
@@ -0,0 +1,383 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
import { InputReceiver } from "../core/input_receiver";
|
||||
import { Application } from "../application";
|
||||
/* typehints:end */
|
||||
|
||||
import { Signal, STOP_PROPAGATION } from "../core/signal";
|
||||
import { IS_MOBILE } from "../core/config";
|
||||
|
||||
function key(str) {
|
||||
return str.toUpperCase().charCodeAt(0);
|
||||
}
|
||||
|
||||
// TODO: Configurable
|
||||
export const defaultKeybindings = {
|
||||
general: {
|
||||
confirm: { keyCode: 13 }, // enter
|
||||
back: { keyCode: 27, builtin: true }, // escape
|
||||
},
|
||||
|
||||
ingame: {
|
||||
map_move_up: { keyCode: key("W") },
|
||||
map_move_right: { keyCode: key("D") },
|
||||
map_move_down: { keyCode: key("S") },
|
||||
map_move_left: { keyCode: key("A") },
|
||||
toggle_hud: { keyCode: 113 },
|
||||
|
||||
center_map: { keyCode: 32 },
|
||||
|
||||
menu_open_shop: { keyCode: key("F") },
|
||||
menu_open_stats: { keyCode: key("G") },
|
||||
},
|
||||
|
||||
toolbar: {
|
||||
building_belt: { keyCode: key("1") },
|
||||
building_miner: { keyCode: key("2") },
|
||||
building_underground_belt: { keyCode: key("3") },
|
||||
building_splitter: { keyCode: key("4") },
|
||||
building_cutter: { keyCode: key("5") },
|
||||
building_rotater: { keyCode: key("6") },
|
||||
building_stacker: { keyCode: key("7") },
|
||||
building_mixer: { keyCode: key("8") },
|
||||
building_painter: { keyCode: key("9") },
|
||||
building_trash: { keyCode: key("0") },
|
||||
|
||||
building_abort_placement: { keyCode: key("Q") },
|
||||
|
||||
rotate_while_placing: { keyCode: key("R") },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a keycode -> string
|
||||
* @param {number} code
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getStringForKeyCode(code) {
|
||||
switch (code) {
|
||||
case 8:
|
||||
return "⌫";
|
||||
case 9:
|
||||
return "TAB";
|
||||
case 13:
|
||||
return "⏎";
|
||||
case 16:
|
||||
return "⇪";
|
||||
case 17:
|
||||
return "CTRL";
|
||||
case 18:
|
||||
return "ALT";
|
||||
case 19:
|
||||
return "PAUSE";
|
||||
case 20:
|
||||
return "CAPS";
|
||||
case 27:
|
||||
return "ESC";
|
||||
case 32:
|
||||
return "SPACE";
|
||||
case 33:
|
||||
return "PGUP";
|
||||
case 34:
|
||||
return "PGDOWN";
|
||||
case 35:
|
||||
return "END";
|
||||
case 36:
|
||||
return "HOME";
|
||||
case 37:
|
||||
return "⬅";
|
||||
case 38:
|
||||
return "⬆";
|
||||
case 39:
|
||||
return "➡";
|
||||
case 40:
|
||||
return "⬇";
|
||||
case 44:
|
||||
return "PRNT";
|
||||
case 45:
|
||||
return "INS";
|
||||
case 46:
|
||||
return "DEL";
|
||||
case 93:
|
||||
return "SEL";
|
||||
case 96:
|
||||
return "NUM 0";
|
||||
case 97:
|
||||
return "NUM 1";
|
||||
case 98:
|
||||
return "NUM 2";
|
||||
case 99:
|
||||
return "NUM 3";
|
||||
case 100:
|
||||
return "NUM 4";
|
||||
case 101:
|
||||
return "NUM 5";
|
||||
case 102:
|
||||
return "NUM 6";
|
||||
case 103:
|
||||
return "NUM 7";
|
||||
case 104:
|
||||
return "NUM 8";
|
||||
case 105:
|
||||
return "NUM 9";
|
||||
case 106:
|
||||
return "*";
|
||||
case 107:
|
||||
return "+";
|
||||
case 109:
|
||||
return "-";
|
||||
case 110:
|
||||
return ".";
|
||||
case 111:
|
||||
return "/";
|
||||
case 112:
|
||||
return "F1";
|
||||
case 113:
|
||||
return "F2";
|
||||
case 114:
|
||||
return "F3";
|
||||
case 115:
|
||||
return "F4";
|
||||
case 116:
|
||||
return "F4";
|
||||
case 117:
|
||||
return "F5";
|
||||
case 118:
|
||||
return "F6";
|
||||
case 119:
|
||||
return "F7";
|
||||
case 120:
|
||||
return "F8";
|
||||
case 121:
|
||||
return "F9";
|
||||
case 122:
|
||||
return "F10";
|
||||
case 123:
|
||||
return "F11";
|
||||
case 124:
|
||||
return "F12";
|
||||
|
||||
case 144:
|
||||
return "NUMLOCK";
|
||||
case 145:
|
||||
return "SCRLOCK";
|
||||
case 182:
|
||||
return "COMP";
|
||||
case 183:
|
||||
return "CALC";
|
||||
case 186:
|
||||
return ";";
|
||||
case 187:
|
||||
return "=";
|
||||
case 188:
|
||||
return ",";
|
||||
case 189:
|
||||
return "-";
|
||||
case 189:
|
||||
return ".";
|
||||
case 191:
|
||||
return "/";
|
||||
case 219:
|
||||
return "[";
|
||||
case 220:
|
||||
return "\\";
|
||||
case 221:
|
||||
return "]";
|
||||
case 222:
|
||||
return "'";
|
||||
}
|
||||
|
||||
// TODO
|
||||
return String.fromCharCode(code);
|
||||
}
|
||||
|
||||
export class Keybinding {
|
||||
/**
|
||||
*
|
||||
* @param {Application} app
|
||||
* @param {object} param0
|
||||
* @param {number} param0.keyCode
|
||||
* @param {boolean=} param0.builtin
|
||||
*/
|
||||
constructor(app, { keyCode, builtin = false }) {
|
||||
assert(keyCode && Number.isInteger(keyCode), "Invalid key code: " + keyCode);
|
||||
this.app = app;
|
||||
this.keyCode = keyCode;
|
||||
this.builtin = builtin;
|
||||
|
||||
this.currentlyDown = false;
|
||||
|
||||
this.signal = new Signal();
|
||||
this.toggled = new Signal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener
|
||||
* @param {function() : void} receiver
|
||||
* @param {object=} scope
|
||||
*/
|
||||
add(receiver, scope = null) {
|
||||
this.signal.add(receiver, scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} elem
|
||||
* @returns {HTMLElement} the created element, or null if the keybindings are not shown
|
||||
* */
|
||||
appendLabelToElement(elem) {
|
||||
if (IS_MOBILE) {
|
||||
return null;
|
||||
}
|
||||
const spacer = document.createElement("code");
|
||||
spacer.classList.add("keybinding");
|
||||
spacer.innerHTML = getStringForKeyCode(this.keyCode);
|
||||
elem.appendChild(spacer);
|
||||
return spacer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the key code as a nice string
|
||||
*/
|
||||
getKeyCodeString() {
|
||||
return getStringForKeyCode(this.keyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remvoes all signal receivers
|
||||
*/
|
||||
clearSignalReceivers() {
|
||||
this.signal.removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyActionMapper {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
* @param {InputReceiver} inputReciever
|
||||
*/
|
||||
constructor(root, inputReciever) {
|
||||
this.root = root;
|
||||
inputReciever.keydown.add(this.handleKeydown, this);
|
||||
inputReciever.keyup.add(this.handleKeyup, this);
|
||||
|
||||
/** @type {Object.<string, Keybinding>} */
|
||||
this.keybindings = {};
|
||||
|
||||
// const overrides = root.app.settings.getKeybindingOverrides();
|
||||
|
||||
for (const category in defaultKeybindings) {
|
||||
for (const key in defaultKeybindings[category]) {
|
||||
let payload = Object.assign({}, defaultKeybindings[category][key]);
|
||||
// if (overrides[key]) {
|
||||
// payload.keyCode = overrides[key];
|
||||
// }
|
||||
|
||||
this.keybindings[key] = new Keybinding(this.root.app, payload);
|
||||
}
|
||||
}
|
||||
|
||||
inputReciever.pageBlur.add(this.onPageBlur, this);
|
||||
inputReciever.destroyed.add(this.cleanup, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all keybindings starting with the given id
|
||||
* @param {string} pattern
|
||||
* @returns {Array<Keybinding>}
|
||||
*/
|
||||
getKeybindingsStartingWith(pattern) {
|
||||
let result = [];
|
||||
for (const key in this.keybindings) {
|
||||
if (key.startsWith(pattern)) {
|
||||
result.push(this.keybindings[key]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards the given events to the other mapper (used in tooltips)
|
||||
* @param {KeyActionMapper} receiver
|
||||
* @param {Array<string>} bindings
|
||||
*/
|
||||
forward(receiver, bindings) {
|
||||
for (let i = 0; i < bindings.length; ++i) {
|
||||
const key = bindings[i];
|
||||
this.keybindings[key].signal.add((...args) => receiver.keybindings[key].signal.dispatch(...args));
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const key in this.keybindings) {
|
||||
this.keybindings[key].signal.removeAll();
|
||||
}
|
||||
}
|
||||
|
||||
onPageBlur() {
|
||||
// Reset all down states
|
||||
// Find mapping
|
||||
for (const key in this.keybindings) {
|
||||
/** @type {Keybinding} */
|
||||
const binding = this.keybindings[key];
|
||||
binding.currentlyDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal keydown handler
|
||||
* @param {object} param0
|
||||
* @param {number} param0.keyCode
|
||||
* @param {boolean} param0.shift
|
||||
* @param {boolean} param0.alt
|
||||
*/
|
||||
handleKeydown({ keyCode, shift, alt }) {
|
||||
let stop = false;
|
||||
|
||||
// Find mapping
|
||||
for (const key in this.keybindings) {
|
||||
/** @type {Keybinding} */
|
||||
const binding = this.keybindings[key];
|
||||
if (binding.keyCode === keyCode /* && binding.shift === shift && binding.alt === alt */) {
|
||||
binding.currentlyDown = true;
|
||||
|
||||
/** @type {Signal} */
|
||||
const signal = this.keybindings[key].signal;
|
||||
if (signal.dispatch() === STOP_PROPAGATION) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stop) {
|
||||
return STOP_PROPAGATION;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal keyup handler
|
||||
* @param {object} param0
|
||||
* @param {number} param0.keyCode
|
||||
* @param {boolean} param0.shift
|
||||
* @param {boolean} param0.alt
|
||||
*/
|
||||
handleKeyup({ keyCode, shift, alt }) {
|
||||
for (const key in this.keybindings) {
|
||||
/** @type {Keybinding} */
|
||||
const binding = this.keybindings[key];
|
||||
if (binding.keyCode === keyCode) {
|
||||
binding.currentlyDown = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a given keybinding
|
||||
* @param {string} id
|
||||
* @returns {Keybinding}
|
||||
*/
|
||||
getBinding(id) {
|
||||
assert(this.keybindings[id], "Keybinding " + id + " not known!");
|
||||
return this.keybindings[id];
|
||||
}
|
||||
}
|
||||
209
src/js/game/logic.js
Normal file
209
src/js/game/logic.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { GameRoot } from "./root";
|
||||
import { Entity } from "./entity";
|
||||
import { Vector, enumDirectionToVector, enumDirection } from "../core/vector";
|
||||
import { MetaBuilding } from "./meta_building";
|
||||
import { StaticMapEntityComponent } from "./components/static_map_entity";
|
||||
import { Math_abs } from "../core/builtins";
|
||||
import { Rectangle } from "../core/rectangle";
|
||||
import { createLogger } from "../core/logging";
|
||||
|
||||
const logger = createLogger("ingame/logic");
|
||||
|
||||
/**
|
||||
* Typing helper
|
||||
* @typedef {Array<{
|
||||
* entity: Entity,
|
||||
* slot: import("./components/item_ejector").ItemEjectorSlot,
|
||||
* fromTile: Vector,
|
||||
* toDirection: enumDirection
|
||||
* }>} EjectorsAffectingTile
|
||||
*/
|
||||
|
||||
/**
|
||||
* Typing helper
|
||||
* @typedef {Array<{
|
||||
* entity: Entity,
|
||||
* slot: import("./components/item_acceptor").ItemAcceptorSlot,
|
||||
* toTile: Vector,
|
||||
* fromDirection: enumDirection
|
||||
* }>} AcceptorsAffectingTile
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* acceptors: AcceptorsAffectingTile,
|
||||
* ejectors: EjectorsAffectingTile
|
||||
* }} AcceptorsAndEjectorsAffectingTile
|
||||
*/
|
||||
|
||||
export class GameLogic {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Vector} origin
|
||||
* @param {number} rotation
|
||||
* @param {MetaBuilding} building
|
||||
*/
|
||||
isAreaFreeToBuild(origin, rotation, building) {
|
||||
const checker = new StaticMapEntityComponent({
|
||||
origin,
|
||||
tileSize: building.getDimensions(),
|
||||
rotationDegrees: rotation,
|
||||
});
|
||||
|
||||
const rect = checker.getTileSpaceBounds();
|
||||
|
||||
for (let x = rect.x; x < rect.x + rect.w; ++x) {
|
||||
for (let y = rect.y; y < rect.y + rect.h; ++y) {
|
||||
const contents = this.root.map.getTileContentXY(x, y);
|
||||
if (contents && !contents.components.ReplaceableMapEntity) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Vector} origin
|
||||
* @param {number} rotation
|
||||
* @param {MetaBuilding} building
|
||||
*/
|
||||
checkCanPlaceBuilding(origin, rotation, building) {
|
||||
if (!building.getIsUnlocked(this.root)) {
|
||||
return false;
|
||||
}
|
||||
return this.isAreaFreeToBuild(origin, rotation, building);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} param0
|
||||
* @param {Vector} param0.origin
|
||||
* @param {number} param0.rotation
|
||||
* @param {number} param0.rotationVariant
|
||||
* @param {MetaBuilding} param0.building
|
||||
*/
|
||||
tryPlaceBuilding({ origin, rotation, rotationVariant, building }) {
|
||||
if (this.checkCanPlaceBuilding(origin, rotation, building)) {
|
||||
// Remove any removeable entities below
|
||||
const checker = new StaticMapEntityComponent({
|
||||
origin,
|
||||
tileSize: building.getDimensions(),
|
||||
rotationDegrees: rotation,
|
||||
});
|
||||
|
||||
const rect = checker.getTileSpaceBounds();
|
||||
|
||||
for (let x = rect.x; x < rect.x + rect.w; ++x) {
|
||||
for (let y = rect.y; y < rect.y + rect.h; ++y) {
|
||||
const contents = this.root.map.getTileContentXY(x, y);
|
||||
if (contents && contents.components.ReplaceableMapEntity) {
|
||||
if (!this.tryDeleteBuilding(contents)) {
|
||||
logger.error("Building has replaceable component but is also unremovable");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
building.createAndPlaceEntity(this.root, origin, rotation, rotationVariant);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given building can get removed
|
||||
* @param {Entity} building
|
||||
*/
|
||||
canDeleteBuilding(building) {
|
||||
return building.components.StaticMapEntity && !building.components.Unremovable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to delete the given building
|
||||
* @param {Entity} building
|
||||
*/
|
||||
tryDeleteBuilding(building) {
|
||||
if (!this.canDeleteBuilding(building)) {
|
||||
return false;
|
||||
}
|
||||
this.root.map.removeStaticEntity(building);
|
||||
this.root.entityMgr.destroyEntity(building);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the acceptors and ejectors which affect the current tile
|
||||
* @param {Vector} tile
|
||||
* @returns {AcceptorsAndEjectorsAffectingTile}
|
||||
*/
|
||||
getEjectorsAndAcceptorsAtTile(tile) {
|
||||
/** @type {EjectorsAffectingTile} */
|
||||
let ejectors = [];
|
||||
/** @type {AcceptorsAffectingTile} */
|
||||
let acceptors = [];
|
||||
|
||||
for (let dx = -1; dx <= 1; ++dx) {
|
||||
for (let dy = -1; dy <= 1; ++dy) {
|
||||
if (Math_abs(dx) + Math_abs(dy) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entity = this.root.map.getTileContentXY(tile.x + dx, tile.y + dy);
|
||||
if (entity) {
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const itemEjector = entity.components.ItemEjector;
|
||||
if (itemEjector) {
|
||||
for (let ejectorSlot = 0; ejectorSlot < itemEjector.slots.length; ++ejectorSlot) {
|
||||
const slot = itemEjector.slots[ejectorSlot];
|
||||
const wsTile = staticComp.localTileToWorld(slot.pos);
|
||||
const wsDirection = staticComp.localDirectionToWorld(slot.direction);
|
||||
const targetTile = wsTile.add(enumDirectionToVector[wsDirection]);
|
||||
if (targetTile.equals(tile)) {
|
||||
ejectors.push({
|
||||
entity,
|
||||
slot,
|
||||
fromTile: wsTile,
|
||||
toDirection: wsDirection,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const itemAcceptor = entity.components.ItemAcceptor;
|
||||
if (itemAcceptor) {
|
||||
for (let acceptorSlot = 0; acceptorSlot < itemAcceptor.slots.length; ++acceptorSlot) {
|
||||
const slot = itemAcceptor.slots[acceptorSlot];
|
||||
const wsTile = staticComp.localTileToWorld(slot.pos);
|
||||
for (let k = 0; k < slot.directions.length; ++k) {
|
||||
const direction = slot.directions[k];
|
||||
const wsDirection = staticComp.localDirectionToWorld(direction);
|
||||
|
||||
const sourceTile = wsTile.add(enumDirectionToVector[wsDirection]);
|
||||
if (sourceTile.equals(tile)) {
|
||||
acceptors.push({
|
||||
entity,
|
||||
slot,
|
||||
toTile: wsTile,
|
||||
fromDirection: wsDirection,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ejectors, acceptors };
|
||||
}
|
||||
}
|
||||
207
src/js/game/map.js
Normal file
207
src/js/game/map.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
/* typehints:end */
|
||||
|
||||
import { globalConfig } from "../core/config";
|
||||
import { Vector } from "../core/vector";
|
||||
import { Entity } from "./entity";
|
||||
import { Math_floor } from "../core/builtins";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { BaseItem } from "./base_item";
|
||||
import { MapChunkView } from "./map_chunk_view";
|
||||
|
||||
const logger = createLogger("map");
|
||||
|
||||
export class BaseMap {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
|
||||
/**
|
||||
* Mapping of 'X|Y' to chunk
|
||||
* @type {Map<string, MapChunkView>} */
|
||||
this.chunksById = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the given chunk by index
|
||||
* @param {number} chunkX
|
||||
* @param {number} chunkY
|
||||
*/
|
||||
getChunk(chunkX, chunkY, createIfNotExistent = false) {
|
||||
// TODO: Better generation
|
||||
const chunkIdentifier = chunkX + "|" + chunkY;
|
||||
let storedChunk;
|
||||
|
||||
if ((storedChunk = this.chunksById.get(chunkIdentifier))) {
|
||||
return storedChunk;
|
||||
}
|
||||
|
||||
if (createIfNotExistent) {
|
||||
const instance = new MapChunkView(this.root, chunkX, chunkY);
|
||||
this.chunksById.set(chunkIdentifier, instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a new chunk if not existent for the given tile
|
||||
* @param {number} tileX
|
||||
* @param {number} tileY
|
||||
* @returns {MapChunkView}
|
||||
*/
|
||||
getOrCreateChunkAtTile(tileX, tileY) {
|
||||
const chunkX = Math_floor(tileX / globalConfig.mapChunkSize);
|
||||
const chunkY = Math_floor(tileY / globalConfig.mapChunkSize);
|
||||
return this.getChunk(chunkX, chunkY, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a chunk if not existent for the given tile
|
||||
* @param {number} tileX
|
||||
* @param {number} tileY
|
||||
* @returns {MapChunkView?}
|
||||
*/
|
||||
getChunkAtTileOrNull(tileX, tileY) {
|
||||
const chunkX = Math_floor(tileX / globalConfig.mapChunkSize);
|
||||
const chunkY = Math_floor(tileY / globalConfig.mapChunkSize);
|
||||
return this.getChunk(chunkX, chunkY, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given tile is within the map bounds
|
||||
* @param {Vector} tile
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isValidTile(tile) {
|
||||
if (G_IS_DEV) {
|
||||
assert(tile instanceof Vector, "tile is not a vector");
|
||||
}
|
||||
return Number.isInteger(tile.x) && Number.isInteger(tile.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tile content of a given tile
|
||||
* @param {Vector} tile
|
||||
* @returns {Entity} Entity or null
|
||||
*/
|
||||
getTileContent(tile) {
|
||||
if (G_IS_DEV) {
|
||||
this.internalCheckTile(tile);
|
||||
}
|
||||
const chunk = this.getChunkAtTileOrNull(tile.x, tile.y);
|
||||
return chunk && chunk.getTileContentFromWorldCoords(tile.x, tile.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the lower layers content of the given tile
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {BaseItem=}
|
||||
*/
|
||||
getLowerLayerContentXY(x, y) {
|
||||
return this.getOrCreateChunkAtTile(x, y).getLowerLayerFromWorldCoords(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tile content of a given tile
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {Entity} Entity or null
|
||||
*/
|
||||
getTileContentXY(x, y) {
|
||||
const chunk = this.getChunkAtTileOrNull(x, y);
|
||||
return chunk && chunk.getTileContentFromWorldCoords(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the tile is used
|
||||
* @param {Vector} tile
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTileUsed(tile) {
|
||||
if (G_IS_DEV) {
|
||||
this.internalCheckTile(tile);
|
||||
}
|
||||
const chunk = this.getChunkAtTileOrNull(tile.x, tile.y);
|
||||
return chunk && chunk.getTileContentFromWorldCoords(tile.x, tile.y) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the tiles content
|
||||
* @param {Vector} tile
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
setTileContent(tile, entity) {
|
||||
if (G_IS_DEV) {
|
||||
this.internalCheckTile(tile);
|
||||
}
|
||||
|
||||
this.getOrCreateChunkAtTile(tile.x, tile.y).setTileContentFromWorldCords(tile.x, tile.y, entity);
|
||||
|
||||
const staticComponent = entity.components.StaticMapEntity;
|
||||
assert(staticComponent, "Can only place static map entities in tiles");
|
||||
}
|
||||
|
||||
/**
|
||||
* Places an entity with the StaticMapEntity component
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
placeStaticEntity(entity) {
|
||||
assert(entity.components.StaticMapEntity, "Entity is not static");
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const rect = staticComp.getTileSpaceBounds();
|
||||
for (let dx = 0; dx < rect.w; ++dx) {
|
||||
for (let dy = 0; dy < rect.h; ++dy) {
|
||||
const x = rect.x + dx;
|
||||
const y = rect.y + dy;
|
||||
this.getOrCreateChunkAtTile(x, y).setTileContentFromWorldCords(x, y, entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an entity with the StaticMapEntity component
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
removeStaticEntity(entity) {
|
||||
assert(entity.components.StaticMapEntity, "Entity is not static");
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
const rect = staticComp.getTileSpaceBounds();
|
||||
for (let dx = 0; dx < rect.w; ++dx) {
|
||||
for (let dy = 0; dy < rect.h; ++dy) {
|
||||
const x = rect.x + dx;
|
||||
const y = rect.y + dy;
|
||||
this.getOrCreateChunkAtTile(x, y).setTileContentFromWorldCords(x, y, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the tiles content
|
||||
* @param {Vector} tile
|
||||
*/
|
||||
clearTile(tile) {
|
||||
if (G_IS_DEV) {
|
||||
this.internalCheckTile(tile);
|
||||
}
|
||||
this.getOrCreateChunkAtTile(tile.x, tile.y).setTileContentFromWorldCords(tile.x, tile.y, null);
|
||||
}
|
||||
|
||||
// Internal
|
||||
|
||||
/**
|
||||
* Checks a given tile for validty
|
||||
* @param {Vector} tile
|
||||
*/
|
||||
internalCheckTile(tile) {
|
||||
assert(tile instanceof Vector, "tile is not a vector: " + tile);
|
||||
assert(tile.x % 1 === 0, "Tile X is not a valid integer: " + tile.x);
|
||||
assert(tile.y % 1 === 0, "Tile Y is not a valid integer: " + tile.y);
|
||||
}
|
||||
}
|
||||
359
src/js/game/map_chunk.js
Normal file
359
src/js/game/map_chunk.js
Normal file
@@ -0,0 +1,359 @@
|
||||
/* typehints:start */
|
||||
import { GameRoot } from "./root";
|
||||
/* typehints:end */
|
||||
|
||||
import { Math_ceil, Math_max, Math_min, Math_random, Math_round } from "../core/builtins";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { createLogger } from "../core/logging";
|
||||
import {
|
||||
clamp,
|
||||
fastArrayDeleteValueIfContained,
|
||||
make2DUndefinedArray,
|
||||
randomChoice,
|
||||
randomInt,
|
||||
} from "../core/utils";
|
||||
import { Vector } from "../core/vector";
|
||||
import { BaseItem } from "./base_item";
|
||||
import { enumColors } from "./colors";
|
||||
import { Entity } from "./entity";
|
||||
import { ColorItem } from "./items/color_item";
|
||||
import { ShapeItem } from "./items/shape_item";
|
||||
import { enumSubShape } from "./shape_definition";
|
||||
|
||||
const logger = createLogger("map_chunk");
|
||||
|
||||
export class MapChunk {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
constructor(root, x, y) {
|
||||
this.root = root;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.tileX = x * globalConfig.mapChunkSize;
|
||||
this.tileY = y * globalConfig.mapChunkSize;
|
||||
|
||||
/** @type {Array<Array<?Entity>>} */
|
||||
this.contents = make2DUndefinedArray(
|
||||
globalConfig.mapChunkSize,
|
||||
globalConfig.mapChunkSize,
|
||||
"map-chunk@" + this.x + "|" + this.y
|
||||
);
|
||||
|
||||
/** @type {Array<Array<?BaseItem>>} */
|
||||
this.lowerLayer = make2DUndefinedArray(
|
||||
globalConfig.mapChunkSize,
|
||||
globalConfig.mapChunkSize,
|
||||
"map-chunk-lower@" + this.x + "|" + this.y
|
||||
);
|
||||
|
||||
/** @type {Array<Entity>} */
|
||||
this.containedEntities = [];
|
||||
|
||||
/**
|
||||
* Store which patches we have so we can render them in the overview
|
||||
* @type {Array<{pos: Vector, item: BaseItem, size: number }>}
|
||||
*/
|
||||
this.patches = [];
|
||||
|
||||
this.generateLowerLayer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a patch filled with the given item
|
||||
* @param {number} patchSize
|
||||
* @param {BaseItem} item
|
||||
* @param {number=} overrideX Override the X position of the patch
|
||||
* @param {number=} overrideY Override the Y position of the patch
|
||||
*/
|
||||
internalGeneratePatch(patchSize, item, overrideX = null, overrideY = null) {
|
||||
const border = Math_ceil(patchSize / 2 + 3);
|
||||
|
||||
// Find a position within the chunk which is not blocked
|
||||
let patchX = randomInt(border, globalConfig.mapChunkSize - border - 1);
|
||||
let patchY = randomInt(border, globalConfig.mapChunkSize - border - 1);
|
||||
|
||||
if (overrideX !== null) {
|
||||
patchX = overrideX;
|
||||
}
|
||||
|
||||
if (overrideY !== null) {
|
||||
patchY = overrideY;
|
||||
}
|
||||
|
||||
const avgPos = new Vector(0, 0);
|
||||
let patchesDrawn = 0;
|
||||
|
||||
// Each patch consists of multiple circles
|
||||
const numCircles = patchSize;
|
||||
// const numCircles = 1;
|
||||
|
||||
for (let i = 0; i <= numCircles; ++i) {
|
||||
// Determine circle parameters
|
||||
const circleRadius = Math_min(1 + i, patchSize);
|
||||
const circleRadiusSquare = circleRadius * circleRadius;
|
||||
const circleOffsetRadius = (numCircles - i) / 2 + 2;
|
||||
|
||||
// We draw an elipsis actually
|
||||
const circleScaleY = 1 + (Math_random() * 2 - 1) * 0.1;
|
||||
const circleScaleX = 1 + (Math_random() * 2 - 1) * 0.1;
|
||||
|
||||
const circleX = patchX + randomInt(-circleOffsetRadius, circleOffsetRadius);
|
||||
const circleY = patchY + randomInt(-circleOffsetRadius, circleOffsetRadius);
|
||||
|
||||
for (let dx = -circleRadius * circleScaleX - 2; dx <= circleRadius * circleScaleX + 2; ++dx) {
|
||||
for (let dy = -circleRadius * circleScaleY - 2; dy <= circleRadius * circleScaleY + 2; ++dy) {
|
||||
const x = Math_round(circleX + dx);
|
||||
const y = Math_round(circleY + dy);
|
||||
if (x >= 0 && x < globalConfig.mapChunkSize && y >= 0 && y <= globalConfig.mapChunkSize) {
|
||||
const originalDx = dx / circleScaleX;
|
||||
const originalDy = dy / circleScaleY;
|
||||
if (originalDx * originalDx + originalDy * originalDy <= circleRadiusSquare) {
|
||||
if (!this.lowerLayer[x][y]) {
|
||||
this.lowerLayer[x][y] = item;
|
||||
++patchesDrawn;
|
||||
avgPos.x += x;
|
||||
avgPos.y += y;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// logger.warn("Tried to spawn resource out of chunk");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.patches.push({
|
||||
pos: avgPos.divideScalar(patchesDrawn),
|
||||
item,
|
||||
size: patchSize,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a color patch
|
||||
* @param {number} colorPatchSize
|
||||
* @param {number} distanceToOriginInChunks
|
||||
*/
|
||||
internalGenerateColorPatch(colorPatchSize, distanceToOriginInChunks) {
|
||||
// First, determine available colors
|
||||
let availableColors = [enumColors.red, enumColors.green];
|
||||
if (distanceToOriginInChunks > 2) {
|
||||
availableColors.push(enumColors.blue);
|
||||
}
|
||||
this.internalGeneratePatch(colorPatchSize, new ColorItem(randomChoice(availableColors)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a shape patch
|
||||
* @param {number} shapePatchSize
|
||||
* @param {number} distanceToOriginInChunks
|
||||
*/
|
||||
internalGenerateShapePatch(shapePatchSize, distanceToOriginInChunks) {
|
||||
/** @type {[enumSubShape, enumSubShape, enumSubShape, enumSubShape]} */
|
||||
let subShapes = null;
|
||||
|
||||
let weights = {};
|
||||
|
||||
if (distanceToOriginInChunks < 3) {
|
||||
// In the beginning, there are just circles
|
||||
weights = {
|
||||
[enumSubShape.circle]: 100,
|
||||
};
|
||||
} else if (distanceToOriginInChunks < 6) {
|
||||
// Later there come rectangles
|
||||
if (Math_random() > 0.4) {
|
||||
weights = {
|
||||
[enumSubShape.circle]: 100,
|
||||
};
|
||||
} else {
|
||||
weights = {
|
||||
[enumSubShape.rect]: 100,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Finally there is a mix of everything
|
||||
weights = {
|
||||
[enumSubShape.rect]: 100,
|
||||
[enumSubShape.circle]: Math_round(50 + clamp(distanceToOriginInChunks * 2, 0, 50)),
|
||||
[enumSubShape.star]: Math_round(20 + clamp(distanceToOriginInChunks * 2, 0, 30)),
|
||||
[enumSubShape.windmill]: Math_round(5 + clamp(distanceToOriginInChunks * 2, 0, 20)),
|
||||
};
|
||||
}
|
||||
subShapes = [
|
||||
this.internalGenerateRandomSubShape(weights),
|
||||
this.internalGenerateRandomSubShape(weights),
|
||||
this.internalGenerateRandomSubShape(weights),
|
||||
this.internalGenerateRandomSubShape(weights),
|
||||
];
|
||||
|
||||
const definition = this.root.shapeDefinitionMgr.getDefinitionFromSimpleShapes(subShapes);
|
||||
this.internalGeneratePatch(shapePatchSize, new ShapeItem(definition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a random shape with the given weights
|
||||
* @param {Object.<enumSubShape, number>} weights
|
||||
* @returns {enumSubShape}
|
||||
*/
|
||||
internalGenerateRandomSubShape(weights) {
|
||||
// @ts-ignore
|
||||
const sum = Object.values(weights).reduce((a, b) => a + b, 0);
|
||||
|
||||
const chosenNumber = randomInt(0, sum - 1);
|
||||
let accumulated = 0;
|
||||
for (const key in weights) {
|
||||
const weight = weights[key];
|
||||
if (accumulated + weight > chosenNumber) {
|
||||
return key;
|
||||
}
|
||||
accumulated += weight;
|
||||
}
|
||||
|
||||
logger.error("Failed to find matching shape in chunk generation");
|
||||
return enumSubShape.circle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the lower layer "terrain"
|
||||
*/
|
||||
generateLowerLayer() {
|
||||
if (this.generatePredefined()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkCenter = new Vector(this.x, this.y).addScalar(0.5);
|
||||
const distanceToOriginInChunks = Math_round(chunkCenter.length());
|
||||
|
||||
// Determine how likely it is that there is a color patch
|
||||
const colorPatchChance = 0.9 - clamp(distanceToOriginInChunks / 25, 0, 1) * 0.5;
|
||||
if (Math_random() < colorPatchChance) {
|
||||
const colorPatchSize = Math_max(2, Math_round(1 + clamp(distanceToOriginInChunks / 8, 0, 4)));
|
||||
this.internalGenerateColorPatch(colorPatchSize, distanceToOriginInChunks);
|
||||
}
|
||||
|
||||
// Determine how likely it is that there is a shape patch
|
||||
const shapePatchChance = 0.9 - clamp(distanceToOriginInChunks / 25, 0, 1) * 0.5;
|
||||
if (Math_random() < shapePatchChance) {
|
||||
const shapePatchSize = Math_max(2, Math_round(1 + clamp(distanceToOriginInChunks / 8, 0, 4)));
|
||||
this.internalGenerateShapePatch(shapePatchSize, distanceToOriginInChunks);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this chunk has predefined contents, and if so returns true and generates the
|
||||
* predefined contents
|
||||
* @returns {boolean}
|
||||
*/
|
||||
generatePredefined() {
|
||||
if (this.x === 0 && this.y === 0) {
|
||||
this.internalGeneratePatch(2, new ColorItem(enumColors.red), 7, 7);
|
||||
return true;
|
||||
}
|
||||
if (this.x === -1 && this.y === 0) {
|
||||
const definition = this.root.shapeDefinitionMgr.getDefinitionFromSimpleShapes([
|
||||
enumSubShape.circle,
|
||||
enumSubShape.circle,
|
||||
enumSubShape.circle,
|
||||
enumSubShape.circle,
|
||||
]);
|
||||
this.internalGeneratePatch(2, new ShapeItem(definition), globalConfig.mapChunkSize - 9, 7);
|
||||
return true;
|
||||
}
|
||||
if (this.x === 0 && this.y === -1) {
|
||||
const definition = this.root.shapeDefinitionMgr.getDefinitionFromSimpleShapes([
|
||||
enumSubShape.rect,
|
||||
enumSubShape.rect,
|
||||
enumSubShape.rect,
|
||||
enumSubShape.rect,
|
||||
]);
|
||||
this.internalGeneratePatch(2, new ShapeItem(definition), 5, globalConfig.mapChunkSize - 7);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.x === -1 && this.y === -1) {
|
||||
this.internalGeneratePatch(2, new ColorItem(enumColors.green));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} worldX
|
||||
* @param {number} worldY
|
||||
* @returns {BaseItem=}
|
||||
*/
|
||||
getLowerLayerFromWorldCoords(worldX, worldY) {
|
||||
const localX = worldX - this.tileX;
|
||||
const localY = worldY - this.tileY;
|
||||
assert(localX >= 0, "Local X is < 0");
|
||||
assert(localY >= 0, "Local Y is < 0");
|
||||
assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size");
|
||||
assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size");
|
||||
return this.lowerLayer[localX][localY] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the contents of this chunk from the given world space coordinates
|
||||
* @param {number} worldX
|
||||
* @param {number} worldY
|
||||
* @returns {Entity=}
|
||||
*/
|
||||
getTileContentFromWorldCoords(worldX, worldY) {
|
||||
const localX = worldX - this.tileX;
|
||||
const localY = worldY - this.tileY;
|
||||
assert(localX >= 0, "Local X is < 0");
|
||||
assert(localY >= 0, "Local Y is < 0");
|
||||
assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size");
|
||||
assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size");
|
||||
return this.contents[localX][localY] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the chunks contents from the given local coordinates
|
||||
* @param {number} localX
|
||||
* @param {number} localY
|
||||
* @returns {Entity=}
|
||||
*/
|
||||
getTileContentFromLocalCoords(localX, localY) {
|
||||
assert(localX >= 0, "Local X is < 0");
|
||||
assert(localY >= 0, "Local Y is < 0");
|
||||
assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size");
|
||||
assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size");
|
||||
|
||||
return this.contents[localX][localY] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the chunks contents
|
||||
* @param {number} tileX
|
||||
* @param {number} tileY
|
||||
* @param {Entity=} contents
|
||||
*/
|
||||
setTileContentFromWorldCords(tileX, tileY, contents) {
|
||||
const localX = tileX - this.tileX;
|
||||
const localY = tileY - this.tileY;
|
||||
assert(localX >= 0, "Local X is < 0");
|
||||
assert(localY >= 0, "Local Y is < 0");
|
||||
assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size");
|
||||
assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size");
|
||||
const oldContents = this.contents[localX][localY];
|
||||
assert(contents === null || !oldContents, "Tile already used: " + tileX + " / " + tileY);
|
||||
|
||||
if (oldContents) {
|
||||
// Remove from list
|
||||
fastArrayDeleteValueIfContained(this.containedEntities, oldContents);
|
||||
}
|
||||
this.contents[localX][localY] = contents;
|
||||
if (contents) {
|
||||
if (this.containedEntities.indexOf(contents) < 0) {
|
||||
this.containedEntities.push(contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
226
src/js/game/map_chunk_view.js
Normal file
226
src/js/game/map_chunk_view.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import { MapChunk } from "./map_chunk";
|
||||
import { GameRoot } from "./root";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { round1Digit } from "../core/utils";
|
||||
import { Math_max, Math_round } from "../core/builtins";
|
||||
import { Rectangle } from "../core/rectangle";
|
||||
import { createLogger } from "../core/logging";
|
||||
import { smoothenDpi } from "../core/dpi_manager";
|
||||
|
||||
const logger = createLogger("chunk");
|
||||
const chunkSizePixels = globalConfig.mapChunkSize * globalConfig.tileSize;
|
||||
|
||||
export class MapChunkView extends MapChunk {
|
||||
/**
|
||||
*
|
||||
* @param {GameRoot} root
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
constructor(root, x, y) {
|
||||
super(root, x, y);
|
||||
|
||||
this.boundInternalDrawBackgroundToContext = this.internalDrawBackgroundToContext.bind(this);
|
||||
this.boundInternalDrawForegroundToContext = this.internalDrawForegroundToContext.bind(this);
|
||||
|
||||
/**
|
||||
* Whenever something changes, we increase this number - so we know we need to redraw
|
||||
*/
|
||||
this.renderIteration = 0;
|
||||
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this chunk as dirty, rerendering all caches
|
||||
*/
|
||||
markDirty() {
|
||||
++this.renderIteration;
|
||||
this.renderKey = this.x + "/" + this.y + "@" + this.renderIteration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the background layer
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawBackgroundLayer(parameters) {
|
||||
if (parameters.zoomLevel > globalConfig.mapChunkPrerenderMinZoom) {
|
||||
this.internalDrawBackgroundSystems(parameters);
|
||||
return;
|
||||
}
|
||||
|
||||
const dpi = smoothenDpi(parameters.zoomLevel);
|
||||
const buffer = this.root.buffers.getForKey(
|
||||
"" + dpi,
|
||||
this.renderKey + "@bg",
|
||||
chunkSizePixels,
|
||||
chunkSizePixels,
|
||||
dpi,
|
||||
this.boundInternalDrawBackgroundToContext,
|
||||
{ zoomLevel: parameters.zoomLevel }
|
||||
);
|
||||
|
||||
parameters.context.drawImage(
|
||||
buffer,
|
||||
this.tileX * globalConfig.tileSize,
|
||||
this.tileY * globalConfig.tileSize,
|
||||
chunkSizePixels,
|
||||
chunkSizePixels
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the foreground layer
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawForegroundLayer(parameters) {
|
||||
if (parameters.zoomLevel > globalConfig.mapChunkPrerenderMinZoom) {
|
||||
this.internalDrawForegroundSystems(parameters);
|
||||
return;
|
||||
}
|
||||
|
||||
const dpi = smoothenDpi(parameters.zoomLevel);
|
||||
const buffer = this.root.buffers.getForKey(
|
||||
"" + dpi,
|
||||
this.renderKey + "@fg",
|
||||
chunkSizePixels,
|
||||
chunkSizePixels,
|
||||
dpi,
|
||||
this.boundInternalDrawForegroundToContext,
|
||||
{ zoomLevel: parameters.zoomLevel }
|
||||
);
|
||||
parameters.context.drawImage(
|
||||
buffer,
|
||||
this.tileX * globalConfig.tileSize,
|
||||
this.tileY * globalConfig.tileSize,
|
||||
chunkSizePixels,
|
||||
chunkSizePixels
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {number} dpi
|
||||
*/
|
||||
internalDrawBackgroundToContext(canvas, context, w, h, dpi, { zoomLevel }) {
|
||||
const pattern = context.createPattern(this.root.map.cachedBackgroundCanvas, "repeat");
|
||||
context.scale(dpi, dpi);
|
||||
|
||||
if (zoomLevel >= globalConfig.mapChunkOverviewMinZoom) {
|
||||
const bgDpi = this.root.map.backgroundCacheDPI;
|
||||
context.scale(1 / bgDpi, 1 / bgDpi);
|
||||
context.fillStyle = pattern;
|
||||
context.fillRect(0, 0, chunkSizePixels * bgDpi, chunkSizePixels * bgDpi);
|
||||
context.scale(bgDpi, bgDpi);
|
||||
} else {
|
||||
if (this.containedEntities.length > 0) {
|
||||
context.fillStyle = "#c5ccd6";
|
||||
} else {
|
||||
context.fillStyle = "#a6afbb";
|
||||
}
|
||||
context.fillRect(0, 0, 10000, 10000);
|
||||
}
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.showChunkBorders) {
|
||||
context.fillStyle = "rgba(0, 0, 255, 0.1)";
|
||||
context.fillRect(0, 0, 10000, 10000);
|
||||
}
|
||||
|
||||
const parameters = new DrawParameters({
|
||||
context,
|
||||
visibleRect: new Rectangle(
|
||||
this.tileX * globalConfig.tileSize,
|
||||
this.tileY * globalConfig.tileSize,
|
||||
chunkSizePixels,
|
||||
chunkSizePixels
|
||||
),
|
||||
desiredAtlasScale: "1",
|
||||
zoomLevel,
|
||||
root: this.root,
|
||||
});
|
||||
|
||||
parameters.context.translate(
|
||||
-this.tileX * globalConfig.tileSize,
|
||||
-this.tileY * globalConfig.tileSize
|
||||
);
|
||||
// parameters.context.save();
|
||||
// parameters.context.transform(
|
||||
// 1,
|
||||
// 0,
|
||||
// 0,
|
||||
// zoomLevel,
|
||||
// this.tileX * globalConfig.tileSize,
|
||||
// this.tileY * globalConfig.tileSize
|
||||
// );
|
||||
|
||||
this.internalDrawBackgroundSystems(parameters);
|
||||
|
||||
// parameters.context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} context
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {number} dpi
|
||||
*/
|
||||
internalDrawForegroundToContext(canvas, context, w, h, dpi, { zoomLevel }) {
|
||||
context.scale(dpi, dpi);
|
||||
|
||||
const parameters = new DrawParameters({
|
||||
context,
|
||||
visibleRect: new Rectangle(
|
||||
this.tileX * globalConfig.tileSize,
|
||||
this.tileY * globalConfig.tileSize,
|
||||
chunkSizePixels,
|
||||
chunkSizePixels
|
||||
),
|
||||
desiredAtlasScale: "1",
|
||||
zoomLevel,
|
||||
root: this.root,
|
||||
});
|
||||
// parameters.context.save();
|
||||
// parameters.context.save();
|
||||
// parameters.context.transform(
|
||||
// zoomLevel,
|
||||
// 0,
|
||||
// 0,
|
||||
// zoomLevel,
|
||||
// this.tileX * globalConfig.tileSize,
|
||||
// this.tileY * globalConfig.tileSize
|
||||
// );
|
||||
|
||||
parameters.context.translate(
|
||||
-this.tileX * globalConfig.tileSize,
|
||||
-this.tileY * globalConfig.tileSize
|
||||
);
|
||||
this.internalDrawForegroundSystems(parameters);
|
||||
|
||||
// parameters.context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
internalDrawBackgroundSystems(parameters) {
|
||||
const systems = this.root.systemMgr.systems;
|
||||
systems.mapResources.drawChunk(parameters, this);
|
||||
systems.belt.drawChunk(parameters, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
internalDrawForegroundSystems(parameters) {
|
||||
const systems = this.root.systemMgr.systems;
|
||||
systems.miner.drawChunk(parameters, this);
|
||||
systems.staticMapEntities.drawChunk(parameters, this);
|
||||
}
|
||||
}
|
||||
249
src/js/game/map_view.js
Normal file
249
src/js/game/map_view.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import { Math_max, Math_min, Math_floor, Math_ceil } from "../core/builtins";
|
||||
import { globalConfig } from "../core/config";
|
||||
import { DrawParameters } from "../core/draw_parameters";
|
||||
import { BaseMap } from "./map";
|
||||
import { freeCanvas, makeOffscreenBuffer } from "../core/buffer_utils";
|
||||
import { Entity } from "./entity";
|
||||
|
||||
/**
|
||||
* This is the view of the map, it extends the map which is the raw model and allows
|
||||
* to draw it
|
||||
*/
|
||||
export class MapView extends BaseMap {
|
||||
constructor(root) {
|
||||
super(root);
|
||||
|
||||
/**
|
||||
* DPI of the background cache images, required in some places
|
||||
*/
|
||||
this.backgroundCacheDPI = 4;
|
||||
|
||||
/**
|
||||
* The cached background sprite, containing the flat background
|
||||
* @type {HTMLCanvasElement} */
|
||||
this.cachedBackgroundCanvas = null;
|
||||
|
||||
/** @type {CanvasRenderingContext2D} */
|
||||
this.cachedBackgroundContext = null;
|
||||
/**
|
||||
* Cached pattern of the stripes background
|
||||
* @type {CanvasPattern} */
|
||||
this.cachedBackgroundPattern = null;
|
||||
|
||||
this.internalInitializeCachedBackgroundCanvases();
|
||||
this.root.signals.aboutToDestruct.add(this.cleanup, this);
|
||||
|
||||
this.root.signals.entityAdded.add(this.onEntityChanged, this);
|
||||
this.root.signals.entityDestroyed.add(this.onEntityChanged, this);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
freeCanvas(this.cachedBackgroundCanvas);
|
||||
this.cachedBackgroundCanvas = null;
|
||||
this.cachedBackgroundPattern = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when an entity was added or removed
|
||||
* @param {Entity} entity
|
||||
*/
|
||||
onEntityChanged(entity) {
|
||||
const staticComp = entity.components.StaticMapEntity;
|
||||
if (staticComp) {
|
||||
const rect = staticComp.getTileSpaceBounds();
|
||||
for (let x = rect.x; x <= rect.right(); ++x) {
|
||||
for (let y = rect.y; y <= rect.bottom(); ++y) {
|
||||
this.root.map.getOrCreateChunkAtTile(x, y).markDirty();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws all static entities like buildings etc.
|
||||
* @param {DrawParameters} drawParameters
|
||||
*/
|
||||
drawStaticEntities(drawParameters) {
|
||||
const cullRange = drawParameters.visibleRect.toTileCullRectangle();
|
||||
const top = cullRange.top();
|
||||
const right = cullRange.right();
|
||||
const bottom = cullRange.bottom();
|
||||
const left = cullRange.left();
|
||||
|
||||
const border = 1;
|
||||
|
||||
const minY = top - border;
|
||||
const maxY = bottom + border;
|
||||
const minX = left - border;
|
||||
const maxX = right + border - 1;
|
||||
|
||||
// Render y from top down for proper blending
|
||||
for (let y = minY; y <= maxY; ++y) {
|
||||
for (let x = minX; x <= maxX; ++x) {
|
||||
// const content = this.tiles[x][y];
|
||||
const chunk = this.getChunkAtTileOrNull(x, y);
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
const content = chunk.getTileContentFromWorldCoords(x, y);
|
||||
if (content) {
|
||||
let isBorder = x <= left - 1 || x >= right + 1 || y <= top - 1 || y >= bottom + 1;
|
||||
if (!isBorder) {
|
||||
content.draw(drawParameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all canvases used for background rendering
|
||||
*/
|
||||
internalInitializeCachedBackgroundCanvases() {
|
||||
// Background canvas
|
||||
const dims = globalConfig.tileSize;
|
||||
const dpi = this.backgroundCacheDPI;
|
||||
const [canvas, context] = makeOffscreenBuffer(dims * dpi, dims * dpi, {
|
||||
smooth: false,
|
||||
label: "map-cached-bg",
|
||||
});
|
||||
context.scale(dpi, dpi);
|
||||
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, dims, dims);
|
||||
|
||||
context.fillStyle = "#fafafa";
|
||||
context.fillRect(0, 0, dims, 1);
|
||||
context.fillRect(0, 0, 1, dims);
|
||||
context.fillRect(dims - 1, 0, 1, dims);
|
||||
context.fillRect(0, dims - 1, dims, 1);
|
||||
|
||||
this.cachedBackgroundCanvas = canvas;
|
||||
this.cachedBackgroundContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the maps foreground
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawForeground(parameters) {
|
||||
const cullRange = parameters.visibleRect.toTileCullRectangle();
|
||||
const top = cullRange.top();
|
||||
const right = cullRange.right();
|
||||
const bottom = cullRange.bottom();
|
||||
const left = cullRange.left();
|
||||
|
||||
const border = 1;
|
||||
const minY = top - border;
|
||||
const maxY = bottom + border;
|
||||
const minX = left - border;
|
||||
const maxX = right + border - 1;
|
||||
|
||||
const chunkStartX = Math_floor(minX / globalConfig.mapChunkSize);
|
||||
const chunkStartY = Math_floor(minY / globalConfig.mapChunkSize);
|
||||
|
||||
const chunkEndX = Math_ceil(maxX / globalConfig.mapChunkSize);
|
||||
const chunkEndY = Math_ceil(maxY / globalConfig.mapChunkSize);
|
||||
|
||||
// Render y from top down for proper blending
|
||||
for (let chunkX = chunkStartX; chunkX <= chunkEndX; ++chunkX) {
|
||||
for (let chunkY = chunkStartY; chunkY <= chunkEndY; ++chunkY) {
|
||||
const chunk = this.root.map.getChunk(chunkX, chunkY, true);
|
||||
chunk.drawForegroundLayer(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the map background
|
||||
* @param {DrawParameters} parameters
|
||||
*/
|
||||
drawBackground(parameters) {
|
||||
// If not using prerendered, draw background
|
||||
if (parameters.zoomLevel > globalConfig.mapChunkPrerenderMinZoom) {
|
||||
if (!this.cachedBackgroundPattern) {
|
||||
this.cachedBackgroundPattern = parameters.context.createPattern(
|
||||
this.cachedBackgroundCanvas,
|
||||
"repeat"
|
||||
);
|
||||
}
|
||||
|
||||
const dpi = this.backgroundCacheDPI;
|
||||
parameters.context.scale(1 / dpi, 1 / dpi);
|
||||
|
||||
parameters.context.fillStyle = this.cachedBackgroundPattern;
|
||||
parameters.context.fillRect(
|
||||
parameters.visibleRect.x * dpi,
|
||||
parameters.visibleRect.y * dpi,
|
||||
parameters.visibleRect.w * dpi,
|
||||
parameters.visibleRect.h * dpi
|
||||
);
|
||||
parameters.context.scale(dpi, dpi);
|
||||
}
|
||||
|
||||
const cullRange = parameters.visibleRect.toTileCullRectangle();
|
||||
const top = cullRange.top();
|
||||
const right = cullRange.right();
|
||||
const bottom = cullRange.bottom();
|
||||
const left = cullRange.left();
|
||||
|
||||
const border = 1;
|
||||
const minY = top - border;
|
||||
const maxY = bottom + border;
|
||||
const minX = left - border;
|
||||
const maxX = right + border - 1;
|
||||
|
||||
const chunkStartX = Math_floor(minX / globalConfig.mapChunkSize);
|
||||
const chunkStartY = Math_floor(minY / globalConfig.mapChunkSize);
|
||||
|
||||
const chunkEndX = Math_ceil(maxX / globalConfig.mapChunkSize);
|
||||
const chunkEndY = Math_ceil(maxY / globalConfig.mapChunkSize);
|
||||
|
||||
// Render y from top down for proper blending
|
||||
for (let chunkX = chunkStartX; chunkX <= chunkEndX; ++chunkX) {
|
||||
for (let chunkY = chunkStartY; chunkY <= chunkEndY; ++chunkY) {
|
||||
const chunk = this.root.map.getChunk(chunkX, chunkY, true);
|
||||
chunk.drawBackgroundLayer(parameters);
|
||||
}
|
||||
}
|
||||
|
||||
if (G_IS_DEV && globalConfig.debug.showChunkBorders) {
|
||||
const cullRange = parameters.visibleRect.toTileCullRectangle();
|
||||
const top = cullRange.top();
|
||||
const right = cullRange.right();
|
||||
const bottom = cullRange.bottom();
|
||||
const left = cullRange.left();
|
||||
|
||||
const border = 1;
|
||||
const minY = top - border;
|
||||
const maxY = bottom + border;
|
||||
const minX = left - border;
|
||||
const maxX = right + border - 1;
|
||||
|
||||
const chunkStartX = Math_floor(minX / globalConfig.mapChunkSize);
|
||||
const chunkStartY = Math_floor(minY / globalConfig.mapChunkSize);
|
||||
|
||||
const chunkEndX = Math_ceil(maxX / globalConfig.mapChunkSize);
|
||||
const chunkEndY = Math_ceil(maxY / globalConfig.mapChunkSize);
|
||||
|
||||
// Render y from top down for proper blending
|
||||
for (let chunkX = chunkStartX; chunkX <= chunkEndX; ++chunkX) {
|
||||
for (let chunkY = chunkStartY; chunkY <= chunkEndY; ++chunkY) {
|
||||
parameters.context.fillStyle = "#ffaaaa";
|
||||
parameters.context.fillRect(
|
||||
chunkX * globalConfig.mapChunkSize * globalConfig.tileSize,
|
||||
chunkY * globalConfig.mapChunkSize * globalConfig.tileSize,
|
||||
globalConfig.mapChunkSize * globalConfig.tileSize,
|
||||
3
|
||||
);
|
||||
parameters.context.fillRect(
|
||||
chunkX * globalConfig.mapChunkSize * globalConfig.tileSize,
|
||||
chunkY * globalConfig.mapChunkSize * globalConfig.tileSize,
|
||||
3,
|
||||
globalConfig.mapChunkSize * globalConfig.tileSize
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
155
src/js/game/meta_building.js
Normal file
155
src/js/game/meta_building.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Vector, enumDirection, enumAngleToDirection } from "../core/vector";
|
||||
import { Loader } from "../core/loader";
|
||||
import { GameRoot } from "./root";
|
||||
import { AtlasSprite } from "../core/sprites";
|
||||
import { Entity } from "./entity";
|
||||
import { StaticMapEntityComponent } from "./components/static_map_entity";
|
||||
|
||||
export class MetaBuilding {
|
||||
/**
|
||||
*
|
||||
* @param {string} id Building id
|
||||
*/
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of this building
|
||||
*/
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the dimensions of the building
|
||||
*/
|
||||
getDimensions() {
|
||||
return new Vector(1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the name of this building
|
||||
*/
|
||||
getName() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return the description of this building
|
||||
*/
|
||||
getDescription() {
|
||||
return "No Description";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to stay in placement mode after having placed a building
|
||||
*/
|
||||
getStayInPlacementMode() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to flip the orientation after a building has been placed - useful
|
||||
* for tunnels.
|
||||
*/
|
||||
getFlipOrientationAfterPlacement() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a preview sprite
|
||||
* @returns {AtlasSprite}
|
||||
*/
|
||||
getPreviewSprite(rotationVariant = 0) {
|
||||
return Loader.getSprite("sprites/buildings/" + this.id + ".png");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this building is rotateable
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRotateable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this building is unlocked for the given game
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
getIsUnlocked(root) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return a silhouette color for the map overview or null if not set
|
||||
*/
|
||||
getSilhouetteColor() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the entity at the given location
|
||||
* @param {GameRoot} root
|
||||
* @param {Vector} origin Origin tile
|
||||
* @param {number=} rotation Rotation
|
||||
* @param {number=} rotationVariant Rotation variant
|
||||
*/
|
||||
createAndPlaceEntity(root, origin, rotation = 0, rotationVariant = 0) {
|
||||
const entity = new Entity(root);
|
||||
entity.addComponent(
|
||||
new StaticMapEntityComponent({
|
||||
spriteKey: "sprites/buildings/" + this.id + ".png",
|
||||
origin: new Vector(origin.x, origin.y),
|
||||
rotationDegrees: rotation,
|
||||
tileSize: this.getDimensions().copy(),
|
||||
silhouetteColor: this.getSilhouetteColor(),
|
||||
})
|
||||
);
|
||||
|
||||
this.setupEntityComponents(entity, root);
|
||||
this.updateRotationVariant(entity, rotationVariant);
|
||||
|
||||
root.entityMgr.registerEntity(entity);
|
||||
root.map.placeStaticEntity(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should compute the optimal rotation variant on the given tile
|
||||
* @param {GameRoot} root
|
||||
* @param {Vector} tile
|
||||
* @param {number} rotation
|
||||
* @return {{ rotation: number, rotationVariant: number, connectedEntities?: Array<Entity> }}
|
||||
*/
|
||||
computeOptimalDirectionAndRotationVariantAtTile(root, tile, rotation) {
|
||||
if (!this.isRotateable()) {
|
||||
return {
|
||||
rotation: 0,
|
||||
rotationVariant: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
rotation,
|
||||
rotationVariant: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Should update the entity to match the given rotation variant
|
||||
* @param {Entity} entity
|
||||
* @param {number} rotationVariant
|
||||
*/
|
||||
updateRotationVariant(entity, rotationVariant) {}
|
||||
|
||||
// PRIVATE INTERFACE
|
||||
|
||||
/**
|
||||
* Should setup the entity components
|
||||
* @param {Entity} entity
|
||||
* @param {GameRoot} root
|
||||
*/
|
||||
setupEntityComponents(entity, root) {
|
||||
abstract;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user