This commit is contained in:
silicons
2021-04-25 16:18:41 -07:00
parent d017d7df66
commit 1dbd86499c
6 changed files with 0 additions and 1009 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,271 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/**
* Converts a given collection to an array.
*
* - Arrays are returned unmodified;
* - If object was provided, keys will be discarded;
* - Everything else will result in an empty array.
*
* @returns {any[]}
*/
export const toArray = collection => {
if (Array.isArray(collection)) {
return collection;
}
if (typeof collection === 'object') {
const hasOwnProperty = Object.prototype.hasOwnProperty;
const result = [];
for (let i in collection) {
if (hasOwnProperty.call(collection, i)) {
result.push(collection[i]);
}
}
return result;
}
return [];
};
/**
* Converts a given object to an array, and appends a key to every
* object inside of that array.
*
* Example input (object):
* ```
* {
* 'Foo': { info: 'Hello world!' },
* 'Bar': { info: 'Hello world!' },
* }
* ```
*
* Example output (array):
* ```
* [
* { key: 'Foo', info: 'Hello world!' },
* { key: 'Bar', info: 'Hello world!' },
* ]
* ```
*
* @template T
* @param {{ [key: string]: T }} obj Object, or in DM terms, an assoc array
* @param {string} keyProp Property, to which key will be assigned
* @returns {T[]} Array of keyed objects
*/
export const toKeyedArray = (obj, keyProp = 'key') => {
return map((item, key) => ({
[keyProp]: key,
...item,
}))(obj);
};
/**
* Iterates over elements of collection, returning an array of all elements
* iteratee returns truthy for. The predicate is invoked with three
* arguments: (value, index|key, collection).
*
* If collection is 'null' or 'undefined', it will be returned "as is"
* without emitting any errors (which can be useful in some cases).
*
* @returns {any[]}
*/
export const filter = iterateeFn => collection => {
if (collection === null || collection === undefined) {
return collection;
}
if (Array.isArray(collection)) {
const result = [];
for (let i = 0; i < collection.length; i++) {
const item = collection[i];
if (iterateeFn(item, i, collection)) {
result.push(item);
}
}
return result;
}
throw new Error(`filter() can't iterate on type ${typeof collection}`);
};
/**
* Creates an array of values by running each element in collection
* thru an iteratee function. The iteratee is invoked with three
* arguments: (value, index|key, collection).
*
* If collection is 'null' or 'undefined', it will be returned "as is"
* without emitting any errors (which can be useful in some cases).
*
* @returns {any[]}
*/
export const map = iterateeFn => collection => {
if (collection === null || collection === undefined) {
return collection;
}
if (Array.isArray(collection)) {
const result = [];
for (let i = 0; i < collection.length; i++) {
result.push(iterateeFn(collection[i], i, collection));
}
return result;
}
if (typeof collection === 'object') {
const hasOwnProperty = Object.prototype.hasOwnProperty;
const result = [];
for (let i in collection) {
if (hasOwnProperty.call(collection, i)) {
result.push(iterateeFn(collection[i], i, collection));
}
}
return result;
}
throw new Error(`map() can't iterate on type ${typeof collection}`);
};
const COMPARATOR = (objA, objB) => {
const criteriaA = objA.criteria;
const criteriaB = objB.criteria;
const length = criteriaA.length;
for (let i = 0; i < length; i++) {
const a = criteriaA[i];
const b = criteriaB[i];
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
}
return 0;
};
/**
* Creates an array of elements, sorted in ascending order by the results
* of running each element in a collection thru each iteratee.
*
* Iteratees are called with one argument (value).
*
* @returns {any[]}
*/
export const sortBy = (...iterateeFns) => array => {
if (!Array.isArray(array)) {
return array;
}
let length = array.length;
// Iterate over the array to collect criteria to sort it by
let mappedArray = [];
for (let i = 0; i < length; i++) {
const value = array[i];
mappedArray.push({
criteria: iterateeFns.map(fn => fn(value)),
value,
});
}
// Sort criteria using the base comparator
mappedArray.sort(COMPARATOR);
// Unwrap values
while (length--) {
mappedArray[length] = mappedArray[length].value;
}
return mappedArray;
};
/**
* A fast implementation of reduce.
*/
export const reduce = (reducerFn, initialValue) => array => {
const length = array.length;
let i;
let result;
if (initialValue === undefined) {
i = 1;
result = array[0];
}
else {
i = 0;
result = initialValue;
}
for (; i < length; i++) {
result = reducerFn(result, array[i], i, array);
}
return result;
};
/**
* Creates a duplicate-free version of an array, using SameValueZero for
* equality comparisons, in which only the first occurrence of each element
* is kept. The order of result values is determined by the order they occur
* in the array.
*
* It accepts iteratee which is invoked for each element in array to generate
* the criterion by which uniqueness is computed. The order of result values
* is determined by the order they occur in the array. The iteratee is
* invoked with one argument: value.
*/
export const uniqBy = iterateeFn => array => {
const { length } = array;
const result = [];
const seen = iterateeFn ? [] : result;
let index = -1;
outer:
while (++index < length) {
let value = array[index];
const computed = iterateeFn ? iterateeFn(value) : value;
value = value !== 0 ? value : 0;
if (computed === computed) {
let seenIndex = seen.length;
while (seenIndex--) {
if (seen[seenIndex] === computed) {
continue outer;
}
}
if (iterateeFn) {
seen.push(computed);
}
result.push(value);
}
else if (!seen.includes(computed)) {
if (seen !== result) {
seen.push(computed);
}
result.push(value);
}
}
return result;
};
/**
* Creates an array of grouped elements, the first of which contains
* the first elements of the given arrays, the second of which contains
* the second elements of the given arrays, and so on.
*
* @returns {any[]}
*/
export const zip = (...arrays) => {
if (arrays.length === 0) {
return;
}
const numArrays = arrays.length;
const numValues = arrays[0].length;
const result = [];
for (let valueIndex = 0; valueIndex < numValues; valueIndex++) {
const entry = [];
for (let arrayIndex = 0; arrayIndex < numArrays; arrayIndex++) {
entry.push(arrays[arrayIndex][valueIndex]);
}
result.push(entry);
}
return result;
};
/**
* This method is like "zip" except that it accepts iteratee to
* specify how grouped values should be combined. The iteratee is
* invoked with the elements of each group.
*
* @returns {any[]}
*/
export const zipWith = iterateeFn => (...arrays) => {
return map(values => iterateeFn(...values))(zip(...arrays));
};

View File

@@ -1,100 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/**
* Limits a number to the range between 'min' and 'max'.
*/
export const clamp = (value, min, max) => {
return value < min ? min : value > max ? max : value;
};
/**
* Limits a number between 0 and 1.
*/
export const clamp01 = value => {
return value < 0 ? 0 : value > 1 ? 1 : value;
};
/**
* Scales a number to fit into the range between min and max.
*/
export const scale = (value, min, max) => {
return (value - min) / (max - min);
};
/**
* Robust number rounding.
*
* Adapted from Locutus, see: http://locutus.io/php/math/round/
*
* @param {number} value
* @param {number} precision
* @return {number}
*/
export const round = (value, precision) => {
if (!value || isNaN(value)) {
return value;
}
// helper variables
let m, f, isHalf, sgn;
// making sure precision is integer
precision |= 0;
m = Math.pow(10, precision);
value *= m;
// sign of the number
sgn = (value > 0) | -(value < 0);
// isHalf = value % 1 === 0.5 * sgn;
isHalf = Math.abs(value % 1) >= 0.4999999999854481;
f = Math.floor(value);
if (isHalf) {
// rounds .5 away from zero
value = f + (sgn > 0);
}
return (isHalf ? value : Math.round(value)) / m;
};
/**
* Returns a string representing a number in fixed point notation.
*/
export const toFixed = (value, fractionDigits = 0) => {
return Number(value).toFixed(Math.max(fractionDigits, 0));
};
/**
* Checks whether a value is within the provided range.
*
* Range is an array of two numbers, for example: [0, 15].
*/
export const inRange = (value, range) => {
return range
&& value >= range[0]
&& value <= range[1];
};
/**
* Walks over the object with ranges, comparing value against every range,
* and returns the key of the first matching range.
*
* Range is an array of two numbers, for example: [0, 15].
*/
export const keyOfMatchingRange = (value, ranges) => {
for (let rangeName of Object.keys(ranges)) {
const range = ranges[rangeName];
if (inRange(value, range)) {
return rangeName;
}
}
};
/**
* Get number of digits following the decimal point in a number
*/
export const numberOfDecimalDigits = value => {
if (Math.floor(value) !== value) {
return value.toString().split('.')[1].length || 0;
}
return 0;
};

View File

@@ -1,383 +0,0 @@
/**
* This file provides a clear separation layer between backend updates
* and what state our React app sees.
*
* Sometimes backend can response without a "data" field, but our final
* state will still contain previous "data" because we are merging
* the response with already existing state.
*
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { perf } from 'common/perf';
import { setupDrag } from './drag';
import { focusMap } from './focus';
import { createLogger } from './logging';
import { resumeRenderer, suspendRenderer } from './renderer';
const logger = createLogger('backend');
export const backendUpdate = state => ({
type: 'backend/update',
payload: state,
});
export const backendSetSharedState = (key, nextState) => ({
type: 'backend/setSharedState',
payload: { key, nextState },
});
export const backendSuspendStart = () => ({
type: 'backend/suspendStart',
});
export const backendSuspendSuccess = () => ({
type: 'backend/suspendSuccess',
payload: {
timestamp: Date.now(),
},
});
const initialState = {
config: {},
data: {},
shared: {},
// Start as suspended
suspended: Date.now(),
suspending: false,
};
export const backendReducer = (state = initialState, action) => {
const { type, payload } = action;
if (type === 'backend/update') {
// Merge config
const config = {
...state.config,
...payload.config,
};
// Merge data
const data = {
...state.data,
...payload.static_data,
...payload.data,
};
// Merge shared states
const shared = { ...state.shared };
if (payload.shared) {
for (let key of Object.keys(payload.shared)) {
const value = payload.shared[key];
if (value === '') {
shared[key] = undefined;
}
else {
shared[key] = JSON.parse(value);
}
}
}
// Return new state
return {
...state,
config,
data,
shared,
suspended: false,
};
}
if (type === 'backend/setSharedState') {
const { key, nextState } = payload;
return {
...state,
shared: {
...state.shared,
[key]: nextState,
},
};
}
if (type === 'backend/suspendStart') {
return {
...state,
suspending: true,
};
}
if (type === 'backend/suspendSuccess') {
const { timestamp } = payload;
return {
...state,
data: {},
shared: {},
config: {
...state.config,
title: '',
status: 1,
},
suspending: false,
suspended: timestamp,
};
}
return state;
};
export const backendMiddleware = store => {
let fancyState;
let suspendInterval;
return next => action => {
const { suspended } = selectBackend(store.getState());
const { type, payload } = action;
if (type === 'update') {
store.dispatch(backendUpdate(payload));
return;
}
if (type === 'suspend') {
store.dispatch(backendSuspendSuccess());
return;
}
if (type === 'ping') {
sendMessage({
type: 'pingReply',
});
return;
}
if (type === 'backend/suspendStart' && !suspendInterval) {
logger.log(`suspending (${window.__windowId__})`);
// Keep sending suspend messages until it succeeds.
// It may fail multiple times due to topic rate limiting.
const suspendFn = () => sendMessage({
type: 'suspend',
});
suspendFn();
suspendInterval = setInterval(suspendFn, 2000);
}
if (type === 'backend/suspendSuccess') {
suspendRenderer();
clearInterval(suspendInterval);
suspendInterval = undefined;
Byond.winset(window.__windowId__, {
'is-visible': false,
});
setImmediate(() => focusMap());
}
if (type === 'backend/update') {
const fancy = payload.config?.window?.fancy;
// Initialize fancy state
if (fancyState === undefined) {
fancyState = fancy;
}
// React to changes in fancy
else if (fancyState !== fancy) {
logger.log('changing fancy mode to', fancy);
fancyState = fancy;
Byond.winset(window.__windowId__, {
titlebar: !fancy,
'can-resize': !fancy,
});
}
}
// Resume on incoming update
if (type === 'backend/update' && suspended) {
// Show the payload
logger.log('backend/update', payload);
// Signal renderer that we have resumed
resumeRenderer();
// Setup drag
setupDrag();
// We schedule this for the next tick here because resizing and unhiding
// during the same tick will flash with a white background.
setImmediate(() => {
perf.mark('resume/start');
// Doublecheck if we are not re-suspended.
const { suspended } = selectBackend(store.getState());
if (suspended) {
return;
}
Byond.winset(window.__windowId__, {
'is-visible': true,
});
perf.mark('resume/finish');
if (process.env.NODE_ENV !== 'production') {
logger.log('visible in',
perf.measure('render/finish', 'resume/finish'));
}
});
}
return next(action);
};
};
/**
* Sends a message to /datum/tgui_window.
*/
export const sendMessage = (message = {}) => {
const { payload, ...rest } = message;
const data = {
// Message identifying header
tgui: 1,
window_id: window.__windowId__,
// Message body
...rest,
};
// JSON-encode the payload
if (payload !== null && payload !== undefined) {
data.payload = JSON.stringify(payload);
}
Byond.topic(data);
};
/**
* Sends an action to `ui_act` on `src_object` that this tgui window
* is associated with.
*/
export const sendAct = (action, payload = {}) => {
// Validate that payload is an object
const isObject = typeof payload === 'object'
&& payload !== null
&& !Array.isArray(payload);
if (!isObject) {
logger.error(`Payload for act() must be an object, got this:`, payload);
return;
}
sendMessage({
type: 'act/' + action,
payload,
});
};
/**
* @typedef BackendState
* @type {{
* config: {
* title: string,
* status: number,
* interface: string,
* window: {
* key: string,
* size: [number, number],
* fancy: boolean,
* locked: boolean,
* },
* client: {
* ckey: string,
* address: string,
* computer_id: string,
* },
* user: {
* name: string,
* observer: number,
* },
* },
* data: any,
* shared: any,
* suspending: boolean,
* suspended: boolean,
* }}
*/
/**
* Selects a backend-related slice of Redux state
*
* @return {BackendState}
*/
export const selectBackend = state => state.backend || {};
/**
* A React hook (sort of) for getting tgui state and related functions.
*
* This is supposed to be replaced with a real React Hook, which can only
* be used in functional components.
*
* @return {BackendState & {
* act: sendAct,
* }}
*/
export const useBackend = context => {
const { store } = context;
const state = selectBackend(store.getState());
return {
...state,
act: sendAct,
};
};
/**
* Allocates state on Redux store without sharing it with other clients.
*
* Use it when you want to have a stateful variable in your component
* that persists between renders, but will be forgotten after you close
* the UI.
*
* It is a lot more performant than `setSharedState`.
*
* @param {any} context React context.
* @param {string} key Key which uniquely identifies this state in Redux store.
* @param {any} initialState Initializes your global variable with this value.
*/
export const useLocalState = (context, key, initialState) => {
const { store } = context;
const state = selectBackend(store.getState());
const sharedStates = state.shared ?? {};
const sharedState = (key in sharedStates)
? sharedStates[key]
: initialState;
return [
sharedState,
nextState => {
store.dispatch(backendSetSharedState(key, (
typeof nextState === 'function'
? nextState(sharedState)
: nextState
)));
},
];
};
/**
* Allocates state on Redux store, and **shares** it with other clients
* in the game.
*
* Use it when you want to have a stateful variable in your component
* that persists not only between renders, but also gets pushed to other
* clients that observe this UI.
*
* This makes creation of observable s
*
* @param {any} context React context.
* @param {string} key Key which uniquely identifies this state in Redux store.
* @param {any} initialState Initializes your global variable with this value.
*/
export const useSharedState = (context, key, initialState) => {
const { store } = context;
const state = selectBackend(store.getState());
const sharedStates = state.shared ?? {};
const sharedState = (key in sharedStates)
? sharedStates[key]
: initialState;
return [
sharedState,
nextState => {
sendMessage({
type: 'setSharedState',
key,
value: JSON.stringify(
typeof nextState === 'function'
? nextState(sharedState)
: nextState
) || '',
});
},
];
};

View File

@@ -1,171 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { KEY_CTRL, KEY_ENTER, KEY_ESCAPE, KEY_F, KEY_F5, KEY_R, KEY_SHIFT, KEY_SPACE, KEY_TAB } from 'common/keycodes';
import { globalEvents } from './events';
import { createLogger } from './logging';
const logger = createLogger('hotkeys');
// BYOND macros, in `key: command` format.
const byondMacros = {};
// Array of acquired keys, which will not be sent to BYOND.
const hotKeysAcquired = [
// Default set of acquired keys
KEY_ESCAPE,
KEY_ENTER,
KEY_SPACE,
KEY_TAB,
KEY_CTRL,
KEY_SHIFT,
KEY_F5,
];
// State of passed-through keys.
const keyState = {};
/**
* Converts a browser keycode to BYOND keycode.
*/
const keyCodeToByond = keyCode => {
if (keyCode === 16) return 'Shift';
if (keyCode === 17) return 'Ctrl';
if (keyCode === 18) return 'Alt';
if (keyCode === 33) return 'Northeast';
if (keyCode === 34) return 'Southeast';
if (keyCode === 35) return 'Southwest';
if (keyCode === 36) return 'Northwest';
if (keyCode === 37) return 'West';
if (keyCode === 38) return 'North';
if (keyCode === 39) return 'East';
if (keyCode === 40) return 'South';
if (keyCode === 45) return 'Insert';
if (keyCode === 46) return 'Delete';
if (keyCode >= 48 && keyCode <= 57 || keyCode >= 65 && keyCode <= 90) {
return String.fromCharCode(keyCode);
}
if (keyCode >= 96 && keyCode <= 105) {
return 'Numpad' + (keyCode - 96);
}
if (keyCode >= 112 && keyCode <= 123) {
return 'F' + (keyCode - 111);
}
if (keyCode === 188) return ',';
if (keyCode === 189) return '-';
if (keyCode === 190) return '.';
};
/**
* Keyboard passthrough logic. This allows you to keep doing things
* in game while the browser window is focused.
*/
const handlePassthrough = key => {
// In addition to F5, support reloading with Ctrl+R and Ctrl+F5
if (key.ctrl && (key.code === KEY_F5 || key.code === KEY_R)) {
location.reload();
return;
}
// Prevent passthrough on Ctrl+F
if (key.ctrl && key.code === KEY_F) {
return;
}
// NOTE: Alt modifier is pretty bad and sticky in IE11.
if (key.event.defaultPrevented
|| key.isModifierKey()
|| hotKeysAcquired.includes(key.code)) {
return;
}
const byondKeyCode = keyCodeToByond(key.code);
if (!byondKeyCode) {
return;
}
// Macro
const macro = byondMacros[byondKeyCode];
if (macro) {
logger.debug('macro', macro);
return Byond.command(macro);
}
// KeyDown
if (key.isDown() && !keyState[byondKeyCode]) {
keyState[byondKeyCode] = true;
const command = `KeyDown "${byondKeyCode}"`;
logger.debug(command);
return Byond.command(command);
}
// KeyUp
if (key.isUp() && keyState[byondKeyCode]) {
keyState[byondKeyCode] = false;
const command = `KeyUp "${byondKeyCode}"`;
logger.debug(command);
return Byond.command(command);
}
};
/**
* Acquires a lock on the hotkey, which prevents it from being
* passed through to BYOND.
*/
export const acquireHotKey = keyCode => {
hotKeysAcquired.push(keyCode);
};
/**
* Makes the hotkey available to BYOND again.
*/
export const releaseHotKey = keyCode => {
const index = hotKeysAcquired.indexOf(keyCode);
if (index >= 0) {
hotKeysAcquired.splice(index, 1);
}
};
export const releaseHeldKeys = () => {
for (let byondKeyCode of Object.keys(keyState)) {
if (keyState[byondKeyCode]) {
keyState[byondKeyCode] = false;
logger.log(`releasing key "${byondKeyCode}"`);
Byond.command(`KeyUp "${byondKeyCode}"`);
}
}
};
export const setupHotKeys = () => {
// Read macros
Byond.winget('default.*').then(data => {
// Group each macro by ref
const groupedByRef = {};
for (let key of Object.keys(data)) {
const keyPath = key.split('.');
const ref = keyPath[1];
const prop = keyPath[2];
if (ref && prop) {
if (!groupedByRef[ref]) {
groupedByRef[ref] = {};
}
groupedByRef[ref][prop] = data[key];
}
}
// Insert macros
const escapedQuotRegex = /\\"/g;
const unescape = str => str
.substring(1, str.length - 1)
.replace(escapedQuotRegex, '"');
for (let ref of Object.keys(groupedByRef)) {
const macro = groupedByRef[ref];
const byondKeyName = unescape(macro.name);
byondMacros[byondKeyName] = unescape(macro.command);
}
logger.debug('loaded macros', byondMacros);
});
// Setup event handlers
globalEvents.on('window-blur', () => {
releaseHeldKeys();
});
globalEvents.on('key', key => {
handlePassthrough(key);
});
};