mirror of
https://gitgud.io/BondageProjects/Bondage-College.git
synced 2025-04-25 17:59:34 +00:00
The reason is that the previous way of doing `ScreenFunctions[funcname]` actually breaks in strict mode, because the optionality of the callback gets carried over, causing an error.
1666 lines
58 KiB
JavaScript
1666 lines
58 KiB
JavaScript
// Main variables
|
|
"use strict";
|
|
/** @type {PlayerCharacter} */
|
|
var Player;
|
|
/**
|
|
* @type {number|string}
|
|
* @deprecated Use the keyboard handler's `event` parameter instead
|
|
*/
|
|
var KeyPress = "";
|
|
/** @type {ModuleType} */
|
|
var CurrentModule;
|
|
/** @type {ModuleScreens[CurrentModule]} */
|
|
var CurrentScreen;
|
|
/** @type {ScreenFunctions} */
|
|
var CurrentScreenFunctions;
|
|
/** @type {Character|NPCCharacter|null} */
|
|
var CurrentCharacter = null;
|
|
var CurrentOnlinePlayers = 0;
|
|
/**
|
|
* A per-screen ratio of how darkened the background must be
|
|
*
|
|
* 1 is bright, 0 is pitch black
|
|
*/
|
|
var CurrentDarkFactor = 1.0;
|
|
var CommonIsMobile = false;
|
|
/** @type {Record<string, string[][]>} */
|
|
var CommonCSVCache = {};
|
|
var CutsceneStage = 0;
|
|
var CommonPhotoMode = false;
|
|
|
|
/**
|
|
* An enum encapsulating possible chatroom message substitution tags. Character name substitution tags are interpreted
|
|
* in chatrooms as follows (assuming the character name is Ben987):
|
|
* SOURCE_CHAR: "Ben987"
|
|
* DEST_CHAR: "Ben987's" (if character is not self), "her" (if character is self)
|
|
* DEST_CHAR_NAME: "Ben987's"
|
|
* TARGET_CHAR: "Ben987" (if character is not self), "herself" (if character is self)
|
|
* TARGET_CHAR_NAME: "Ben987"
|
|
* Additionally, sending the following tags will ensure that asset names in messages are correctly translated by
|
|
* recipients:
|
|
* ASSET_NAME: (substituted with the localized name of the asset, if available)
|
|
* @type {Record<"SOURCE_CHAR"|"DEST_CHAR"|"DEST_CHAR_NAME"|"TARGET_CHAR"|"TARGET_CHAR_NAME"|"ASSET_NAME"|"AUTOMATIC", CommonChatTags>}
|
|
*/
|
|
const CommonChatTags = {
|
|
SOURCE_CHAR: "SourceCharacter",
|
|
DEST_CHAR: "DestinationCharacter",
|
|
DEST_CHAR_NAME: "DestinationCharacterName",
|
|
TARGET_CHAR: "TargetCharacter",
|
|
TARGET_CHAR_NAME: "TargetCharacterName",
|
|
ASSET_NAME: "AssetName",
|
|
AUTOMATIC: "Automatic",
|
|
};
|
|
|
|
String.prototype.replaceAt=function(index, character) {
|
|
return this.substr(0, index) + character + this.substr(index+character.length);
|
|
};
|
|
|
|
/**
|
|
* A map of keys to common font stack definitions. Each stack definition is a
|
|
* two-item array whose first item is an ordered list of fonts, and whose
|
|
* second item is the generic fallback font family (e.g. sans-serif, serif,
|
|
* etc.)
|
|
* @constant
|
|
* @type {Record<String, [String[], String]>}
|
|
*/
|
|
const CommonFontStacks = {
|
|
Arial: [["Arial"], "sans-serif"],
|
|
TimesNewRoman: [["Times New Roman", "Times"], "serif"],
|
|
Papyrus: [["Papyrus", "Ink Free", "Segoe Script", "Gabriola"], "fantasy"],
|
|
ComicSans: [["Comic Sans MS", "Comic Sans", "Brush Script MT", "Segoe Print"], "cursive"],
|
|
Impact: [["Impact", "Arial Black", "Franklin Gothic", "Arial"], "sans-serif"],
|
|
HelveticaNeue: [["Helvetica Neue", "Helvetica", "Arial"], "sans-serif"],
|
|
Verdana: [["Verdana", "Helvetica Neue", "Arial"], "sans-serif"],
|
|
CenturyGothic: [["Century Gothic", "Apple Gothic", "AppleGothic", "Futura"], "sans-serif"],
|
|
Georgia: [["Georgia", "Times"], "serif"],
|
|
CourierNew: [["Courier New", "Courier"], "monospace"],
|
|
Copperplate: [["Copperplate", "Copperplate Gothic Light"], "fantasy"],
|
|
};
|
|
|
|
/**
|
|
* Checks if a variable is a number
|
|
* @param {any} n - Variable to check for
|
|
* @returns {n is number} - Returns TRUE if the variable is a finite number
|
|
*/
|
|
function CommonIsNumeric(n) {
|
|
return !isNaN(parseFloat(n)) && isFinite(n);
|
|
}
|
|
|
|
/**
|
|
* Gets the current time as a string
|
|
* @returns {string} - Returns the current date and time in a yyyy-mm-dd hh:mm:ss format
|
|
*/
|
|
function CommonGetFormatDate() {
|
|
var d = new Date();
|
|
var yyyy = d.getFullYear();
|
|
var mm = d.getMonth() < 9 ? "0" + (d.getMonth() + 1) : (d.getMonth() + 1); // getMonth() is zero-based
|
|
var dd = d.getDate() < 10 ? "0" + d.getDate() : d.getDate();
|
|
var hh = d.getHours() < 10 ? "0" + d.getHours() : d.getHours();
|
|
var min = d.getMinutes() < 10 ? "0" + d.getMinutes() : d.getMinutes();
|
|
var ss = d.getSeconds() < 10 ? "0" + d.getSeconds() : d.getSeconds();
|
|
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
|
|
}
|
|
|
|
/**
|
|
* Detects if the user is on a mobile browser
|
|
* @returns {boolean} - Returns TRUE if the user is on a mobile browser
|
|
*/
|
|
function CommonDetectMobile() {
|
|
|
|
// First check
|
|
var mobile = ['iphone', 'ipad', 'android', 'blackberry', 'nokia', 'opera mini', 'windows mobile', 'windows phone', 'iemobile', 'mobile/', 'webos', 'kindle'];
|
|
for (let i in mobile) if (navigator.userAgent.toLowerCase().indexOf(mobile[i].toLowerCase()) > 0) return true;
|
|
|
|
// IPad pro check
|
|
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 2 && /MacIntel/.test(navigator.platform)) return true;
|
|
|
|
// Second check
|
|
if (sessionStorage.desktop) return false;
|
|
else if (localStorage.mobile) return true;
|
|
|
|
// If nothing is found, we assume desktop
|
|
return false;
|
|
|
|
}
|
|
|
|
/**
|
|
* Gets the current browser name and version
|
|
* @returns {{Name: string, Version: string}} - Browser info
|
|
*/
|
|
function CommonGetBrowser() {
|
|
var ua = navigator.userAgent, tem, M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
|
|
if (/trident/i.test(M[1])) {
|
|
tem = /\brv[ :]+(\d+)/g.exec(ua) || [];
|
|
return { Name: "IE", Version: (tem[1] || "N/A") };
|
|
}
|
|
if (M[1] === 'Chrome') {
|
|
tem = ua.match(/\bOPR|Edge\/(\d+)/);
|
|
if (tem != null) return { Name: "Opera", Version: tem[1] || "N/A" };
|
|
}
|
|
M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, '-?'];
|
|
if ((tem = ua.match(/version\/(\d+)/i)) != null) M.splice(1, 1, tem[1]);
|
|
return { Name: M[0] || "N/A", Version: M[1] || "N/A" };
|
|
}
|
|
|
|
/**
|
|
* Parse a CSV file content into an array
|
|
* @param {string} str - Content of the CSV
|
|
* @returns {string[][]} Array representing each line of the parsed content, each line itself is split by commands and stored within an array.
|
|
*/
|
|
function CommonParseCSV(str) {
|
|
/** @type {string[][]} */
|
|
var arr = [];
|
|
var quote = false; // true means we're inside a quoted field
|
|
var c;
|
|
var col;
|
|
// We remove whitespace on start and end
|
|
str = str.replace(/\r\n/g, '\n').trim();
|
|
|
|
// iterate over each character, keep track of current row and column (of the returned array)
|
|
for (let row = col = c = 0; c < str.length; c++) {
|
|
var cc = str[c], nc = str[c + 1]; // current character, next character
|
|
arr[row] = arr[row] || []; // create a new row if necessary
|
|
arr[row][col] = arr[row][col] || ''; // create a new column (start with empty string) if necessary
|
|
|
|
// If the current character is a quotation mark, and we're inside a
|
|
// quoted field, and the next character is also a quotation mark,
|
|
// add a quotation mark to the current column and skip the next character
|
|
if (cc == '"' && quote && nc == '"') { arr[row][col] += cc; ++c; continue; }
|
|
|
|
// If it's just one quotation mark, begin/end quoted field
|
|
if (cc == '"') { quote = !quote; continue; }
|
|
|
|
// If it's a comma and we're not in a quoted field, move on to the next column
|
|
if (cc == ',' && !quote) { ++col; continue; }
|
|
|
|
// If it's a newline and we're not in a quoted field, move on to the next
|
|
// row and move to column 0 of that new row
|
|
if (cc == '\n' && !quote) { ++row; col = 0; continue; }
|
|
|
|
// Otherwise, append the current character to the current column
|
|
arr[row][col] += cc;
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
/**
|
|
* Read a CSV file from cache, or fetch it from the server
|
|
* @param {string} Array - Name of where the cached text is stored
|
|
* @param {string} Path - Path/Group in which the screen is located
|
|
* @param {string} Screen - Screen for which the file is for
|
|
* @param {string} File - Name of the file to get
|
|
* @returns {void} - Nothing
|
|
*/
|
|
function CommonReadCSV(Array, Path, Screen, File) {
|
|
|
|
// Changed from a single path to various arguments and internally concatenate them
|
|
// This ternary operator is used to keep backward compatibility
|
|
var FullPath = "Screens/" + Path + "/" + Screen + "/" + File + ".csv";
|
|
if (CommonCSVCache[FullPath]) {
|
|
window[Array] = CommonCSVCache[FullPath];
|
|
return;
|
|
}
|
|
|
|
// Opens the file, parse it and returns the result in an Object
|
|
CommonGet(FullPath, function () {
|
|
if (this.status == 200) {
|
|
CommonCSVCache[FullPath] = CommonParseCSV(this.responseText);
|
|
window[Array] = CommonCSVCache[FullPath];
|
|
}
|
|
});
|
|
|
|
// If a translation file is available, we open the txt file and keep it in cache
|
|
var TranslationPath = FullPath.replace(".csv", "_" + TranslationLanguage + ".txt");
|
|
if (TranslationAvailable(TranslationPath))
|
|
CommonGet(TranslationPath, function () {
|
|
if (this.status == 200) TranslationCache[TranslationPath] = TranslationParseTXT(this.responseText);
|
|
});
|
|
|
|
}
|
|
|
|
/**
|
|
* AJAX utility to get a file and return its content. By default will retry requests 10 times
|
|
* @param {string} Path - Path of the resource to request
|
|
* @param {(this: XMLHttpRequest, xhr: XMLHttpRequest) => void} Callback - Callback to execute once the resource is received
|
|
* @param {number} [RetriesLeft] - How many more times to retry if the request fails - after this hits zero, an error will be logged
|
|
* @returns {void} - Nothing
|
|
*/
|
|
function CommonGet(Path, Callback, RetriesLeft) {
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open("GET", Path);
|
|
xhr.onloadend = function() {
|
|
// For non-error responses, call the callback
|
|
if (this.status < 400) Callback.bind(this)(xhr);
|
|
// Otherwise, retry
|
|
else CommonGetRetry(Path, Callback, RetriesLeft);
|
|
};
|
|
xhr.onerror = function() { CommonGetRetry(Path, Callback, RetriesLeft); };
|
|
xhr.send(null);
|
|
}
|
|
|
|
/**
|
|
* Retry handler for CommonGet requests. Exponentially backs off retry attempts up to a limit of 1 minute. By default,
|
|
* retries up to a maximum of 10 times.
|
|
* @param {string} Path - The path of the resource to request
|
|
* @param {(this: XMLHttpRequest, xhr: XMLHttpRequest) => void} Callback - Callback to execute once the resource is received
|
|
* @param {number} [RetriesLeft] - How many more times to retry - after this hits zero, an error will be logged
|
|
* @returns {void} - Nothing
|
|
*/
|
|
function CommonGetRetry(Path, Callback, RetriesLeft) {
|
|
if (typeof RetriesLeft !== "number") RetriesLeft = 10;
|
|
if (RetriesLeft <= 0) {
|
|
console.error(`GET request to ${Path} failed - no more retries`);
|
|
} else {
|
|
const retrySeconds = Math.min(Math.pow(2, Math.max(0, 10 - RetriesLeft)), 60);
|
|
console.warn(`GET request to ${Path} failed - retrying in ${retrySeconds} second${retrySeconds === 1 ? "" : "s"}...`);
|
|
setTimeout(() => CommonGet(Path, Callback, RetriesLeft - 1), retrySeconds * 1000);
|
|
}
|
|
}
|
|
|
|
/** @type {MouseEventListener} */
|
|
function CommonMouseDown(event) {
|
|
if (CurrentCharacter == null) {
|
|
if (CurrentScreenFunctions.MouseDown) {
|
|
CurrentScreenFunctions.MouseDown(event);
|
|
}
|
|
} else {
|
|
DialogMouseDown(event);
|
|
}
|
|
}
|
|
|
|
/** @type {MouseEventListener} */
|
|
function CommonMouseUp(event) {
|
|
if (CurrentScreenFunctions.MouseUp)
|
|
{
|
|
CurrentScreenFunctions.MouseUp(event);
|
|
}
|
|
}
|
|
|
|
/** @type {MouseEventListener} */
|
|
function CommonMouseMove(event) {
|
|
if (CurrentScreenFunctions.MouseMove)
|
|
{
|
|
CurrentScreenFunctions.MouseMove(event);
|
|
}
|
|
}
|
|
|
|
/** @type {MouseWheelEventListener} */
|
|
function CommonMouseWheel(event) {
|
|
if (CurrentScreenFunctions.MouseWheel) {
|
|
CurrentScreenFunctions.MouseWheel(event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Catches the clicks on the main screen and forwards it to the current screen click function if it exists, otherwise it sends it to the dialog click function
|
|
* @type {MouseEventListener}
|
|
*/
|
|
function CommonClick(event) {
|
|
ServerClickBeep();
|
|
if (CurrentCharacter == null)
|
|
CurrentScreenFunctions.Click(event);
|
|
else
|
|
DialogClick(event);
|
|
}
|
|
|
|
/**
|
|
* Returns TRUE if a section of the screen is currently touched on a mobile device
|
|
* @param {number} X - The X position
|
|
* @param {number} Y - The Y position
|
|
* @param {number} W - The width of the square
|
|
* @param {number} H - The height of the square
|
|
* @param {TouchList} [TL] - Can give a specific touch event instead of the default one
|
|
* @returns {boolean}
|
|
*/
|
|
function CommonTouchActive(X, Y, W, H, TL) {
|
|
if (!CommonIsMobile) return false;
|
|
if (TL == null) TL = CommonTouchList;
|
|
if (TL == null) return;
|
|
for (let index = 0; index < TL.length; index++) {
|
|
const Touch = TL[index];
|
|
let TouchX = Math.round((Touch.pageX - MainCanvas.canvas.offsetLeft) * 2000 / MainCanvas.canvas.clientWidth);
|
|
let TouchY = Math.round((Touch.pageY - MainCanvas.canvas.offsetTop) * 1000 / MainCanvas.canvas.clientHeight);
|
|
if ((TouchX >= X) && (TouchX <= X + W) && (TouchY >= Y) && (TouchY <= Y + H))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Catches key presses on the main screen and forwards it to the current screen key down function if it exists, otherwise it sends it to the dialog key down function
|
|
* @deprecated Use GameKeyDown instead
|
|
* @type {KeyboardEventListener}
|
|
*/
|
|
function CommonKeyDown(event) { return false; }
|
|
|
|
/**
|
|
* Calls a basic dynamic function if it exists, for complex functions, use: CommonDynamicFunctionParams
|
|
* @param {string} FunctionName - Name of the function to call
|
|
* @returns {void} - Nothing
|
|
*/
|
|
function CommonDynamicFunction(FunctionName) {
|
|
if (typeof window[FunctionName.substr(0, FunctionName.indexOf("("))] === "function")
|
|
window[FunctionName.replace("()", "")]();
|
|
else
|
|
console.log("Trying to launch invalid function: " + FunctionName);
|
|
}
|
|
|
|
|
|
/**
|
|
* Calls a dynamic function with parameters (if it exists), also allow ! in front to reverse the result. The dynamic function is the provided function name in the dialog option object and it is prefixed by the current screen.
|
|
* @param {string} FunctionName - Function name to call dynamically
|
|
* @returns {*} - Returns what the dynamic function returns or FALSE if the function does not exist
|
|
*/
|
|
function CommonDynamicFunctionParams(FunctionName) {
|
|
|
|
// Gets the reverse (!) sign
|
|
var Reverse = false;
|
|
if (FunctionName.substring(0, 1) == "!") Reverse = true;
|
|
FunctionName = FunctionName.replace("!", "");
|
|
|
|
// Gets the real function name and parameters
|
|
var openParenthesisIndex = FunctionName.indexOf("(");
|
|
var closedParenthesisIndex = FunctionName.indexOf(")", openParenthesisIndex);
|
|
var ParamsString = FunctionName.substring(openParenthesisIndex + 1, closedParenthesisIndex);
|
|
var Params = ParamsString.length === 0 ? [] : ParamsString.split(",");
|
|
for (let P = 0; P < Params.length; P++)
|
|
Params[P] = Params[P].trim().replace('"', '').replace('"', '');
|
|
FunctionName = FunctionName.substring(0, openParenthesisIndex);
|
|
if ((FunctionName.indexOf("Dialog") != 0) && (FunctionName.indexOf("Inventory") != 0) && (FunctionName.indexOf(CurrentScreen) != 0)) FunctionName = CurrentScreen + FunctionName;
|
|
|
|
// If it's really a function, we continue
|
|
if (typeof window[FunctionName] === "function") {
|
|
|
|
// Launches the function with the params and returns the result
|
|
var Result = window[FunctionName](...Params);
|
|
return Reverse ? !Result : Result;
|
|
} else {
|
|
|
|
// Log the error in the console
|
|
console.log("Trying to launch invalid function: " + FunctionName);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Calls a named global function with the passed in arguments, if the named function exists. Differs from
|
|
* CommonDynamicFunctionParams in that arguments are not parsed from the passed in FunctionName string, but
|
|
* passed directly into the function call, allowing for more complex JS objects to be passed in. This
|
|
* function will not log to console if the provided function name does not exist as a global function.
|
|
* @param {string} FunctionName - The name of the global function to call
|
|
* @param {readonly any[]} [args] - zero or more arguments to be passed to the function (optional)
|
|
* @returns {any} - returns the result of the function call, or undefined if the function name isn't valid
|
|
*/
|
|
function CommonCallFunctionByName(FunctionName/*, ...args */) {
|
|
var Function = window[FunctionName];
|
|
if (typeof Function === "function") {
|
|
var args = Array.prototype.slice.call(arguments, 1);
|
|
return Function.apply(null, args);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Behaves exactly like CommonCallFunctionByName, but logs a warning if the function name is invalid.
|
|
* @param {string} FunctionName - The name of the global function to call
|
|
* @param {readonly any[]} [args] - zero or more arguments to be passed to the function (optional)
|
|
* @returns {any} - returns the result of the function call, or undefined if the function name isn't valid
|
|
*/
|
|
function CommonCallFunctionByNameWarn(FunctionName/*, ...args */) {
|
|
var Function = window[FunctionName];
|
|
if (typeof Function === "function") {
|
|
var args = Array.prototype.slice.call(arguments, 1);
|
|
return Function.apply(null, args);
|
|
} else {
|
|
console.warn(`Attempted to call invalid function "${FunctionName}"`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current screen
|
|
* @returns {ScreenSpecifier}
|
|
*/
|
|
function CommonGetScreen() {
|
|
return /** @type {ScreenSpecifier} */([CurrentModule, CurrentScreen]);
|
|
}
|
|
|
|
/**
|
|
* Sets the current screen and calls the loading script if needed
|
|
* @param {ScreenSpecifier} spec
|
|
* @returns {void} - Nothing
|
|
*/
|
|
function CommonSetScreen(...spec) {
|
|
if (CurrentScreenFunctions && CurrentScreenFunctions.Unload) {
|
|
CurrentScreenFunctions.Unload();
|
|
}
|
|
if (ControllerIsActive()) {
|
|
ControllerClearAreas();
|
|
}
|
|
|
|
|
|
const [NewModule, NewScreen] = spec;
|
|
// Check for required functions
|
|
if (typeof window[`${NewScreen}Run`] !== "function") {
|
|
throw Error(`Screen "${NewScreen}": Missing required Run function`);
|
|
}
|
|
if (typeof window[`${NewScreen}Click`] !== "function") {
|
|
throw Error(`Screen "${NewScreen}": Missing required Click function`);
|
|
}
|
|
|
|
CurrentModule = NewModule;
|
|
CurrentScreen = NewScreen;
|
|
CurrentScreenFunctions = {
|
|
Run: (time) => window[`${NewScreen}Run`](time),
|
|
Click: (e) => window[`${NewScreen}Click`](e),
|
|
MouseDown: typeof window[`${NewScreen}MouseDown`] === "function" ? (e) => window[`${NewScreen}MouseDown`](e) : undefined,
|
|
MouseUp: typeof window[`${NewScreen}MouseUp`] === "function" ? (e) => window[`${NewScreen}MouseUp`](e) : undefined,
|
|
MouseMove: typeof window[`${NewScreen}MouseMove`] === "function" ? (e) => window[`${NewScreen}MouseMove`](e) : undefined,
|
|
MouseWheel: typeof window[`${NewScreen}MouseWheel`] === "function" ? (e) => window[`${NewScreen}MouseWheel`](e) : undefined,
|
|
Draw: typeof window[`${NewScreen}Draw`] === "function" ? () => window[`${NewScreen}Draw`]() : undefined,
|
|
Load: typeof window[`${NewScreen}Load`] === "function" ? () => window[`${NewScreen}Load`]() : undefined,
|
|
Unload: typeof window[`${NewScreen}Unload`] === "function" ? () => window[`${NewScreen}Unload`]() : undefined,
|
|
Resize: typeof window[`${NewScreen}Resize`] === "function" ? (load) => window[`${NewScreen}Resize`](load) : undefined,
|
|
KeyDown: typeof window[`${NewScreen}KeyDown`] === "function" ? (e) => window[`${NewScreen}KeyDown`](e) : undefined,
|
|
KeyUp: typeof window[`${NewScreen}KeyUp`] === "function" ? (e) => window[`${NewScreen}KeyUp`](e) : undefined,
|
|
Exit: typeof window[`${NewScreen}Exit`] === "function" ? () => window[`${NewScreen}Exit`]() : undefined
|
|
};
|
|
|
|
CurrentDarkFactor = 1.0;
|
|
CommonGetFont.clearCache();
|
|
CommonGetFontName.clearCache();
|
|
TextLoad();
|
|
|
|
ElementToggleGeneratedElements(CurrentScreen, true);
|
|
|
|
if (CurrentScreenFunctions.Load) {
|
|
CurrentScreenFunctions.Load();
|
|
}
|
|
if (CurrentScreenFunctions.Resize) {
|
|
CurrentScreenFunctions.Resize(true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the current time in ms
|
|
* @returns {number} - Date in ms
|
|
*/
|
|
function CommonTime() {
|
|
return Date.now();
|
|
}
|
|
|
|
/**
|
|
* Checks if a given value is a valid HEX color code
|
|
* @param {string | undefined} Value - Potential HEX color code
|
|
* @returns {boolean} - Returns TRUE if the string is a valid HEX color
|
|
*/
|
|
function CommonIsColor(Value) {
|
|
if ((Value == null) || (Value.length < 3)) return false;
|
|
//convert short hand hex color to standard format
|
|
if (/^#[0-9A-F]{3}$/i.test(Value)) Value = "#" + Value[1] + Value[1] + Value[2] + Value[2] + Value[3] + Value[3];
|
|
return /^#[0-9A-F]{6}$/i.test(Value);
|
|
}
|
|
|
|
/**
|
|
* Checks whether an item's color has a valid value that can be interpreted by the drawing
|
|
* functions. Valid values are null, undefined, strings, and an array containing any of the
|
|
* aforementioned types.
|
|
* @param {null | string | readonly (null | string)[]} [Color] - The Color value to check
|
|
* @returns {boolean} - Returns TRUE if the color is a valid item color
|
|
*/
|
|
function CommonColorIsValid(Color) {
|
|
if (Color == null || typeof Color === "string") return true;
|
|
if (Array.isArray(Color)) return Color.every(C => C == null || typeof C === "string");
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check that the passed string looks like an acceptable email address.
|
|
*
|
|
* @param {string} Email
|
|
* @returns {boolean}
|
|
*/
|
|
function CommonEmailIsValid(Email) {
|
|
if (Email.length < 5 || Email.length > 100) return false;
|
|
|
|
const parts = Email.split("@");
|
|
if (parts.length !== 2) return false;
|
|
if (parts[1].indexOf(".") === -1) return false;
|
|
|
|
return ServerAccountEmailRegex.test(Email);
|
|
}
|
|
|
|
/**
|
|
* Remove item from list on given index and returns it
|
|
* @template T
|
|
* @param {T[]} list
|
|
* @param {number} index
|
|
* @returns {undefined|T}
|
|
*/
|
|
function CommonRemoveItemFromList(list, index) {
|
|
if(index > list.length || index < 0) return undefined;
|
|
return list.splice(index, 1)[0];
|
|
}
|
|
|
|
/**
|
|
* Removes random item from list and returns it
|
|
* @template T
|
|
* @param {T[]} list
|
|
* @returns {T}
|
|
*/
|
|
function CommonRemoveRandomItemFromList(list) {
|
|
return CommonRemoveItemFromList(list, Math.floor(Math.random() * list.length));
|
|
}
|
|
|
|
/**
|
|
* Get a random item from a list while making sure not to pick the previous one.
|
|
* Function expects unique values in the list. If there are multiple instances of ItemPrevious, it may still return it.
|
|
* @template T
|
|
* @param {T} ItemPrevious - Previously selected item from the given list
|
|
* @param {readonly T[]} ItemList - List for which to pick a random item from
|
|
* @returns {T} - The randomly selected item from the list
|
|
*/
|
|
function CommonRandomItemFromList(ItemPrevious, ItemList) {
|
|
let previousIndex = ItemList.indexOf(ItemPrevious);
|
|
let maxRandom = ItemList.length;
|
|
if(previousIndex > -1){
|
|
maxRandom--;
|
|
}
|
|
if(maxRandom > 0){
|
|
let randomIndex = Math.floor(Math.random() * maxRandom);
|
|
if( previousIndex > -1 && randomIndex >= previousIndex) randomIndex++;
|
|
return ItemList[randomIndex];
|
|
} else return undefined;
|
|
}
|
|
|
|
/**
|
|
* Converts a string of numbers split by commas to an array, sanitizes the array by removing all NaN or undefined elements.
|
|
* @param {string} s - String of numbers split by commas
|
|
* @returns {number[]} - Array of valid numbers from the given string
|
|
*/
|
|
function CommonConvertStringToArray(s) {
|
|
var arr = [];
|
|
if (typeof s === "string") {
|
|
arr = s.split(',').reduce((list, curr) => {
|
|
if (!(!curr || Number.isNaN(Number(curr)))) list.push(Number(curr));
|
|
return list;
|
|
}, []);
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
/**
|
|
* Shuffles all characters in a string
|
|
* @param {string} string - The string to shuffle
|
|
* @returns {string} - The shuffled string
|
|
*/
|
|
function CommonStringShuffle(string) {
|
|
var parts = string.split('');
|
|
for (var i = parts.length; i > 0;) {
|
|
var random = parseInt(Math.random() * i);
|
|
var temp = parts[--i];
|
|
parts[i] = parts[random];
|
|
parts[random] = temp;
|
|
}
|
|
return parts.join('');
|
|
}
|
|
|
|
/**
|
|
* Generate a unique ID
|
|
* @returns {string}
|
|
*/
|
|
function CommonGenerateUniqueID() {
|
|
return Math.random().toString(36).substring(2);
|
|
}
|
|
/**
|
|
* Converts an array to a string separated by commas (equivalent of .join(","))
|
|
* @param {readonly any[]} Arr - Array to convert to a joined string
|
|
* @returns {string} - String of all the array items joined together
|
|
*/
|
|
function CommonConvertArrayToString(Arr) {
|
|
var S = "";
|
|
for (let P = 0; P < Arr.length; P++) {
|
|
if (P != 0) S = S + ",";
|
|
S = S + Arr[P].toString();
|
|
}
|
|
return S;
|
|
}
|
|
|
|
/**
|
|
* Checks whether two item colors are equal. An item color may either be a string or an array of strings.
|
|
* @param {string | readonly string[]} C1 - The first color to check
|
|
* @param {string | readonly string[]} C2 - The second color to check
|
|
* @returns {boolean} - TRUE if C1 and C2 represent the same item color, FALSE otherwise
|
|
*/
|
|
function CommonColorsEqual(C1, C2) {
|
|
if (Array.isArray(C1) && Array.isArray(C2)) {
|
|
return CommonArraysEqual(C1, C2);
|
|
}
|
|
return C1 === C2;
|
|
}
|
|
|
|
/**
|
|
* Checks whether two arrays are equal. The arrays are considered equal if they have the same length and contain the same items in the same
|
|
* order, as determined by === comparison
|
|
* @template {readonly *[]} T
|
|
* @param {T} a1 - The first array to compare
|
|
* @param {readonly *[]} a2 - The second array to compare
|
|
* @param {boolean} [ignoreOrder] - Whether to ignore item order when considering equality
|
|
* @returns {a2 is T} - TRUE if both arrays have the same length and contain the same items in the same order, FALSE otherwise
|
|
*/
|
|
function CommonArraysEqual(a1, a2, ignoreOrder = false) {
|
|
return a1.length === a2.length && a1.every((item, i) => ignoreOrder ? a2.includes(item) : item === a2[i]);
|
|
}
|
|
|
|
/**
|
|
* Creates a debounced wrapper for the provided function with the provided wait time. The wrapped function will not be called as long as
|
|
* the debounced function continues to be called. If the debounced function is called, and then not called again within the wait time, the
|
|
* wrapped function will be called.
|
|
* @param {function} func - The function to debounce
|
|
* @returns {function} - A debounced version of the provided function
|
|
*/
|
|
function CommonDebounce(func) {
|
|
let timeout, args, context, timestamp, result, wait;
|
|
|
|
function later() {
|
|
const last = CommonTime() - timestamp;
|
|
if (last >= 0 && last < wait) {
|
|
timeout = setTimeout(later, wait - last);
|
|
} else {
|
|
timeout = null;
|
|
result = func.apply(context, args);
|
|
context = args = null;
|
|
}
|
|
}
|
|
|
|
return function (waitInterval/*, ...args */) {
|
|
context = this;
|
|
wait = waitInterval;
|
|
args = Array.prototype.slice.call(arguments, 1);
|
|
timestamp = CommonTime();
|
|
if (!timeout) {
|
|
timeout = setTimeout(later, wait);
|
|
}
|
|
return result;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a throttling wrapper for the provided function with the provided wait time. If the wrapped function has been successfully called
|
|
* within the wait time, further call attempts will be delayed until the wait time has passed.
|
|
* @param {function} func - The function to throttle
|
|
* @returns {function} - A throttled version of the provided function
|
|
*/
|
|
function CommonThrottle(func) {
|
|
let timeout, args, context, timestamp = 0, result;
|
|
|
|
function run() {
|
|
timeout = null;
|
|
result = func.apply(context, args);
|
|
timestamp = CommonTime();
|
|
}
|
|
|
|
return function (wait/*, ...args */) {
|
|
context = this;
|
|
args = Array.prototype.slice.call(arguments, 1);
|
|
if (!timeout) {
|
|
const last = CommonTime() - timestamp;
|
|
if (last >= 0 && last < wait) {
|
|
timeout = setTimeout(run, wait - last);
|
|
} else {
|
|
run();
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a wrapper for a function to limit how often it can be called. The player-defined wait interval setting determines the
|
|
* allowed frequency. Below 100 ms the function will be throttled and above will be debounced.
|
|
* @template {(...args: any) => any} FunctionType
|
|
* @param {FunctionType} func - The function to limit calls of
|
|
* @param {number} [minWait=0] - A lower bound for how long the wait interval can be, 0 by default
|
|
* @param {number} [maxWait=1000] - An upper bound for how long the wait interval can be, 1 second by default
|
|
* @returns {FunctionType} - A debounced or throttled version of the function
|
|
*/
|
|
function CommonLimitFunction(func, minWait = 0, maxWait = 1000) {
|
|
const funcDebounced = CommonDebounce(func);
|
|
const funcThrottled = CommonThrottle(func);
|
|
|
|
return /** @type {FunctionType} */(function () {
|
|
const wait = Math.min(
|
|
Math.max(
|
|
Player.GraphicsSettings ? Player.GraphicsSettings.AnimationQuality : 100, minWait
|
|
),
|
|
maxWait,
|
|
);
|
|
const args = [wait].concat(Array.from(arguments));
|
|
return wait < 100 ? funcThrottled.apply(this, args) : funcDebounced.apply(this, args);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates a simple memoizer.
|
|
* The memoized function does calculate its result exactly once and from that point on, uses
|
|
* the result stored in a local cache to speed up things.
|
|
* @template {(...args: any) => any} T
|
|
* @param {T} func - The function to memoize
|
|
* @param {((arg: any) => string)[]} argConvertors - A list of stringification functions for creating a memo, one for each function argument
|
|
* @returns {MemoizedFunction<T>} - The result of the memoized function
|
|
*/
|
|
function CommonMemoize(func, argConvertors=null) {
|
|
var memo = {};
|
|
var memoized = /** @type {MemoizedFunction<T>} */(function (...args) {
|
|
let index = "";
|
|
if (argConvertors !== null) {
|
|
index += argConvertors.map((callback, i) => callback(args[i])).join(",");
|
|
} else {
|
|
for (const arg of args) {
|
|
if (typeof arg === "object") {
|
|
index += JSON.stringify(arg);
|
|
} else {
|
|
index += String(arg);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!(index in memo)) {
|
|
memo[index] = func(...args);
|
|
}
|
|
return memo[index];
|
|
});
|
|
|
|
// add a clear cache method
|
|
memoized.clearCache = function () {
|
|
memo = {};
|
|
};
|
|
return memoized;
|
|
}
|
|
|
|
/**
|
|
* Memoized getter function. Returns a font string specifying the player's
|
|
* preferred font and the provided size. This is memoized as it is called on
|
|
* every frame in many cases.
|
|
* @param {number} size - The font size that should be specified in the
|
|
* returned font string
|
|
* @returns {string} - A font string specifying the requested font size and
|
|
* the player's preferred font stack. For example:
|
|
* 12px "Courier New", "Courier", monospace
|
|
*/
|
|
const CommonGetFont = CommonMemoize((size) => {
|
|
return `${size}px ${CommonGetFontName()}`;
|
|
});
|
|
|
|
/**
|
|
* Memoized getter function. Returns a font string specifying the player's
|
|
* preferred font stack. This is memoized as it is called on every frame in
|
|
* many cases.
|
|
* @returns {string} - A font string specifying the player's preferred font
|
|
* stack. For example:
|
|
* "Courier New", "Courier", monospace
|
|
*/
|
|
const CommonGetFontName = CommonMemoize(() => {
|
|
const pref = Player && Player.GraphicsSettings && Player.GraphicsSettings.Font;
|
|
const fontStack = CommonFontStacks[pref] || CommonFontStacks.Arial;
|
|
const font = fontStack[0].map(fontName => `"${fontName}"`).join(", ");
|
|
return `${font}, ${fontStack[1]}`;
|
|
});
|
|
|
|
/**
|
|
* Take a screenshot of specified area in "photo mode" and open the image in a new tab
|
|
* @param {number} Left - Position of the area to capture from the left of the canvas
|
|
* @param {number} Top - Position of the area to capture from the top of the canvas
|
|
* @param {number} Width - Width of the area to capture
|
|
* @param {number} Height - Height of the area to capture
|
|
* @returns {void} - Nothing
|
|
*/
|
|
function CommonTakePhoto(Left, Top, Width, Height) {
|
|
CommonPhotoMode = true;
|
|
|
|
// Ensure everything is redrawn once in photo-mode
|
|
DrawProcess(0);
|
|
|
|
// Capture screen as image URL
|
|
const ImgData = /** @type {HTMLCanvasElement} */ (document.getElementById("MainCanvas")).getContext('2d').getImageData(Left, Top, Width, Height);
|
|
let PhotoCanvas = document.createElement('canvas');
|
|
PhotoCanvas.width = Width;
|
|
PhotoCanvas.height = Height;
|
|
PhotoCanvas.getContext('2d').putImageData(ImgData, 0, 0);
|
|
const PhotoImg = PhotoCanvas.toDataURL("image/png");
|
|
|
|
// Open the image in a new window
|
|
let newWindow = window.open('about:blank', '_blank');
|
|
if (newWindow) {
|
|
newWindow.document.write("<img src='" + PhotoImg + "' alt='from canvas'/>");
|
|
newWindow.document.close();
|
|
} else {
|
|
console.warn("Popups blocked: Cannot open photo in new tab.");
|
|
}
|
|
|
|
CommonPhotoMode = false;
|
|
}
|
|
|
|
/**
|
|
* Compares two version numbers and returns -1/0/1 if Other number is smaller/same/larger than Current one
|
|
* @param {string} Current Current version number
|
|
* @param {string} Other Other version number
|
|
* @returns {-1|0|1} Comparison result
|
|
*/
|
|
function CommonCompareVersion(Current, Other) {
|
|
const CurrentMatch = GameVersionFormat.exec(Current);
|
|
const OtherMatch = GameVersionFormat.exec(Other);
|
|
if (!CurrentMatch || !OtherMatch) return -1;
|
|
const CurrentVer = [
|
|
Number.parseInt(CurrentMatch[1]),
|
|
CurrentMatch[2] === "Alpha" ? 1 : CurrentMatch[2] === "Beta" ? 2 : 3,
|
|
Number.parseInt(CurrentMatch[3]) || 0
|
|
];
|
|
const OtherVer = [
|
|
Number.parseInt(OtherMatch[1]),
|
|
OtherMatch[2] === "Alpha" ? 1 : OtherMatch[2] === "Beta" ? 2 : 3,
|
|
Number.parseInt(OtherMatch[3]) || 0
|
|
];
|
|
for (let i = 0; i < 3; i++) {
|
|
if (CurrentVer[i] !== OtherVer[i]) {
|
|
return /** @type {-1|0|1} */ (Math.sign(OtherVer[i] - CurrentVer[i]));
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* A simple deep equality check function which checks whether two objects are equal. The function traverses recursively
|
|
* into objects and arrays to check for equality. Primitives and simple types are considered equal as defined by `===`.
|
|
* @template T
|
|
* @param {unknown} obj1 - The first object to compare
|
|
* @param {T} obj2 - The second object to compare
|
|
* @returns {obj1 is T} - TRUE if both objects are equal, up to arbitrarily deeply nested property values, FALSE
|
|
* otherwise.
|
|
*/
|
|
function CommonDeepEqual(obj1, obj2) {
|
|
if (obj1 === obj2) {
|
|
return true;
|
|
}
|
|
|
|
if (obj1 && obj2 && typeof obj1 === "object" && typeof obj2 === "object") {
|
|
// If the objects do not share a prototype, they are not equal
|
|
if (Object.getPrototypeOf(obj1) !== Object.getPrototypeOf(obj2)) {
|
|
return false;
|
|
}
|
|
|
|
// Get the keys for the objects
|
|
const keys1 = Object.keys(obj1);
|
|
const keys2 = Object.keys(obj2);
|
|
|
|
// If the objects have different numbers of keys, they are not equal
|
|
if (keys1.length !== keys2.length) {
|
|
return false;
|
|
}
|
|
|
|
// Sort the keys
|
|
keys1.sort();
|
|
keys2.sort();
|
|
return keys1.every((key, i) => {
|
|
// If the keys are different, the objects are not equal
|
|
if (key !== keys2[i]) {
|
|
return false;
|
|
}
|
|
// Otherwise, compare the values
|
|
return CommonDeepEqual(obj1[key], obj2[key]);
|
|
});
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* A simple deep equality check function which checks whether two objects are equal for all properties in `subRec`.
|
|
* The function traverses recursively into objects and arrays to check for equality.
|
|
* Primitives and simple types are considered equal as defined by `===`.
|
|
* @template T
|
|
* @param {unknown} subRec - The subset-containg object to compare
|
|
* @param {T} superRec - The superset-containg object to compare
|
|
* @returns {subRec is Partial<T>} - Whether `subRec` is a subset of `superRec` up to arbitrarily deeply nested property values
|
|
*/
|
|
function CommonDeepIsSubset(subRec, superRec) {
|
|
if (subRec === superRec) {
|
|
return true;
|
|
}
|
|
|
|
if (subRec && superRec && typeof subRec === "object" && typeof superRec === "object") {
|
|
// If the objects do not share a prototype, they are not equal
|
|
if (Object.getPrototypeOf(subRec) !== Object.getPrototypeOf(superRec)) {
|
|
return false;
|
|
}
|
|
|
|
if (CommonIsArray(subRec) && CommonIsArray(superRec)) {
|
|
return subRec.every(subValue => superRec.some(superValue => CommonDeepIsSubset(subValue, superValue)));
|
|
} else {
|
|
// Get the keys for the objects
|
|
const subKeys = Object.keys(subRec);
|
|
const superKeys = new Set(Object.keys(superRec));
|
|
|
|
// If the objects have different numbers of keys, they are not equal
|
|
if (!subKeys.every(k => superKeys.has(k))) {
|
|
return false;
|
|
} else {
|
|
return subKeys.every(key => CommonDeepIsSubset(subRec[key], superRec[key]));
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Adds all items from the source array to the destination array if they aren't already included
|
|
* @template T
|
|
* @param {T[]} dest - The destination array
|
|
* @param {readonly T[]} src - The source array
|
|
* @returns {T[]} - The destination array
|
|
*/
|
|
function CommonArrayConcatDedupe(dest, src) {
|
|
if (Array.isArray(src) && Array.isArray(dest)) {
|
|
for (const item of src) {
|
|
if (!dest.includes(item)) dest.push(item);
|
|
}
|
|
}
|
|
return dest;
|
|
}
|
|
|
|
/**
|
|
* Common function for removing a padlock from an item and publishing a corresponding chat message (must be called with
|
|
* the item's group focused)
|
|
* @param {Character} C - The character on whom the item is equipped
|
|
* @param {Item} Item - The item to unlock
|
|
* @returns {void} - Nothing
|
|
*/
|
|
function CommonPadlockUnlock(C, Item) {
|
|
for (let A = 0; A < C.Appearance.length; A++) {
|
|
if (C.Appearance[A].Asset.Group.Name === C.FocusGroup.Name) {
|
|
C.Appearance[A] = Item;
|
|
break;
|
|
}
|
|
}
|
|
InventoryUnlock(C, C.FocusGroup.Name);
|
|
if (ChatRoomPublishAction(C, "ActionUnlock", Item, null))
|
|
DialogLeave();
|
|
}
|
|
|
|
/**
|
|
* Common noop function
|
|
* @returns {void} - Nothing
|
|
*/
|
|
function CommonNoop() {
|
|
// Noop function
|
|
}
|
|
|
|
/**
|
|
* Redirects the address to HTTPS for all production environments, returns the proper heroku server
|
|
* @returns {String} - Returns the proper server to use in production or test
|
|
*/
|
|
function CommonGetServer() {
|
|
if ((location.href.indexOf("bondageprojects") < 0) && (location.href.indexOf("bondage-europe") < 0)) return "https://bondage-club-server-test.herokuapp.com/";
|
|
if (location.protocol !== 'https:') location.replace(`https:${location.href.substring(location.protocol.length)}`);
|
|
return "https://bondage-club-server.herokuapp.com/";
|
|
}
|
|
|
|
/**
|
|
* Performs the required substitutions on the given message
|
|
*
|
|
* @param {string} msg - The string to perform the substitutions on.
|
|
* @param {CommonSubtituteSubstitution[]} substitutions - An array of [string, replacement, replacer?] subtitutions.
|
|
*/
|
|
function CommonStringSubstitute(msg, substitutions) {
|
|
if (typeof msg !== "string" || msg.trim() === "")
|
|
return "";
|
|
|
|
function makeReplacer(replacer, replacement) {
|
|
if (typeof replacer === "function")
|
|
return (match, offset, string) => replacer(match, offset, replacement, string);
|
|
return () => replacement;
|
|
}
|
|
|
|
substitutions = substitutions.sort((a, b) => b[0].length - a[0].length);
|
|
for (const [tag, subst, replacer] of substitutions) {
|
|
let repl = makeReplacer(replacer, subst);
|
|
msg = msg.replace(new RegExp(tag, "g"), repl);
|
|
}
|
|
return msg;
|
|
}
|
|
|
|
/**
|
|
* Returns a nice version of the passed strings
|
|
*
|
|
* This turns ["this", "this", "that"] into "this, this, and that" using appropriate localization
|
|
*
|
|
* @param {string[]} strings The strings to join
|
|
*/
|
|
function CommonArrayJoinPretty(strings) {
|
|
const last = strings.pop();
|
|
let pretty = strings.join(InterfaceTextGet("PrettyArrayJoin"));
|
|
pretty += InterfaceTextGet("PrettyArrayJoinFinal") + last;
|
|
return pretty;
|
|
}
|
|
|
|
/**
|
|
* Returns a titlecased version of the given string.
|
|
* @param {string} str
|
|
* @returns {string}
|
|
*/
|
|
function CommonStringTitlecase(str) {
|
|
return str[0].toUpperCase() + str.substring(1);
|
|
}
|
|
|
|
/**
|
|
* Censors a string or words in that string based on the player preferences
|
|
* @param {string} S - The string to censor
|
|
* @returns {String} - The censored string
|
|
*/
|
|
function CommonCensor(S) {
|
|
|
|
// Validates that we must apply censoring
|
|
if ((Player.ChatSettings == null) || (Player.ChatSettings.CensoredWordsLevel == null) || (Player.ChatSettings.CensoredWordsList == null)) return S;
|
|
let WordList = PreferenceCensoredWordsList = Player.ChatSettings.CensoredWordsList.split("|");
|
|
if (WordList.length <= 0) return S;
|
|
|
|
// At level zero, we replace the word with ***
|
|
if (Player.ChatSettings.CensoredWordsLevel == 0)
|
|
for (let W of WordList)
|
|
if ((W != "") && (W != " ") && !W.includes("*") && S.toUpperCase().includes(W.toUpperCase())) {
|
|
let searchMask = W;
|
|
let regEx = new RegExp(searchMask, "ig");
|
|
let replaceMask = "***";
|
|
S = S.replace(regEx, replaceMask);
|
|
}
|
|
|
|
// At level one, we replace the full phrase with ***, at level two we return a ¶¶¶ string indicating to filter out
|
|
if (Player.ChatSettings.CensoredWordsLevel >= 1)
|
|
for (let W of WordList)
|
|
if ((W != "") && (W != " ") && !W.includes("*") && S.toUpperCase().includes(W.toUpperCase()))
|
|
return (Player.ChatSettings.CensoredWordsLevel >= 2) ? "¶¶¶" : "***";
|
|
|
|
// Returns the mashed string
|
|
return S;
|
|
|
|
}
|
|
|
|
/**
|
|
* Type guard which checks that a value is a simple object (i.e. a non-null object which is not an array)
|
|
* @param {unknown} value - The value to test
|
|
* @returns {value is Record<string, unknown>}
|
|
*/
|
|
function CommonIsObject(value) {
|
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
/**
|
|
* Deep-clones an object
|
|
* @todo JSON serialization will break things like functions, Sets and Maps.
|
|
* @template T
|
|
* @param {T} obj
|
|
* @param {null | ((this: any, key: string, value: any) => any)} replacer
|
|
* @param {null | ((this: any, key: string, value: any) => any)} reviver
|
|
* @returns {T}
|
|
*/
|
|
function CommonCloneDeep(obj, reviver=null, replacer=null) {
|
|
reviver ??= undefined;
|
|
replacer ??= undefined;
|
|
return JSON.parse(JSON.stringify(obj, replacer), reviver);
|
|
}
|
|
|
|
/**
|
|
* Type guard which checks that a value is a non-negative (i.e. positive or zero) integer
|
|
* @param {unknown} value - The value to test
|
|
* @returns {value is number}
|
|
* @see {@link CommonIsInteger}
|
|
*/
|
|
function CommonIsNonNegativeInteger(value) {
|
|
return CommonIsInteger(value, 0);
|
|
}
|
|
|
|
/**
|
|
* Type guard which checks that a value is an integer that, optionally, falls within the specified range
|
|
* @param {unknown} value - The value to test
|
|
* @param {number} min - The minimum allowed value
|
|
* @param {number} max - The maximum allowed value
|
|
* @returns {value is number}
|
|
*/
|
|
function CommonIsInteger(value, min=-Infinity, max=Infinity) {
|
|
return (
|
|
typeof value === "number"
|
|
&& Number.isInteger(value)
|
|
&& value >= min
|
|
&& value <= max
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Type guard which checks that a value is a finite number that, optionally, falls within the specified range
|
|
* @param {unknown} value - The value to test
|
|
* @param {number} min - The minimum allowed value
|
|
* @param {number} max - The maximum allowed value
|
|
* @returns {value is number}
|
|
*/
|
|
function CommonIsFinite(value, min=-Infinity, max=Infinity) {
|
|
return (
|
|
typeof value === "number"
|
|
&& Number.isFinite(value)
|
|
&& value >= min
|
|
&& value <= max
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A version of {@link Array.isArray} more friendly towards readonly arrays.
|
|
* @param {unknown} arg - The to-be validated object
|
|
* @returns {arg is readonly unknown[]} Whether the passed object is a (potentially readonly) array
|
|
*/
|
|
function CommonIsArray(arg) {
|
|
return Array.isArray(arg);
|
|
}
|
|
|
|
/**
|
|
* A {@link Object.keys} variant annotated to return respect literal key types
|
|
* @template {string} T
|
|
* @param {Partial<Record<T, unknown>>} record A record with string-based keys
|
|
* @returns {T[]} The keys in the passed record
|
|
*/
|
|
function CommonKeys(record) {
|
|
return /** @type {T[]} */(Object.keys(record));
|
|
}
|
|
|
|
/**
|
|
* A {@link Object.entries} variant annotated to return respect literal key types
|
|
* @template {string} KT
|
|
* @template VT
|
|
* @param {Partial<Record<KT, VT>>} record A record with string-based keys
|
|
* @returns {[KT, VT][]} The key/value pairs in the passed record
|
|
*/
|
|
function CommonEntries(record) {
|
|
return /** @type {[KT, VT][]} */(Object.entries(record));
|
|
}
|
|
|
|
/**
|
|
* A {@link Array.includes} version annotated to return a type guard.
|
|
* @template T
|
|
* @param {readonly T[]} array The array in question
|
|
* @param {unknown} searchElement The value to search for
|
|
* @param {number} [fromIndex] Zero-based index at which to start searching
|
|
* @returns {searchElement is T} Whether the array contains the passed element
|
|
*/
|
|
function CommonIncludes(array, searchElement, fromIndex) {
|
|
return array.includes(/** @type {T} */(searchElement), fromIndex);
|
|
}
|
|
|
|
/**
|
|
* A {@link Object.fromEntries} variant annotated to return respect literal key types.
|
|
* @note The returned record is typed as being non-{@link Partial}, an assumption that may not hold in practice
|
|
* @template {string} KT
|
|
* @template VT
|
|
* @param {Iterable<[key: KT, value: VT]>} iterable An iterable object that contains key-value entries for properties and methods
|
|
* @returns {Record<KT, VT>} A record created from the passed key/value pairs
|
|
*/
|
|
function CommonFromEntries(iterable) {
|
|
return /** @type {Record<KT, VT>} */(Object.fromEntries(iterable));
|
|
}
|
|
|
|
/**
|
|
* Automatically generate grid coordinates based on parameters.
|
|
* @param {number} nItems - The upper bound to the number of grid points; fewer can be returned if they do not all fit on the grid
|
|
* @param {CommonGenerateGridParameters} grid - The grid parameters
|
|
* @returns {RectTuple[]} - A list of grid coordinates with a length of `<= nItems`
|
|
* @see {@link CommonGenerateGrid}
|
|
*/
|
|
function CommonGenerateGridCoords(nItems, grid) {
|
|
// Calculate horizontal & vertical margins
|
|
const minMarginX = grid.minMarginX ?? 0;
|
|
const minMarginY = grid.minMarginY ?? 0;
|
|
const itemCountX = Math.floor(grid.width / grid.itemWidth);
|
|
const marginX = Math.max(minMarginX, grid.itemMarginX ?? (grid.width - (itemCountX * grid.itemWidth)) / (itemCountX - 1));
|
|
const itemCountY = Math.floor(grid.height / grid.itemHeight);
|
|
const marginY = Math.max(minMarginY, grid.itemMarginY ?? (grid.height - (itemCountY * grid.itemHeight)) / (itemCountY - 1));
|
|
const direction = grid.direction ?? "horizontal";
|
|
|
|
/** @type {RectTuple[]} */
|
|
const gridList = [];
|
|
let i = 0;
|
|
let x = grid.x;
|
|
let y = grid.y;
|
|
if (direction === "horizontal") {
|
|
for (i; i < nItems && y <= grid.y + grid.height; i++) {
|
|
gridList.push([x, y, grid.itemWidth, grid.itemHeight]);
|
|
x += grid.itemWidth + marginX;
|
|
if (x - grid.x >= grid.width) {
|
|
x = grid.x;
|
|
y += grid.itemHeight + marginY;
|
|
}
|
|
}
|
|
} else {
|
|
for (i; i < nItems && x <= grid.x + grid.width; i++) {
|
|
gridList.push([x, y, grid.itemWidth, grid.itemHeight]);
|
|
y += grid.itemHeight + marginY;
|
|
if (y - grid.y >= grid.height) {
|
|
y = grid.y;
|
|
x += grid.itemWidth + marginX;
|
|
}
|
|
}
|
|
}
|
|
return gridList;
|
|
}
|
|
|
|
/**
|
|
* Automatically generate a grid based on parameters and apply a callback to each grid point.
|
|
*
|
|
* This function takes a list of items, grid parameters, and a callback to manage
|
|
* creating a grid of them. It'll find the best value for margins between each cell,
|
|
* then will call the callback passing each item with its calculated coordinates in turn.
|
|
*
|
|
* Returning true from the callback to stop the iteration, useful for click handlers
|
|
* so you don't keep checking items after handling one.
|
|
*
|
|
* @template T
|
|
* @param {readonly T[]} items
|
|
* @param {number} offset
|
|
* @param {CommonGenerateGridParameters} grid
|
|
* @param {CommonGenerateGridCallback<T>} callback
|
|
* @returns {number} - The (offsetted) index for which the callback evaluated to `true`, or `-1` if no elements satisfy the testing
|
|
*/
|
|
function CommonGenerateGrid(items, offset, grid, callback) {
|
|
const gridCoords = CommonGenerateGridCoords(items.length - offset, grid);
|
|
const index = gridCoords.findIndex((coords, i) => callback(items[i + offset], ...coords));
|
|
return index === -1 ? -1 : index - offset;
|
|
}
|
|
|
|
/**
|
|
* Create a copy of the passed record with all specified keys removed
|
|
* @template {keyof RecordType} KeyType
|
|
* @template {{}} RecordType
|
|
* @param {RecordType} object - The to-be copied record
|
|
* @param {Iterable<KeyType>} keys - The to-be removed keys from the record
|
|
* @returns {Omit<RecordType, KeyType>}
|
|
*/
|
|
function CommonOmit(object, keys) {
|
|
const ret = { ...object };
|
|
for (const k of keys) {
|
|
delete ret[k];
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
/**
|
|
* Create a copy of the passed record with all specified keys removed
|
|
* @template {keyof RecordType} KeyType
|
|
* @template {{}} RecordType
|
|
* @param {RecordType} object - The to-be copied record
|
|
* @param {Iterable<KeyType>} keys - The to-be removed keys from the record
|
|
* @returns {Pick<RecordType, KeyType>}
|
|
*/
|
|
function CommonPick(object, keys) {
|
|
const ret = /** @type {Pick<RecordType, KeyType>} */({});
|
|
for (const k of keys) {
|
|
ret[k] = object[k];
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
/**
|
|
* Iterate through the passed iterable and yield index/value pairs.
|
|
* @template T
|
|
* @param {Iterable<T>} iterable - The to-be iterated iterable
|
|
* @param {number} start - The starting index
|
|
* @param {number} step - The step size in which the index is incremented
|
|
* @returns {Generator<[index: number, value: T], void>}
|
|
*/
|
|
function* CommonEnumerate(iterable, start=0, step=1) {
|
|
let i = start;
|
|
for (const item of iterable) {
|
|
yield [i, item];
|
|
i += step;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return a value clamped to a minimum and maximum
|
|
* @param {number} value
|
|
* @param {number} min
|
|
* @param {number} max
|
|
* @returns {number}
|
|
*/
|
|
function CommonClamp(value, min, max) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
/**
|
|
* Return value % divisor but properly handling negative values
|
|
*
|
|
* Useful if you have a set of keys and want to move an index within its bounds with proper wrap-around.
|
|
*
|
|
* @param {number} value
|
|
* @param {number} divisor
|
|
*/
|
|
function CommonModulo(value, divisor) {
|
|
return Math.abs((divisor + value) % divisor);
|
|
}
|
|
|
|
/**
|
|
* Returns TRUE if the URL is valid, is from http or https or screens/ or backgrounds/ and has the required extension
|
|
* @param {string} TestURL - The URL to test
|
|
* @param {readonly string[]} Extension - An array containing the valid extensions
|
|
* @returns {boolean}
|
|
*/
|
|
function CommonURLHasExtension(TestURL, Extension) {
|
|
if ((TestURL == null) || (typeof TestURL !== "string") || (Extension == null) || !CommonIsArray(Extension)) return false;
|
|
try {
|
|
const Url = new URL(TestURL);
|
|
if ((Url.protocol !== 'http:') && (Url.protocol !== 'https:')) return false;
|
|
let FormatUrl = Url.pathname.trim().toLowerCase();
|
|
for (let Ext of Extension)
|
|
if (FormatUrl.endsWith(Ext))
|
|
return true;
|
|
} catch (err) {
|
|
let FormatUrl = TestURL.trim().toLowerCase();
|
|
if ((FormatUrl.startsWith("screens/")) || (FormatUrl.startsWith("backgrounds/")))
|
|
for (let Ext of Extension)
|
|
if (FormatUrl.endsWith(Ext))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Return whether two records are equivalent for all fields as returned by {@link Object.keys}.
|
|
* @note does *not* support the comparison of nested structures.
|
|
* @template T
|
|
* @param {T} rec1
|
|
* @param {unknown} rec2
|
|
* @returns {rec2 is T}
|
|
*/
|
|
function CommonObjectEqual(rec1, rec2) {
|
|
if (!CommonIsObject(rec1) || !CommonIsObject(rec2)) {
|
|
return false;
|
|
}
|
|
|
|
const entries1 = Object.entries(rec1);
|
|
const keys2 = Object.keys(rec2);
|
|
if (entries1.length !== keys2.length) {
|
|
return false;
|
|
}
|
|
return entries1.every(([k, v]) => rec2[k] === v);
|
|
}
|
|
|
|
/**
|
|
* Return if the key/value pairs of `subRec` form a subset of `superRec`
|
|
* @template T
|
|
* @param {unknown} subRec
|
|
* @param {T} superRec
|
|
* @returns {subRec is Partial<T>}
|
|
*/
|
|
function CommonObjectIsSubset(subRec, superRec) {
|
|
if (!CommonIsObject(subRec) || !CommonIsObject(superRec)) {
|
|
return false;
|
|
}
|
|
const entries = Object.entries(subRec);
|
|
return entries.every(([k, v]) => superRec[k] === v);
|
|
}
|
|
|
|
/**
|
|
* Returns the object with keys and values reversed
|
|
* @param {object} obj
|
|
*/
|
|
function CommonObjectFlip(obj) {
|
|
return Object.keys(obj).reduce((ret, key) => {
|
|
ret[obj[key]] = key;
|
|
return ret;
|
|
}, {});
|
|
}
|
|
|
|
/**
|
|
* Parse the passed stringified JSON data and catch any exceptions.
|
|
* Exceptions will cause the function to return `undefined`.
|
|
* @param {string} data
|
|
* @returns {any}
|
|
* @see {@link JSON.parse}
|
|
*/
|
|
function CommonJSONParse(data) {
|
|
try {
|
|
return JSON.parse(data);
|
|
} catch (error) {
|
|
console.error(error, data);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Translates the current event into movement directions
|
|
*
|
|
* This returns a layout independent u/d/l/r string
|
|
*
|
|
* @param {KeyboardEvent} event
|
|
* @returns {"u"|"d"|"l"|"r"|undefined}
|
|
*/
|
|
function CommonKeyMove(event, allowArrowKeys = true) {
|
|
if (CommonKey.GetModifiers(event)) {
|
|
return undefined;
|
|
} else if (event.code === "KeyW" || allowArrowKeys && event.code === "ArrowUp") {
|
|
return "u";
|
|
} else if (event.code === "KeyA" || allowArrowKeys && event.code === "ArrowLeft") {
|
|
return "l";
|
|
} else if (event.code === "KeyS" || allowArrowKeys && event.code === "ArrowDown") {
|
|
return "d";
|
|
} else if (event.code === "KeyD" || allowArrowKeys && event.code === "ArrowRight") {
|
|
return "r";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* A {@link Set.has}/{@link Map.has} version annotated to return a type guard.
|
|
* @template T
|
|
* @param {{ has: (key: T) => boolean }} obj The set or map in question
|
|
* @param {unknown} key The key to search for
|
|
* @returns {key is T} Whether the object contains the passed key
|
|
*/
|
|
function CommonHas(obj, key) {
|
|
return obj.has(/** @type {T} */(key));
|
|
}
|
|
|
|
/**
|
|
* Defines a property with the given name and the passed setter and getter
|
|
* @example
|
|
* CommonDeprecate(
|
|
* "OldFunc",
|
|
* function NewFunc (a, b) { return a + b; },
|
|
* );
|
|
* @template {any} T
|
|
* @param {string} propertyName - The name for the new property
|
|
* @param {()=>T} getter - The getter function for the property
|
|
* @param {(T)=>void} setter - The setter function for the property
|
|
*/
|
|
function CommonProperty(propertyName, getter = undefined, setter = undefined) {
|
|
if(window[propertyName] !== undefined)
|
|
{
|
|
throw new Error(`The name ${setter} already exists.`);
|
|
}
|
|
|
|
getter ??= function(){ throw Error(`Property "${propertyName}" has no getter defined`); };
|
|
setter ??= function(){ throw Error(`Property "${propertyName}" has no setter defined`); };
|
|
|
|
Object.defineProperty(window, propertyName, {
|
|
enumerable: true,
|
|
configurable: true,
|
|
get: function() {
|
|
return getter();
|
|
},
|
|
set: function(val) { setter(val); },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Assign a function to the passed namespace, creating a deprecated and non-deprecated symbol.
|
|
* Both symbols will are in fact get/set wrappers around the same object, enforcing that `namespace[oldName] === namespace[callback.name]` *always* holds.
|
|
* @example
|
|
* CommonDeprecateFunction(
|
|
* "OldFunc",
|
|
* function NewFunc (a, b) { return a + b; },
|
|
* );
|
|
* @template {(...any) => any} T
|
|
* @param {string} oldName - The old (deprecated) name of the symbol
|
|
* @param {T} callback - The function with its new name
|
|
* @param {Record<string, any>} namespace - The namespace wherein the new and old names will be stored
|
|
*/
|
|
function CommonDeprecateFunction(oldName, callback, namespace=globalThis) {
|
|
const newName = callback.name;
|
|
if (!newName) {
|
|
throw new Error("Passed function lacks a name");
|
|
} else if (newName === oldName) {
|
|
throw new Error(`Callback name (${newName}) and oldName (${oldName}) must not be equivalent`);
|
|
}
|
|
|
|
const privateName = `_${newName}`;
|
|
namespace[privateName] = callback;
|
|
|
|
window[newName].get = function() { return namespace[privateName]; };
|
|
window[newName].set = function(val) { namespace[privateName] = val; };
|
|
|
|
Object.defineProperty(window, oldName, {
|
|
enumerable: true,
|
|
configurable: true,
|
|
get: function() {
|
|
console.warn(`"${oldName}" is deprecated, use "${newName}" instead`);
|
|
return namespace[privateName];
|
|
},
|
|
set: function(val) { namespace[privateName] = val; },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Capitalize the first character of the passed string.
|
|
* @template {string} T
|
|
* @param {T} string
|
|
* @returns {Capitalize<T>}
|
|
*/
|
|
function CommonCapitalize(string) {
|
|
return /** @type {Capitalize<T>} */(string ? `${string[0].toUpperCase()}${string.slice(1)}` : "");
|
|
}
|
|
|
|
/**
|
|
* Construct an object for holding arbitrary (user-specified) values with a mechanism to reset them to a default
|
|
* @template T1
|
|
* @template [T2={}]
|
|
* @param {T1} defaults - Default values that should be restored upon reset
|
|
* @param {null | T2} extraVars - Extra values that should *not* affected by resets
|
|
* @param {null | { replacer?: (this: any, key: string, value: any) => any, reviver?: (this: any, key: string, value: any) => any }} resetCallbacks - Extra callbacks for affecting the deep cloning process on reset.
|
|
* Generally only relevant if one of the fields contains non-JSON-serializible data.
|
|
* @returns {VariableContainer<T1, T2>} - The newly created object
|
|
*/
|
|
function CommonVariableContainer(defaults, extraVars=null, resetCallbacks=null) {
|
|
// @ts-ignore
|
|
extraVars ??= {};
|
|
const ret = Object.assign(
|
|
extraVars,
|
|
defaults,
|
|
{
|
|
Defaults: defaults,
|
|
Reset: () => {
|
|
Object.assign(ret, CommonCloneDeep(ret.Defaults, resetCallbacks?.reviver, resetCallbacks?.replacer));
|
|
},
|
|
},
|
|
);
|
|
ret.Reset();
|
|
return ret;
|
|
}
|
|
|
|
/**
|
|
* Namespace with helper functions for validating key presses.
|
|
* @namespace
|
|
*/
|
|
var CommonKey = /** @type {const} */({
|
|
/** Bitmask component for the {@link KeyboardEvent.altKey} modifier */
|
|
ALT: 1,
|
|
/** Bitmask component for the {@link KeyboardEvent.ctrlKey} modifier */
|
|
CTRL: 2,
|
|
/** Bitmask component for the {@link KeyboardEvent.metaKey} modifier */
|
|
META: 4,
|
|
/** Bitmask component for the {@link KeyboardEvent.shiftKey} modifier */
|
|
SHIFT: 8,
|
|
|
|
/**
|
|
* Check whether the expected key and all its modifiers were pressed
|
|
* @example
|
|
* // `Enter` without modifiers
|
|
* CommonKey(event, "Enter");
|
|
* // `Enter` with the `Ctrl` and `Shift` modifiers
|
|
* CommonKey(event, "Enter", CommonKey.SHIFT | CommonKey.CTRL);
|
|
* @param {KeyboardEvent} event - The keyboard event in question
|
|
* @param {string} key - The expected key (see {@link KeyboardEvent.key})
|
|
* @param {null | number} modifiers - An optional bit mask of all expected modifiers (see examples)
|
|
* @returns {boolean} - Whether the expected key and all its modifiers were pressed
|
|
*/
|
|
IsPressed: function IsPressed(event, key, modifiers=null) {
|
|
modifiers ??= 0;
|
|
return event.key === key && modifiers === CommonKey.GetModifiers(event);
|
|
},
|
|
|
|
/**
|
|
* Return a bit mask with all modifiers in the passed keyboard event
|
|
* @param {KeyboardEvent} event - The keyboard event in question
|
|
* @returns {number} - The bitmask of keyboard modifiers
|
|
*/
|
|
GetModifiers: function GetModifiers(event) {
|
|
return [
|
|
event.altKey ? CommonKey.ALT : undefined,
|
|
event.ctrlKey ? CommonKey.CTRL : undefined,
|
|
event.metaKey ? CommonKey.META : undefined,
|
|
event.shiftKey ? CommonKey.SHIFT : undefined,
|
|
].filter(Boolean).reduce((sum, bit) => sum | bit, 0);
|
|
},
|
|
|
|
/**
|
|
* A {@link ScreenFunctions.keydown} helper function for implementing the navigation key handling of scrollable elements.
|
|
* @param {HTMLElement} scrollableElem - The scrollable element
|
|
* @param {KeyboardEvent} event - The keydown event
|
|
* @param {(scrollableElem: HTMLElement) => number} getArrowScrollDistance - A callback for computing the (absolute) scroll distance for up/down arrow key presses
|
|
* @returns {boolean} - Whether a navigation key was (successfuly pressed)
|
|
*/
|
|
NavigationKeyDown: function NavigationKeyDown(scrollableElem, event, getArrowScrollDistance) {
|
|
// Abort if an element has been (explicitly)
|
|
const noFocus = (
|
|
document.activeElement === null
|
|
|| document.activeElement === document.body
|
|
|| document.activeElement.id === "MainCanvas"
|
|
);
|
|
|
|
if (!scrollableElem || !noFocus || !event || CommonKey.GetModifiers(event)) {
|
|
return false;
|
|
}
|
|
|
|
const sign = (event.key === "PageUp" || event.key === "ArrowUp") ? -1 : 1;
|
|
switch (event.key) {
|
|
case "Home":
|
|
scrollableElem.scrollTop = 0;
|
|
return true;
|
|
case "End":
|
|
scrollableElem.scrollTop = scrollableElem.scrollHeight;
|
|
return true;
|
|
case "PageUp":
|
|
case "PageDown":
|
|
scrollableElem.scrollBy({ top: sign * scrollableElem.clientHeight, behavior: "instant" });
|
|
return true;
|
|
case "ArrowUp":
|
|
case "ArrowDown":
|
|
scrollableElem.scrollBy({ top: sign * getArrowScrollDistance(scrollableElem), behavior: "instant" });
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
},
|
|
});
|
|
|
|
/**
|
|
*
|
|
* @param {RoomName} screen
|
|
*/
|
|
function CommonScreenName(screen) {
|
|
const cache = TextAllScreenCache.get(`Screens/Room/MainHall/Text_MainHall.csv`);
|
|
return cache.get(screen);
|
|
}
|