Bundle Copy

This commit is contained in:
ItsSelis
2023-05-23 17:43:01 +02:00
parent 8aad48f508
commit 3da68ee1b6
420 changed files with 47669 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
const EXCLUDED_PATTERNS = [/v4shim/i];
const loadedMappings = {};
export const resolveAsset = (name) => loadedMappings[name] || name;
export const assetMiddleware = (store) => (next) => (action) => {
const { type, payload } = action;
if (type === 'asset/stylesheet') {
Byond.loadCss(payload);
return;
}
if (type === 'asset/mappings') {
for (let name of Object.keys(payload)) {
// Skip anything that matches excluded patterns
if (EXCLUDED_PATTERNS.some((regex) => regex.test(name))) {
continue;
}
const url = payload[name];
const ext = name.split('.').pop();
loadedMappings[name] = url;
if (ext === 'css') {
Byond.loadCss(url);
}
if (ext === 'js') {
Byond.loadJs(url);
}
}
return;
}
next(action);
};

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 425 200" opacity=".33">
<path d="m 178.00399,0.03869 -71.20393,0 a 6.7613422,6.0255495 0 0 0 -6.76134,6.02555 l 0,187.87147 a 6.7613422,6.0255495 0 0 0 6.76134,6.02554 l 53.1072,0 a 6.7613422,6.0255495 0 0 0 6.76135,-6.02554 l 0,-101.544018 72.21628,104.699398 a 6.7613422,6.0255495 0 0 0 5.76015,2.87016 l 73.55487,0 a 6.7613422,6.0255495 0 0 0 6.76135,-6.02554 l 0,-187.87147 a 6.7613422,6.0255495 0 0 0 -6.76135,-6.02555 l -54.71644,0 a 6.7613422,6.0255495 0 0 0 -6.76133,6.02555 l 0,102.61935 L 183.76413,2.90886 a 6.7613422,6.0255495 0 0 0 -5.76014,-2.87017 z" />
<path d="M 4.8446333,22.10875 A 13.412039,12.501842 0 0 1 13.477588,0.03924 l 66.118315,0 a 5.3648158,5.000737 0 0 1 5.364823,5.00073 l 0,79.87931 z" />
<path d="m 420.15535,177.89119 a 13.412038,12.501842 0 0 1 -8.63295,22.06951 l -66.11832,0 a 5.3648152,5.000737 0 0 1 -5.36482,-5.00074 l 0,-79.87931 z" />
</svg>
<!-- This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. -->
<!-- http://creativecommons.org/licenses/by-sa/4.0/ -->

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,5 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="user-secret" class="svg-inline--fa fa-user-secret fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" opacity=".33">
<path fill="currentColor" d="M383.9 308.3l23.9-62.6c4-10.5-3.7-21.7-15-21.7h-58.5c11-18.9 17.8-40.6 17.8-64v-.3c39.2-7.8 64-19.1 64-31.7 0-13.3-27.3-25.1-70.1-33-9.2-32.8-27-65.8-40.6-82.8-9.5-11.9-25.9-15.6-39.5-8.8l-27.6 13.8c-9 4.5-19.6 4.5-28.6 0L182.1 3.4c-13.6-6.8-30-3.1-39.5 8.8-13.5 17-31.4 50-40.6 82.8-42.7 7.9-70 19.7-70 33 0 12.6 24.8 23.9 64 31.7v.3c0 23.4 6.8 45.1 17.8 64H56.3c-11.5 0-19.2 11.7-14.7 22.3l25.8 60.2C27.3 329.8 0 372.7 0 422.4v44.8C0 491.9 20.1 512 44.8 512h358.4c24.7 0 44.8-20.1 44.8-44.8v-44.8c0-48.4-25.8-90.4-64.1-114.1zM176 480l-41.6-192 49.6 32 24 40-32 120zm96 0l-32-120 24-40 49.6-32L272 480zm41.7-298.5c-3.9 11.9-7 24.6-16.5 33.4-10.1 9.3-48 22.4-64-25-2.8-8.4-15.4-8.4-18.3 0-17 50.2-56 32.4-64 25-9.5-8.8-12.7-21.5-16.5-33.4-.8-2.5-6.3-5.7-6.3-5.8v-10.8c28.3 3.6 61 5.8 96 5.8s67.7-2.1 96-5.8v10.8c-.1.1-5.6 3.2-6.4 5.8z"></path>
</svg>
<!-- This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. -->
<!-- http://creativecommons.org/licenses/by-sa/4.0/ -->

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 200 289.742" opacity=".33">
<path d="m 93.537677,0 c -18.113125,0 -34.220133,3.11164 -48.323484,9.33437 -13.965092,6.22167 -24.612442,15.07114 -31.940651,26.5471 -7.1899398,11.33789 -10.3012266,24.74911 -10.3012266,40.23478 0,10.64662 2.7250026,20.46465 8.1751116,29.45258 5.615277,8.98686 14.038277,17.35204 25.268821,25.09436 11.230544,7.60531 26.507421,15.41835 45.830514,23.43782 19.983748,8.29557 34.848848,15.55471 44.592998,21.77638 9.74414,6.22273 16.7617,12.8585 21.05572,19.90951 4.29404,7.05208 6.44193,15.76408 6.44193,26.13459 0,16.17702 -5.20196,28.48222 -15.60673,36.91682 -10.2396,8.4347 -25.02203,12.6523 -44.345169,12.6523 -14.038171,0 -25.515247,-1.6594 -34.433618,-4.9777 -8.91837,-3.4566 -16.185572,-8.7113 -21.800839,-15.7633 -5.615277,-7.0521 -10.074795,-16.66088 -13.377899,-28.82812 l -24.7731626293945,0 0,56.82632 C 33.856769,286.07601 63.74904,289.74201 89.678383,289.74201 c 16.020027,0 30.719787,-1.3827 44.097337,-4.1479 13.54272,-2.9043 25.1041,-7.4676 34.68309,-13.6893 9.74413,-6.3597 17.34042,-14.5195 22.79052,-24.4748 5.4501,-10.09332 8.17511,-22.39959 8.17511,-36.91682 0,-12.99764 -3.3021,-24.33539 -9.90829,-34.0146 -6.44105,-9.81725 -15.52545,-18.52707 -27.25146,-26.13133 -11.56085,-7.60427 -27.91083,-15.83142 -49.05066,-24.68022 -17.50644,-7.19012 -30.719668,-13.68948 -39.638038,-19.49701 -8.918371,-5.80752 -18.607474,-12.43409 -24.096524,-18.87417 -5.426043,-6.36616 -9.658826,-15.07003 -9.658826,-24.88729 0,-9.26401 2.075414,-17.21345 6.223454,-23.85033 11.098298,-14.39748 41.286638,-1.79507 45.075609,24.34762 4.839392,6.77491 8.84935,16.24729 12.029515,28.4156 l 20.53234,0 0,-55.99967 c -4.47825,-5.92448 -9.95488,-10.63222 -15.90837,-14.37411 1.64055,0.47905 3.19039,1.02376 4.63865,1.64024 6.49861,2.62607 12.16793,7.32747 17.0073,14.10345 4.83939,6.77491 8.84935,16.24567 12.02952,28.41397 0,0 8.48128,-0.12894 8.48978,-0.002 0.41776,6.41494 -1.75339,9.45286 -4.12342,12.56104 -2.4174,3.16978 -5.14486,6.78973 -4.00278,13.0029 1.50786,8.20318 10.18354,10.59642 14.62194,9.31154 -3.31842,-0.49911 -5.31855,-1.74948 -5.31855,-1.74948 0,0 1.87646,0.99868 5.65117,-1.35981 -3.27695,0.95571 -10.70529,-0.79738 -11.80125,-6.76313 -0.95752,-5.20861 0.94654,-7.29514 3.40113,-10.51482 2.45462,-3.21968 5.28426,-6.95831 4.6843,-14.48824 l 0.003,0.002 8.92676,0 0,-55.99967 c -15.07125,-3.87168 -27.65314,-6.36042 -37.74671,-7.46586 -9.95531,-1.10755 -20.18823,-1.65981 -30.696613,-1.65981 z m 70.321603,17.30893 0.23805,40.3049 c 1.31808,1.22666 2.43965,2.27815 3.34081,3.10602 4.83939,6.77491 8.84934,16.24566 12.02951,28.41397 l 20.53234,0 0,-55.99967 c -6.67731,-4.59381 -19.83643,-10.47309 -36.14071,-15.82522 z m -28.12049,5.60551 8.56479,17.71655 c -11.97037,-6.46697 -13.84678,-9.71726 -8.56479,-17.71655 z m 22.79705,0 c 2.7715,7.99929 1.78741,11.24958 -4.49354,17.71655 l 4.49354,-17.71655 z m 15.22195,24.00848 8.56479,17.71655 c -11.97038,-6.46697 -13.84679,-9.71726 -8.56479,-17.71655 z m 22.79704,0 c 2.7715,7.99929 1.78741,11.24958 -4.49354,17.71655 l 4.49354,-17.71655 z m -99.11384,2.20764 8.56479,17.71655 c -11.970382,-6.46697 -13.846782,-9.71726 -8.56479,-17.71655 z m 22.79542,0 c 2.7715,7.99929 1.78741,11.24958 -4.49354,17.71655 l 4.49354,-17.71655 z" />
</svg>
<!-- This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. -->
<!-- http://creativecommons.org/licenses/by-sa/4.0/ -->

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,3 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="meteor" class="svg-inline--fa fa-meteor fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" opacity=".33"><path fill="currentColor" d="M511.328,20.8027c-11.60759,38.70264-34.30724,111.70173-61.30311,187.70077,6.99893,2.09372,13.4042,4,18.60653,5.59368a16.06158,16.06158,0,0,1,9.49854,22.906c-22.106,42.29635-82.69047,152.795-142.47819,214.40356-.99984,1.09373-1.99969,2.5-2.99954,3.49995A194.83046,194.83046,0,1,1,57.085,179.41009c.99985-1,2.40588-2,3.49947-3,61.59994-59.90549,171.97367-120.40473,214.37343-142.4982a16.058,16.058,0,0,1,22.90274,9.49988c1.59351,5.09368,3.49947,11.5936,5.5929,18.59351C379.34818,35.00565,452.43074,12.30281,491.12794.70921A16.18325,16.18325,0,0,1,511.328,20.8027ZM319.951,320.00207A127.98041,127.98041,0,1,0,191.97061,448.00046,127.97573,127.97573,0,0,0,319.951,320.00207Zm-127.98041-31.9996a31.9951,31.9951,0,1,1-31.9951-31.9996A31.959,31.959,0,0,1,191.97061,288.00247Zm31.9951,79.999a15.99755,15.99755,0,1,1-15.99755-15.9998A16.04975,16.04975,0,0,1,223.96571,368.00147Z"></path></svg>
<!-- This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. -->
<!-- http://creativecommons.org/licenses/by-sa/4.0/ -->

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,333 @@
/**
* 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 { createAction } from 'common/redux';
import { setupDrag } from './drag';
import { focusMap } from './focus';
import { createLogger } from './logging';
import { resumeRenderer, suspendRenderer } from './renderer';
const logger = createLogger('backend');
export const backendUpdate = createAction('backend/update');
export const backendSetSharedState = createAction('backend/setSharedState');
export const backendSuspendStart = createAction('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') {
Byond.sendMessage('pingReply');
return;
}
if (type === 'backend/suspendStart' && !suspendInterval) {
logger.log(`suspending (${Byond.windowId})`);
// Keep sending suspend messages until it succeeds.
// It may fail multiple times due to topic rate limiting.
const suspendFn = () => Byond.sendMessage('suspend');
suspendFn();
suspendInterval = setInterval(suspendFn, 2000);
}
if (type === 'backend/suspendSuccess') {
suspendRenderer();
clearInterval(suspendInterval);
suspendInterval = undefined;
Byond.winset(Byond.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(Byond.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(Byond.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 an action to `ui_act` on `src_object` that this tgui window
* is associated with.
*/
export const sendAct = (action: string, payload: object = {}) => {
// 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;
}
Byond.sendMessage('act/' + action, payload);
};
type BackendState<TData> = {
config: {
title: string;
status: number;
interface: string;
refreshing: boolean;
window: {
key: string;
size: [number, number];
fancy: boolean;
locked: boolean;
};
client: {
ckey: string;
address: string;
computer_id: string;
};
user: {
name: string;
observer: number;
};
};
data: TData;
shared: Record<string, any>;
suspending: boolean;
suspended: boolean;
};
/**
* Selects a backend-related slice of Redux state
*/
export const selectBackend = <TData>(state: any): BackendState<TData> => state.backend || {};
/**
* Get data from tgui backend.
*
* Includes the `act` function for performing DM actions.
*/
export const useBackend = <TData>(context: any) => {
const { store } = context;
const state = selectBackend<TData>(store.getState());
return {
...state,
act: sendAct,
};
};
/**
* A tuple that contains the state and a setter function for it.
*/
type StateWithSetter<T> = [T, (nextState: T) => void];
/**
* 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 context React context.
* @param key Key which uniquely identifies this state in Redux store.
* @param initialState Initializes your global variable with this value.
*/
export const useLocalState = <T>(context: any, key: string, initialState: T): StateWithSetter<T> => {
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,
nextState: 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 context React context.
* @param key Key which uniquely identifies this state in Redux store.
* @param initialState Initializes your global variable with this value.
*/
export const useSharedState = <T>(context: any, key: string, initialState: T): StateWithSetter<T> => {
const { store } = context;
const state = selectBackend(store.getState());
const sharedStates = state.shared ?? {};
const sharedState = key in sharedStates ? sharedStates[key] : initialState;
return [
sharedState,
(nextState) => {
Byond.sendMessage({
type: 'setSharedState',
key,
value: JSON.stringify(typeof nextState === 'function' ? nextState(sharedState) : nextState) || '',
});
},
];
};

View File

@@ -0,0 +1,81 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { clamp, toFixed } from 'common/math';
import { Component } from 'inferno';
const FPS = 20;
const Q = 0.5;
const isSafeNumber = (value) => {
return typeof value === 'number' && Number.isFinite(value) && !Number.isNaN(value);
};
export class AnimatedNumber extends Component {
constructor(props) {
super(props);
this.timer = null;
this.state = {
value: 0,
};
// Use provided initial state
if (isSafeNumber(props.initial)) {
this.state.value = props.initial;
}
// Set initial state with value provided in props
else if (isSafeNumber(props.value)) {
this.state.value = Number(props.value);
}
}
tick() {
const { props, state } = this;
const currentValue = Number(state.value);
const targetValue = Number(props.value);
// Avoid poisoning our state with infinities and NaN
if (!isSafeNumber(targetValue)) {
return;
}
// Smooth the value using an exponential moving average
const value = currentValue * Q + targetValue * (1 - Q);
this.setState({ value });
}
componentDidMount() {
this.timer = setInterval(() => this.tick(), 1000 / FPS);
}
componentWillUnmount() {
clearTimeout(this.timer);
}
render() {
const { props, state } = this;
const { format, children } = props;
const currentValue = state.value;
const targetValue = props.value;
// Directly display values which can't be animated
if (!isSafeNumber(targetValue)) {
return targetValue || null;
}
let formattedValue;
// Use custom formatter
if (format) {
formattedValue = format(currentValue);
}
// Fix our animated precision at target value's precision.
else {
const fraction = String(targetValue).split('.')[1];
const precision = fraction ? fraction.length : 0;
formattedValue = toFixed(currentValue, clamp(precision, 0, 8));
}
// Use a custom render function
if (typeof children === 'function') {
return children(formattedValue, currentValue);
}
return formattedValue;
}
}

View File

@@ -0,0 +1,19 @@
import { Component, createRef } from 'inferno';
export class Autofocus extends Component {
ref = createRef<HTMLDivElement>();
componentDidMount() {
setTimeout(() => {
this.ref.current?.focus();
}, 1);
}
render() {
return (
<div ref={this.ref} tabIndex={-1}>
{this.props.children}
</div>
);
}
}

View File

@@ -0,0 +1,62 @@
import { Component } from 'inferno';
const DEFAULT_BLINKING_INTERVAL = 1000;
const DEFAULT_BLINKING_TIME = 1000;
export class Blink extends Component {
constructor() {
super();
this.state = {
hidden: false,
};
}
createTimer() {
const { interval = DEFAULT_BLINKING_INTERVAL, time = DEFAULT_BLINKING_TIME } = this.props;
clearInterval(this.interval);
clearTimeout(this.timer);
this.setState({
hidden: false,
});
this.interval = setInterval(() => {
this.setState({
hidden: true,
});
this.timer = setTimeout(() => {
this.setState({
hidden: false,
});
}, time);
}, interval + time);
}
componentDidMount() {
this.createTimer();
}
componentDidUpdate(prevProps) {
if (prevProps.interval !== this.props.interval || prevProps.time !== this.props.time) {
this.createTimer();
}
}
componentWillUnmount() {
clearInterval(this.interval);
clearTimeout(this.timer);
}
render(props) {
return (
<span
style={{
visibility: this.state.hidden ? 'hidden' : 'visible',
}}>
{props.children}
</span>
);
}
}

View File

@@ -0,0 +1,13 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes } from 'common/react';
import { Box } from './Box';
export const BlockQuote = (props) => {
const { className, ...rest } = props;
return <Box className={classes(['BlockQuote', className])} {...rest} />;
};

View File

@@ -0,0 +1,298 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { BooleanLike, classes, pureComponentHooks } from 'common/react';
import { createVNode, InfernoNode, SFC } from 'inferno';
import { ChildFlags, VNodeFlags } from 'inferno-vnode-flags';
import { CSS_COLORS } from '../constants';
export interface BoxProps {
[key: string]: any;
as?: string;
className?: string | BooleanLike;
children?: InfernoNode;
position?: string | BooleanLike;
overflow?: string | BooleanLike;
overflowX?: string | BooleanLike;
overflowY?: string | BooleanLike;
top?: string | BooleanLike;
bottom?: string | BooleanLike;
left?: string | BooleanLike;
right?: string | BooleanLike;
width?: string | BooleanLike;
minWidth?: string | BooleanLike;
maxWidth?: string | BooleanLike;
height?: string | BooleanLike;
minHeight?: string | BooleanLike;
maxHeight?: string | BooleanLike;
fontSize?: string | BooleanLike;
fontFamily?: string;
lineHeight?: string | BooleanLike;
opacity?: number;
textAlign?: string | BooleanLike;
verticalAlign?: string | BooleanLike;
textTransform?: string | BooleanLike; // VOREStation Addition
unselectable?: string | BooleanLike; // VOREStation Addition
inline?: BooleanLike;
bold?: BooleanLike;
italic?: BooleanLike;
nowrap?: BooleanLike;
preserveWhitespace?: BooleanLike;
m?: string | BooleanLike;
mx?: string | BooleanLike;
my?: string | BooleanLike;
mt?: string | BooleanLike;
mb?: string | BooleanLike;
ml?: string | BooleanLike;
mr?: string | BooleanLike;
p?: string | BooleanLike;
px?: string | BooleanLike;
py?: string | BooleanLike;
pt?: string | BooleanLike;
pb?: string | BooleanLike;
pl?: string | BooleanLike;
pr?: string | BooleanLike;
color?: string | BooleanLike;
textColor?: string | BooleanLike;
backgroundColor?: string | BooleanLike;
// VOREStation Addition Start
// Flex props
order?: string | BooleanLike;
flexDirection?: string | BooleanLike;
flexGrow?: string | BooleanLike;
flexShrink?: string | BooleanLike;
flexWrap?: string | BooleanLike;
flexFlow?: string | BooleanLike;
flexBasis?: string | BooleanLike;
flex?: string | BooleanLike;
alignItems?: string | BooleanLike;
justifyContent?: string | BooleanLike;
alignSelf?: string | BooleanLike;
// VOREStation Addition End
fillPositionedParent?: boolean;
}
/**
* Coverts our rem-like spacing unit into a CSS unit.
*/
export const unit = (value: unknown): string | undefined => {
if (typeof value === 'string') {
// Transparently convert pixels into rem units
if (value.endsWith('px') && !Byond.IS_LTE_IE8) {
return parseFloat(value) / 12 + 'rem';
}
return value;
}
if (typeof value === 'number') {
if (Byond.IS_LTE_IE8) {
return value * 12 + 'px';
}
return value + 'rem';
}
};
/**
* Same as `unit`, but half the size for integers numbers.
*/
export const halfUnit = (value: unknown): string | undefined => {
if (typeof value === 'string') {
return unit(value);
}
if (typeof value === 'number') {
return unit(value * 0.5);
}
};
const isColorCode = (str: unknown) => !isColorClass(str);
const isColorClass = (str: unknown): boolean => {
return typeof str === 'string' && CSS_COLORS.includes(str);
};
const mapRawPropTo = (attrName) => (style, value) => {
if (typeof value === 'number' || typeof value === 'string') {
style[attrName] = value;
}
};
const mapUnitPropTo = (attrName, unit) => (style, value) => {
if (typeof value === 'number' || typeof value === 'string') {
style[attrName] = unit(value);
}
};
const mapBooleanPropTo = (attrName, attrValue) => (style, value) => {
if (value) {
style[attrName] = attrValue;
}
};
const mapDirectionalUnitPropTo = (attrName, unit, dirs) => (style, value) => {
if (typeof value === 'number' || typeof value === 'string') {
for (let i = 0; i < dirs.length; i++) {
style[attrName + '-' + dirs[i]] = unit(value);
}
}
};
const mapColorPropTo = (attrName) => (style, value) => {
if (isColorCode(value)) {
style[attrName] = value;
}
};
const styleMapperByPropName = {
// Direct mapping
position: mapRawPropTo('position'),
overflow: mapRawPropTo('overflow'),
overflowX: mapRawPropTo('overflow-x'),
overflowY: mapRawPropTo('overflow-y'),
top: mapUnitPropTo('top', unit),
bottom: mapUnitPropTo('bottom', unit),
left: mapUnitPropTo('left', unit),
right: mapUnitPropTo('right', unit),
width: mapUnitPropTo('width', unit),
minWidth: mapUnitPropTo('min-width', unit),
maxWidth: mapUnitPropTo('max-width', unit),
height: mapUnitPropTo('height', unit),
minHeight: mapUnitPropTo('min-height', unit),
maxHeight: mapUnitPropTo('max-height', unit),
fontSize: mapUnitPropTo('font-size', unit),
fontFamily: mapRawPropTo('font-family'),
lineHeight: (style, value) => {
if (typeof value === 'number') {
style['line-height'] = value;
} else if (typeof value === 'string') {
style['line-height'] = unit(value);
}
},
opacity: mapRawPropTo('opacity'),
textAlign: mapRawPropTo('text-align'),
verticalAlign: mapRawPropTo('vertical-align'),
textTransform: mapRawPropTo('text-transform'), // VOREStation Addition
unselectable: mapRawPropTo('unselectable'), // VOREStation Addition
// Boolean props
inline: mapBooleanPropTo('display', 'inline-block'),
bold: mapBooleanPropTo('font-weight', 'bold'),
italic: mapBooleanPropTo('font-style', 'italic'),
nowrap: mapBooleanPropTo('white-space', 'nowrap'),
preserveWhitespace: mapBooleanPropTo('white-space', 'pre-wrap'),
// Margins
m: mapDirectionalUnitPropTo('margin', halfUnit, ['top', 'bottom', 'left', 'right']),
mx: mapDirectionalUnitPropTo('margin', halfUnit, ['left', 'right']),
my: mapDirectionalUnitPropTo('margin', halfUnit, ['top', 'bottom']),
mt: mapUnitPropTo('margin-top', halfUnit),
mb: mapUnitPropTo('margin-bottom', halfUnit),
ml: mapUnitPropTo('margin-left', halfUnit),
mr: mapUnitPropTo('margin-right', halfUnit),
// Margins
p: mapDirectionalUnitPropTo('padding', halfUnit, ['top', 'bottom', 'left', 'right']),
px: mapDirectionalUnitPropTo('padding', halfUnit, ['left', 'right']),
py: mapDirectionalUnitPropTo('padding', halfUnit, ['top', 'bottom']),
pt: mapUnitPropTo('padding-top', halfUnit),
pb: mapUnitPropTo('padding-bottom', halfUnit),
pl: mapUnitPropTo('padding-left', halfUnit),
pr: mapUnitPropTo('padding-right', halfUnit),
// Color props
color: mapColorPropTo('color'),
textColor: mapColorPropTo('color'),
backgroundColor: mapColorPropTo('background-color'),
// VOREStation Addition Start
// Flex props
order: mapRawPropTo('order'),
flexDirection: mapRawPropTo('flex-direction'),
flexGrow: mapRawPropTo('flex-grow'),
flexShrink: mapRawPropTo('flex-shrink'),
flexWrap: mapRawPropTo('flex-wrap'),
flexFlow: mapRawPropTo('flex-flow'),
flexBasis: mapRawPropTo('flex-basis'),
flex: mapRawPropTo('flex'),
alignItems: mapRawPropTo('align-items'),
justifyContent: mapRawPropTo('justify-content'),
alignSelf: mapRawPropTo('align-self'),
// VOREStation Addition End
// Utility props
fillPositionedParent: (style, value) => {
if (value) {
style['position'] = 'absolute';
style['top'] = 0;
style['bottom'] = 0;
style['left'] = 0;
style['right'] = 0;
}
},
};
export const computeBoxProps = (props: BoxProps) => {
const computedProps: HTMLAttributes<any> = {};
const computedStyles = {};
// Compute props
for (let propName of Object.keys(props)) {
if (propName === 'style') {
continue;
}
// IE8: onclick workaround
if (Byond.IS_LTE_IE8 && propName === 'onClick') {
computedProps.onclick = props[propName];
continue;
}
const propValue = props[propName];
const mapPropToStyle = styleMapperByPropName[propName];
if (mapPropToStyle) {
mapPropToStyle(computedStyles, propValue);
} else {
computedProps[propName] = propValue;
}
}
// Concatenate styles
let style = '';
for (let attrName of Object.keys(computedStyles)) {
const attrValue = computedStyles[attrName];
style += attrName + ':' + attrValue + ';';
}
if (props.style) {
for (let attrName of Object.keys(props.style)) {
const attrValue = props.style[attrName];
style += attrName + ':' + attrValue + ';';
}
}
if (style.length > 0) {
computedProps.style = style;
}
return computedProps;
};
export const computeBoxClassName = (props: BoxProps) => {
const color = props.textColor || props.color;
const backgroundColor = props.backgroundColor;
return classes([
isColorClass(color) && 'color-' + color,
isColorClass(backgroundColor) && 'color-bg-' + backgroundColor,
]);
};
export const Box: SFC<BoxProps> = (props: BoxProps) => {
const { as = 'div', className, children, ...rest } = props;
// Render props
if (typeof children === 'function') {
return children(computeBoxProps(props));
}
const computedClassName =
typeof className === 'string' ? className + ' ' + computeBoxClassName(rest) : computeBoxClassName(rest);
const computedProps = computeBoxProps(rest);
// Render a wrapper element
return createVNode(
VNodeFlags.HtmlElement,
as,
computedClassName,
children,
ChildFlags.UnknownChildren,
computedProps,
undefined
);
};
Box.defaultHooks = pureComponentHooks;

View File

@@ -0,0 +1,287 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { KEY_ENTER, KEY_ESCAPE, KEY_SPACE } from 'common/keycodes';
import { classes, pureComponentHooks } from 'common/react';
import { Component, createRef } from 'inferno';
import { createLogger } from '../logging';
import { Box, computeBoxClassName, computeBoxProps } from './Box';
import { Icon } from './Icon';
import { Tooltip } from './Tooltip';
const logger = createLogger('Button');
export const Button = (props) => {
const {
className,
fluid,
icon,
iconRotation,
iconSpin,
iconColor,
iconPosition,
iconSize, // VOREStation Addition
color,
disabled,
selected,
tooltip,
tooltipPosition,
ellipsis,
compact,
circular,
content,
children,
onclick,
onClick,
...rest
} = props;
const hasContent = !!(content || children);
// A warning about the lowercase onclick
if (onclick) {
logger.warn(
`Lowercase 'onclick' is not supported on Button and lowercase` +
` prop names are discouraged in general. Please use a camelCase` +
`'onClick' instead and read: ` +
`https://infernojs.org/docs/guides/event-handling`
);
}
rest.onClick = (e) => {
if (!disabled && onClick) {
onClick(e);
}
};
// IE8: Use "unselectable" because "user-select" doesn't work.
if (Byond.IS_LTE_IE8) {
rest.unselectable = true;
}
let buttonContent = (
<div
className={classes([
'Button',
fluid && 'Button--fluid',
disabled && 'Button--disabled',
selected && 'Button--selected',
hasContent && 'Button--hasContent',
ellipsis && 'Button--ellipsis',
circular && 'Button--circular',
compact && 'Button--compact',
iconPosition && 'Button--iconPosition--' + iconPosition,
color && typeof color === 'string' ? 'Button--color--' + color : 'Button--color--default',
className,
computeBoxClassName(rest),
])}
tabIndex={!disabled && '0'}
onKeyDown={(e) => {
if (props.captureKeys === false) {
return;
}
const keyCode = window.event ? e.which : e.keyCode;
// Simulate a click when pressing space or enter.
if (keyCode === KEY_SPACE || keyCode === KEY_ENTER) {
e.preventDefault();
if (!disabled && onClick) {
onClick(e);
}
return;
}
// Refocus layout on pressing escape.
if (keyCode === KEY_ESCAPE) {
e.preventDefault();
return;
}
}}
{...computeBoxProps(rest)}>
{icon && iconPosition !== 'right' && (
<Icon name={icon} color={iconColor} rotation={iconRotation} spin={iconSpin} />
)}
{content}
{children}
{icon && iconPosition === 'right' && (
<Icon
name={icon}
color={iconColor}
rotation={iconRotation}
spin={iconSpin}
fontSize={iconSize} // VOREStation Addition
/>
)}
</div>
);
if (tooltip) {
buttonContent = (
<Tooltip content={tooltip} position={tooltipPosition}>
{buttonContent}
</Tooltip>
);
}
return buttonContent;
};
Button.defaultHooks = pureComponentHooks;
export const ButtonCheckbox = (props) => {
const { checked, ...rest } = props;
return <Button color="transparent" icon={checked ? 'check-square-o' : 'square-o'} selected={checked} {...rest} />;
};
Button.Checkbox = ButtonCheckbox;
export class ButtonConfirm extends Component {
constructor() {
super();
this.state = {
clickedOnce: false,
};
this.handleClick = () => {
if (this.state.clickedOnce) {
this.setClickedOnce(false);
}
};
}
setClickedOnce(clickedOnce) {
this.setState({
clickedOnce,
});
if (clickedOnce) {
setTimeout(() => window.addEventListener('click', this.handleClick));
} else {
window.removeEventListener('click', this.handleClick);
}
}
render() {
const {
confirmContent = 'Confirm?',
confirmColor = 'bad',
confirmIcon,
icon,
color,
content,
onClick,
...rest
} = this.props;
return (
<Button
content={this.state.clickedOnce ? confirmContent : content}
icon={this.state.clickedOnce ? confirmIcon : icon}
color={this.state.clickedOnce ? confirmColor : color}
onClick={() => (this.state.clickedOnce ? onClick() : this.setClickedOnce(true))}
{...rest}
/>
);
}
}
Button.Confirm = ButtonConfirm;
export class ButtonInput extends Component {
constructor() {
super();
this.inputRef = createRef();
this.state = {
inInput: false,
};
}
setInInput(inInput) {
this.setState({
inInput,
});
if (this.inputRef) {
const input = this.inputRef.current;
if (inInput) {
input.value = this.props.currentValue || '';
try {
input.focus();
input.select();
} catch {}
}
}
}
commitResult(e) {
if (this.inputRef) {
const input = this.inputRef.current;
const hasValue = input.value !== '';
if (hasValue) {
this.props.onCommit(e, input.value);
return;
} else {
if (!this.props.defaultValue) {
return;
}
this.props.onCommit(e, this.props.defaultValue);
}
}
}
render() {
const {
fluid,
content,
icon,
iconRotation,
iconSpin,
tooltip,
tooltipPosition,
color = 'default',
placeholder,
maxLength,
...rest
} = this.props;
let buttonContent = (
<Box
className={classes(['Button', fluid && 'Button--fluid', 'Button--color--' + color])}
{...rest}
onClick={() => this.setInInput(true)}>
{icon && <Icon name={icon} rotation={iconRotation} spin={iconSpin} />}
<div>{content}</div>
<input
ref={this.inputRef}
className="NumberInput__input"
style={{
'display': !this.state.inInput ? 'none' : undefined,
'text-align': 'left',
}}
onBlur={(e) => {
if (!this.state.inInput) {
return;
}
this.setInInput(false);
this.commitResult(e);
}}
onKeyDown={(e) => {
if (e.keyCode === KEY_ENTER) {
this.setInInput(false);
this.commitResult(e);
return;
}
if (e.keyCode === KEY_ESCAPE) {
this.setInInput(false);
}
}}
/>
</Box>
);
if (tooltip) {
buttonContent = (
<Tooltip content={tooltip} position={tooltipPosition}>
{buttonContent}
</Tooltip>
);
}
return buttonContent;
}
}
Button.Input = ButtonInput;

View File

@@ -0,0 +1,128 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { shallowDiffers } from 'common/react';
import { debounce } from 'common/timer';
import { Component, createRef } from 'inferno';
import { createLogger } from '../logging';
import { computeBoxProps } from './Box';
const logger = createLogger('ByondUi');
// Stack of currently allocated BYOND UI element ids.
const byondUiStack = [];
const createByondUiElement = (elementId) => {
// Reserve an index in the stack
const index = byondUiStack.length;
byondUiStack.push(null);
// Get a unique id
const id = elementId || 'byondui_' + index;
logger.log(`allocated '${id}'`);
// Return a control structure
return {
render: (params) => {
logger.log(`rendering '${id}'`);
byondUiStack[index] = id;
Byond.winset(id, params);
},
unmount: () => {
logger.log(`unmounting '${id}'`);
byondUiStack[index] = null;
Byond.winset(id, {
parent: '',
});
},
};
};
window.addEventListener('beforeunload', () => {
// Cleanly unmount all visible UI elements
for (let index = 0; index < byondUiStack.length; index++) {
const id = byondUiStack[index];
if (typeof id === 'string') {
logger.log(`unmounting '${id}' (beforeunload)`);
byondUiStack[index] = null;
Byond.winset(id, {
parent: '',
});
}
}
});
/**
* Get the bounding box of the DOM element in display-pixels.
*/
const getBoundingBox = (element) => {
const pixelRatio = window.devicePixelRatio ?? 1;
const rect = element.getBoundingClientRect();
return {
pos: [rect.left * pixelRatio, rect.top * pixelRatio],
size: [(rect.right - rect.left) * pixelRatio, (rect.bottom - rect.top) * pixelRatio],
};
};
export class ByondUi extends Component {
constructor(props) {
super(props);
this.containerRef = createRef();
this.byondUiElement = createByondUiElement(props.params?.id);
this.handleResize = debounce(() => {
this.forceUpdate();
}, 100);
}
shouldComponentUpdate(nextProps) {
const { params: prevParams = {}, ...prevRest } = this.props;
const { params: nextParams = {}, ...nextRest } = nextProps;
return shallowDiffers(prevParams, nextParams) || shallowDiffers(prevRest, nextRest);
}
componentDidMount() {
// IE8: It probably works, but fuck you anyway.
if (Byond.IS_LTE_IE10) {
return;
}
window.addEventListener('resize', this.handleResize);
this.componentDidUpdate();
this.handleResize();
}
componentDidUpdate() {
// IE8: It probably works, but fuck you anyway.
if (Byond.IS_LTE_IE10) {
return;
}
const { params = {} } = this.props;
const box = getBoundingBox(this.containerRef.current);
logger.debug('bounding box', box);
this.byondUiElement.render({
parent: Byond.windowId,
...params,
pos: box.pos[0] + ',' + box.pos[1],
size: box.size[0] + 'x' + box.size[1],
});
}
componentWillUnmount() {
// IE8: It probably works, but fuck you anyway.
if (Byond.IS_LTE_IE10) {
return;
}
window.removeEventListener('resize', this.handleResize);
this.byondUiElement.unmount();
}
render() {
const { params, ...rest } = this.props;
return (
<div ref={this.containerRef} {...computeBoxProps(rest)}>
{/* Filler */}
<div style={{ 'min-height': '22px' }} />
</div>
);
}
}

View File

@@ -0,0 +1,127 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { map, zipWith } from 'common/collections';
import { pureComponentHooks } from 'common/react';
import { Component, createRef } from 'inferno';
import { Box } from './Box';
const normalizeData = (data, scale, rangeX, rangeY) => {
if (data.length === 0) {
return [];
}
const min = zipWith(Math.min)(...data);
const max = zipWith(Math.max)(...data);
if (rangeX !== undefined) {
min[0] = rangeX[0];
max[0] = rangeX[1];
}
if (rangeY !== undefined) {
min[1] = rangeY[0];
max[1] = rangeY[1];
}
const normalized = map((point) => {
return zipWith((value, min, max, scale) => {
return ((value - min) / (max - min)) * scale;
})(point, min, max, scale);
})(data);
return normalized;
};
const dataToPolylinePoints = (data) => {
let points = '';
for (let i = 0; i < data.length; i++) {
const point = data[i];
points += point[0] + ',' + point[1] + ' ';
}
return points;
};
class LineChart extends Component {
constructor(props) {
super(props);
this.ref = createRef();
this.state = {
// Initial guess
viewBox: [600, 200],
};
this.handleResize = () => {
const element = this.ref.current;
this.setState({
viewBox: [element.offsetWidth, element.offsetHeight],
});
};
}
componentDidMount() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
render() {
const {
data = [],
rangeX,
rangeY,
fillColor = 'none',
strokeColor = '#ffffff',
strokeWidth = 2,
...rest
} = this.props;
const { viewBox } = this.state;
const normalized = normalizeData(data, viewBox, rangeX, rangeY);
// Push data outside viewBox and form a fillable polygon
if (normalized.length > 0) {
const first = normalized[0];
const last = normalized[normalized.length - 1];
normalized.push([viewBox[0] + strokeWidth, last[1]]);
normalized.push([viewBox[0] + strokeWidth, -strokeWidth]);
normalized.push([-strokeWidth, -strokeWidth]);
normalized.push([-strokeWidth, first[1]]);
}
const points = dataToPolylinePoints(normalized);
return (
<Box position="relative" {...rest}>
{(props) => (
<div ref={this.ref} {...props}>
<svg
viewBox={`0 0 ${viewBox[0]} ${viewBox[1]}`}
preserveAspectRatio="none"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}>
<polyline
transform={`scale(1, -1) translate(0, -${viewBox[1]})`}
fill={fillColor}
stroke={strokeColor}
strokeWidth={strokeWidth}
points={points}
/>
</svg>
</div>
)}
</Box>
);
}
}
LineChart.defaultHooks = pureComponentHooks;
const Stub = (props) => null;
// IE8: No inline svg support
export const Chart = {
Line: Byond.IS_LTE_IE8 ? Stub : LineChart,
};

View File

@@ -0,0 +1,43 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { Component } from 'inferno';
import { Box } from './Box';
import { Button } from './Button';
export class Collapsible extends Component {
constructor(props) {
super(props);
const { open } = props;
this.state = {
open: open || false,
};
}
render() {
const { props } = this;
const { open } = this.state;
const { children, color = 'default', title, buttons, ...rest } = props;
return (
<Box mb={1}>
<div className="Table">
<div className="Table__cell">
<Button
fluid
color={color}
icon={open ? 'chevron-down' : 'chevron-right'}
onClick={() => this.setState({ open: !open })}
{...rest}>
{title}
</Button>
</div>
{buttons && <div className="Table__cell Table__cell--collapsing">{buttons}</div>}
</div>
{open && <Box mt={1}>{children}</Box>}
</Box>
);
}
}

View File

@@ -0,0 +1,21 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes, pureComponentHooks } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
export const ColorBox = (props) => {
const { content, children, className, color, backgroundColor, ...rest } = props;
rest.color = content ? null : 'transparent';
rest.backgroundColor = color || backgroundColor;
return (
<div className={classes(['ColorBox', className, computeBoxClassName(rest)])} {...computeBoxProps(rest)}>
{content || '.'}
</div>
);
};
ColorBox.defaultHooks = pureComponentHooks;

View File

@@ -0,0 +1,17 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes } from 'common/react';
import { Box } from './Box';
export const Dimmer = (props) => {
const { className, children, ...rest } = props;
return (
<Box className={classes(['Dimmer', ...className])} {...rest}>
<div className="Dimmer__inner">{children}</div>
</Box>
);
};

View File

@@ -0,0 +1,20 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes } from 'common/react';
export const Divider = (props) => {
const { vertical, hidden } = props;
return (
<div
className={classes([
'Divider',
hidden && 'Divider--hidden',
vertical ? 'Divider--vertical' : 'Divider--horizontal',
])}
/>
);
};

View File

@@ -0,0 +1,271 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { clamp } from 'common/math';
import { pureComponentHooks } from 'common/react';
import { Component, createRef } from 'inferno';
import { AnimatedNumber } from './AnimatedNumber';
const DEFAULT_UPDATE_RATE = 400;
/**
* Reduces screen offset to a single number based on the matrix provided.
*/
const getScalarScreenOffset = (e, matrix) => {
return e.screenX * matrix[0] + e.screenY * matrix[1];
};
export class DraggableControl extends Component {
constructor(props) {
super(props);
this.inputRef = createRef();
this.state = {
value: props.value,
dragging: false,
editing: false,
internalValue: null,
origin: null,
suppressingFlicker: false,
};
// Suppresses flickering while the value propagates through the backend
this.flickerTimer = null;
this.suppressFlicker = () => {
const { suppressFlicker } = this.props;
if (suppressFlicker > 0) {
this.setState({
suppressingFlicker: true,
});
clearTimeout(this.flickerTimer);
this.flickerTimer = setTimeout(
() =>
this.setState({
suppressingFlicker: false,
}),
suppressFlicker
);
}
};
this.handleDragStart = (e) => {
const { value, dragMatrix } = this.props;
const { editing } = this.state;
if (editing) {
return;
}
document.body.style['pointer-events'] = 'none';
this.ref = e.target;
this.setState({
dragging: false,
origin: getScalarScreenOffset(e, dragMatrix),
value,
internalValue: value,
});
this.timer = setTimeout(() => {
this.setState({
dragging: true,
});
}, 250);
this.dragInterval = setInterval(() => {
const { dragging, value } = this.state;
const { onDrag } = this.props;
if (dragging && onDrag) {
onDrag(e, value);
}
}, this.props.updateRate || DEFAULT_UPDATE_RATE);
document.addEventListener('mousemove', this.handleDragMove);
document.addEventListener('mouseup', this.handleDragEnd);
};
this.handleDragMove = (e) => {
const { minValue, maxValue, step, stepPixelSize, dragMatrix } = this.props;
this.setState((prevState) => {
const state = { ...prevState };
const offset = getScalarScreenOffset(e, dragMatrix) - state.origin;
if (prevState.dragging) {
const stepOffset = Number.isFinite(minValue) ? minValue % step : 0;
// Translate mouse movement to value
// Give it some headroom (by increasing clamp range by 1 step)
state.internalValue = clamp(
state.internalValue + (offset * step) / stepPixelSize,
minValue - step,
maxValue + step
);
// Clamp the final value
state.value = clamp(state.internalValue - (state.internalValue % step) + stepOffset, minValue, maxValue);
state.origin = getScalarScreenOffset(e, dragMatrix);
} else if (Math.abs(offset) > 4) {
state.dragging = true;
}
return state;
});
};
this.handleDragEnd = (e) => {
const { onChange, onDrag } = this.props;
const { dragging, value, internalValue } = this.state;
document.body.style['pointer-events'] = 'auto';
clearTimeout(this.timer);
clearInterval(this.dragInterval);
this.setState({
dragging: false,
editing: !dragging,
origin: null,
});
document.removeEventListener('mousemove', this.handleDragMove);
document.removeEventListener('mouseup', this.handleDragEnd);
if (dragging) {
this.suppressFlicker();
if (onChange) {
onChange(e, value);
}
if (onDrag) {
onDrag(e, value);
}
} else if (this.inputRef) {
const input = this.inputRef.current;
input.value = internalValue;
// IE8: Dies when trying to focus a hidden element
// (Error: Object does not support this action)
try {
input.focus();
input.select();
} catch {}
}
};
}
render() {
const { dragging, editing, value: intermediateValue, suppressingFlicker } = this.state;
const {
animated,
value,
unit,
minValue,
maxValue,
unclamped,
format,
onChange,
onDrag,
children,
// Input props
height,
lineHeight,
fontSize,
} = this.props;
let displayValue = value;
if (dragging || suppressingFlicker) {
displayValue = intermediateValue;
}
// Setup a display element
// Shows a formatted number based on what we are currently doing
// with the draggable surface.
const renderDisplayElement = (value) => value + (unit ? ' ' + unit : '');
const displayElement =
(animated && !dragging && !suppressingFlicker && (
<AnimatedNumber value={displayValue} format={format}>
{renderDisplayElement}
</AnimatedNumber>
)) ||
renderDisplayElement(format ? format(displayValue) : displayValue);
// Setup an input element
// Handles direct input via the keyboard
const inputElement = (
<input
ref={this.inputRef}
className="NumberInput__input"
style={{
display: !editing ? 'none' : undefined,
height: height,
'line-height': lineHeight,
'font-size': fontSize,
}}
onBlur={(e) => {
if (!editing) {
return;
}
let value;
if (unclamped) {
value = parseFloat(e.target.value);
} else {
value = clamp(parseFloat(e.target.value), minValue, maxValue);
}
if (Number.isNaN(value)) {
this.setState({
editing: false,
});
return;
}
this.setState({
editing: false,
value,
});
this.suppressFlicker();
if (onChange) {
onChange(e, value);
}
if (onDrag) {
onDrag(e, value);
}
}}
onKeyDown={(e) => {
if (e.keyCode === 13) {
let value;
if (unclamped) {
value = parseFloat(e.target.value);
} else {
value = clamp(parseFloat(e.target.value), minValue, maxValue);
}
if (Number.isNaN(value)) {
this.setState({
editing: false,
});
return;
}
this.setState({
editing: false,
value,
});
this.suppressFlicker();
if (onChange) {
onChange(e, value);
}
if (onDrag) {
onDrag(e, value);
}
return;
}
if (e.keyCode === 27) {
this.setState({
editing: false,
});
return;
}
}}
/>
);
// Return a part of the state for higher-level components to use.
return children({
dragging,
editing,
value,
displayValue,
displayElement,
inputElement,
handleDragStart: this.handleDragStart,
});
}
}
DraggableControl.defaultHooks = pureComponentHooks;
DraggableControl.defaultProps = {
minValue: -Infinity,
maxValue: +Infinity,
step: 1,
stepPixelSize: 1,
suppressFlicker: 50,
dragMatrix: [1, 0],
};

View File

@@ -0,0 +1,145 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes } from 'common/react';
import { Component } from 'inferno';
import { Box } from './Box';
import { Icon } from './Icon';
export class Dropdown extends Component {
constructor(props) {
super(props);
this.state = {
selected: props.selected,
open: false,
};
this.handleClick = () => {
if (this.state.open) {
this.setOpen(false);
}
};
}
componentWillUnmount() {
window.removeEventListener('click', this.handleClick);
}
setOpen(open) {
this.setState({ open: open });
if (open) {
setTimeout(() => window.addEventListener('click', this.handleClick));
this.menuRef.focus();
} else {
window.removeEventListener('click', this.handleClick);
}
}
setSelected(selected) {
this.setState({
selected: selected,
});
this.setOpen(false);
this.props.onSelected(selected);
}
buildMenu() {
const { options = [], placeholder } = this.props; // VOREStation edit
const ops = options.map((option) => (
<Box
key={option}
className="Dropdown__menuentry"
onClick={() => {
this.setSelected(option);
}}>
{option}
</Box>
));
// VOREStation addition start
if (placeholder) {
ops.unshift(
<div
key={placeholder}
className="Dropdown__menuentry"
onClick={() => {
this.setSelected(null);
}}>
-- {placeholder} --
</div>
);
}
// VOREStation addition end
return ops.length ? ops : 'No Options Found';
}
render() {
const { props } = this;
const {
icon,
iconRotation,
iconSpin,
color = 'default',
over,
noscroll,
nochevron,
width,
onClick,
selected,
disabled,
displayText,
placeholder, // VOREStation Addition
...boxProps
} = props;
const { className, ...rest } = boxProps;
const adjustedOpen = over ? !this.state.open : this.state.open;
const menu = this.state.open ? (
<div
ref={(menu) => {
this.menuRef = menu;
}}
tabIndex="-1"
style={{
'width': width,
}}
className={classes([(noscroll && 'Dropdown__menu-noscroll') || 'Dropdown__menu', over && 'Dropdown__over'])}>
{this.buildMenu()}
</div>
) : null;
return (
<div className="Dropdown">
<Box
width={width}
className={classes([
'Dropdown__control',
'Button',
'Button--color--' + color,
disabled && 'Button--disabled',
className,
])}
{...rest}
onClick={() => {
if (disabled && !this.state.open) {
return;
}
this.setOpen(!this.state.open);
}}>
{icon && <Icon name={icon} rotation={iconRotation} spin={iconSpin} mr={1} />}
<span className="Dropdown__selected-text">
{displayText ? displayText : this.state.selected || placeholder /* VOREStation Edit */}
</span>
{!!nochevron || (
<span className="Dropdown__arrow-button">
<Icon name={adjustedOpen ? 'chevron-up' : 'chevron-down'} />
</span>
)}
</Box>
{menu}
</div>
);
}
}

View File

@@ -0,0 +1,110 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { BooleanLike, classes, pureComponentHooks } from 'common/react';
import { BoxProps, computeBoxClassName, computeBoxProps, unit } from './Box';
export type FlexProps = BoxProps & {
direction?: string | BooleanLike;
wrap?: string | BooleanLike;
align?: string | BooleanLike;
alignContent?: string | BooleanLike; // VOREStation Addition
justify?: string | BooleanLike;
inline?: BooleanLike;
};
export const computeFlexClassName = (props: FlexProps) => {
return classes([
'Flex',
props.inline && 'Flex--inline',
Byond.IS_LTE_IE10 && 'Flex--iefix',
Byond.IS_LTE_IE10 && props.direction === 'column' && 'Flex--iefix--column',
computeBoxClassName(props),
]);
};
export const computeFlexProps = (props: FlexProps) => {
const {
className,
direction,
wrap,
align,
alignContent, // VOREStation Addition
justify,
inline,
...rest
} = props;
return computeBoxProps({
style: {
...rest.style,
'flex-direction': direction,
'flex-wrap': wrap === true ? 'wrap' : wrap,
'align-items': align,
'align-content': alignContent, // VOREStation Addition
'justify-content': justify,
},
...rest,
});
};
export const Flex = (props) => {
const { className, ...rest } = props;
return <div className={classes([className, computeFlexClassName(rest)])} {...computeFlexProps(rest)} />;
};
Flex.defaultHooks = pureComponentHooks;
export type FlexItemProps = BoxProps & {
grow?: number;
order?: number;
shrink?: number;
basis?: string | BooleanLike;
align?: string | BooleanLike;
};
export const computeFlexItemClassName = (props: FlexItemProps) => {
return classes(['Flex__item', Byond.IS_LTE_IE10 && 'Flex__item--iefix', computeBoxClassName(props)]);
};
export const computeFlexItemProps = (props: FlexItemProps) => {
const {
className,
style,
grow,
order,
shrink,
// IE11: Always set basis to specified width, which fixes certain
// bugs when rendering tables inside the flex.
basis = props.width,
align,
...rest
} = props;
return computeBoxProps({
style: {
...style,
'flex-grow': grow !== undefined && Number(grow),
'flex-shrink': shrink !== undefined && Number(shrink),
'flex-basis': unit(basis),
'order': order,
'align-self': align,
},
...rest,
});
};
const FlexItem = (props) => {
const { className, ...rest } = props;
return (
<div
className={classes([className, computeFlexItemClassName(props), computeBoxClassName(props)])}
{...computeFlexItemProps(rest)}
/>
);
};
FlexItem.defaultHooks = pureComponentHooks;
Flex.Item = FlexItem;

View File

@@ -0,0 +1,38 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { Table } from './Table';
import { pureComponentHooks } from 'common/react';
/** @deprecated */
export const Grid = (props) => {
const { children, ...rest } = props;
return (
<Table {...rest}>
<Table.Row>{children}</Table.Row>
</Table>
);
};
Grid.defaultHooks = pureComponentHooks;
/** @deprecated */
export const GridColumn = (props) => {
const { size = 1, style, ...rest } = props;
return (
<Table.Cell
style={{
width: size + '%',
...style,
}}
{...rest}
/>
);
};
Grid.defaultHooks = pureComponentHooks;
Grid.Column = GridColumn;

View File

@@ -0,0 +1,56 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @author Original Aleksej Komarov
* @author Changes ThePotato97
* @license MIT
*/
import { classes, pureComponentHooks } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
const FA_OUTLINE_REGEX = /-o$/;
export const Icon = (props) => {
const { name, size, spin, className, rotation, inverse, ...rest } = props;
if (size) {
if (!rest.style) {
rest.style = {};
}
rest.style['font-size'] = size * 100 + '%';
}
if (typeof rotation === 'number') {
if (!rest.style) {
rest.style = {};
}
rest.style['transform'] = `rotate(${rotation}deg)`;
}
const boxProps = computeBoxProps(rest);
let iconClass = '';
if (name.startsWith('tg-')) {
// tgfont icon
iconClass = name;
} else {
// font awesome icon
const faRegular = FA_OUTLINE_REGEX.test(name);
const faName = name.replace(FA_OUTLINE_REGEX, '');
iconClass = (faRegular ? 'far ' : 'fas ') + 'fa-' + faName + (spin ? ' fa-spin' : '');
}
return <i className={classes(['Icon', iconClass, className, computeBoxClassName(rest)])} {...boxProps} />;
};
Icon.defaultHooks = pureComponentHooks;
export const IconStack = (props) => {
const { className, children, ...rest } = props;
return (
<span class={classes(['IconStack', className, computeBoxClassName(rest)])} {...computeBoxProps(rest)}>
{children}
</span>
);
};
Icon.Stack = IconStack;

View File

@@ -0,0 +1,155 @@
import { computeBoxProps } from './Box';
import { Stack } from './Stack';
import { ProgressBar } from './ProgressBar';
import { Button } from './Button';
import { Component } from 'inferno';
const ZOOM_MIN_VAL = 0.5;
const ZOOM_MAX_VAL = 1.5;
const ZOOM_INCREMENT = 0.1;
export class InfinitePlane extends Component {
constructor() {
super();
this.state = {
mouseDown: false,
left: 0,
top: 0,
lastLeft: 0,
lastTop: 0,
zoom: 1,
};
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.doOffsetMouse = this.doOffsetMouse.bind(this);
}
componentDidMount() {
window.addEventListener('mouseup', this.onMouseUp);
window.addEventListener('mousedown', this.doOffsetMouse);
window.addEventListener('mousemove', this.doOffsetMouse);
window.addEventListener('mouseup', this.doOffsetMouse);
}
componentWillUnmount() {
window.removeEventListener('mouseup', this.onMouseUp);
window.removeEventListener('mousedown', this.doOffsetMouse);
window.removeEventListener('mousemove', this.doOffsetMouse);
window.removeEventListener('mouseup', this.doOffsetMouse);
}
doOffsetMouse(event) {
const { zoom } = this.state;
event.screenZoomX = event.screenX * Math.pow(zoom, -1);
event.screenZoomY = event.screenY * Math.pow(zoom, -1);
}
handleMouseDown(event) {
this.setState((state) => {
return {
mouseDown: true,
lastLeft: event.clientX - state.left,
lastTop: event.clientY - state.top,
};
});
}
onMouseUp() {
this.setState({
mouseDown: false,
});
}
handleMouseMove(event) {
if (this.state.mouseDown) {
this.setState((state) => {
return {
left: event.clientX - state.lastLeft,
top: event.clientY - state.lastTop,
};
});
}
}
render() {
const { children, backgroundImage, imageWidth, ...rest } = this.props;
const { left, top, zoom } = this.state;
return (
<div
ref={this.ref}
{...computeBoxProps({
...rest,
style: {
...rest.style,
overflow: 'hidden',
position: 'relative',
},
})}>
<div
onMouseDown={this.handleMouseDown}
onMouseMove={this.handleMouseMove}
style={{
'position': 'fixed',
'height': '100%',
'width': '100%',
'background-image': `url("${backgroundImage}")`,
'background-position': `${left}px ${top}px`,
'background-repeat': 'repeat',
'background-size': `${zoom * imageWidth}px`,
}}
/>
<div
onMouseDown={this.handleMouseDown}
onMouseMove={this.handleMouseMove}
style={{
'position': 'fixed',
'transform': `translate(${left}px, ${top}px) scale(${zoom})`,
'transform-origin': 'top left',
'height': '100%',
'width': '100%',
}}>
{children}
</div>
<Stack position="absolute" width="100%">
<Stack.Item>
<Button
icon="minus"
onClick={() =>
this.setState({
zoom: Math.max(zoom - ZOOM_INCREMENT, ZOOM_MIN_VAL),
})
}
/>
</Stack.Item>
<Stack.Item grow={1}>
<ProgressBar minValue={ZOOM_MIN_VAL} value={zoom} maxValue={ZOOM_MAX_VAL}>
{zoom}x
</ProgressBar>
</Stack.Item>
<Stack.Item>
<Button
icon="plus"
onClick={() =>
this.setState({
zoom: Math.min(zoom + ZOOM_INCREMENT, ZOOM_MAX_VAL),
})
}
/>
</Stack.Item>
</Stack>
</div>
);
}
}

View File

@@ -0,0 +1,141 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes } from 'common/react';
import { Component, createRef } from 'inferno';
import { Box } from './Box';
import { KEY_ESCAPE, KEY_ENTER } from 'common/keycodes';
export const toInputValue = (value) => (typeof value !== 'number' && typeof value !== 'string' ? '' : String(value));
export class Input extends Component {
constructor() {
super();
this.inputRef = createRef();
this.state = {
editing: false,
};
this.handleInput = (e) => {
const { editing } = this.state;
const { onInput } = this.props;
if (!editing) {
this.setEditing(true);
}
if (onInput) {
onInput(e, e.target.value);
}
};
this.handleFocus = (e) => {
const { editing } = this.state;
const { autoSelect } = this.props;
if (!editing) {
this.setEditing(true);
}
if (autoSelect) {
e.target.select();
}
};
this.handleBlur = (e) => {
const { editing } = this.state;
const { onChange } = this.props;
if (editing) {
this.setEditing(false);
if (onChange) {
onChange(e, e.target.value);
}
}
};
this.handleKeyDown = (e) => {
const { onInput, onChange, onEnter } = this.props;
if (e.keyCode === KEY_ENTER) {
this.setEditing(false);
if (onChange) {
onChange(e, e.target.value);
}
if (onInput) {
onInput(e, e.target.value);
}
if (onEnter) {
onEnter(e, e.target.value);
}
if (this.props.selfClear) {
e.target.value = '';
} else {
e.target.blur();
}
return;
}
if (e.keyCode === KEY_ESCAPE) {
if (this.props.onEscape) {
this.props.onEscape(e);
return;
}
this.setEditing(false);
e.target.value = toInputValue(this.props.value);
e.target.blur();
return;
}
};
}
componentDidMount() {
const nextValue = this.props.value;
const input = this.inputRef.current;
if (input) {
input.value = toInputValue(nextValue);
}
if (this.props.autoFocus || this.props.autoSelect) {
setTimeout(() => {
input.focus();
if (this.props.autoSelect) {
input.select();
}
}, 1);
}
}
componentDidUpdate(prevProps, prevState) {
const { editing } = this.state;
const prevValue = prevProps.value;
const nextValue = this.props.value;
const input = this.inputRef.current;
if (input && !editing && prevValue !== nextValue) {
input.value = toInputValue(nextValue);
}
}
setEditing(editing) {
this.setState({ editing });
}
render() {
const { props } = this;
// Input only props
const { selfClear, onInput, onChange, onEnter, value, maxLength, placeholder, ...boxProps } = props;
// Box props
const { className, fluid, monospace, ...rest } = boxProps;
return (
<Box
className={classes(['Input', fluid && 'Input--fluid', monospace && 'Input--monospace', className])}
{...rest}>
<div className="Input__baseline">.</div>
<input
ref={this.inputRef}
className="Input__input"
placeholder={placeholder}
onInput={this.handleInput}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
maxLength={maxLength}
/>
</Box>
);
}
}

View File

@@ -0,0 +1,39 @@
import { Component } from 'inferno';
import { KeyEvent } from '../events';
import { listenForKeyEvents } from '../hotkeys';
type KeyListenerProps = Partial<{
onKey: (key: KeyEvent) => void;
onKeyDown: (key: KeyEvent) => void;
onKeyUp: (key: KeyEvent) => void;
}>;
export class KeyListener extends Component<KeyListenerProps> {
dispose: () => void;
constructor() {
super();
this.dispose = listenForKeyEvents((key) => {
if (this.props.onKey) {
this.props.onKey(key);
}
if (key.isDown() && this.props.onKeyDown) {
this.props.onKeyDown(key);
}
if (key.isUp() && this.props.onKeyUp) {
this.props.onKeyUp(key);
}
});
}
componentWillUnmount() {
this.dispose();
}
render() {
return null;
}
}

View File

@@ -0,0 +1,114 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { keyOfMatchingRange, scale } from 'common/math';
import { classes } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
import { DraggableControl } from './DraggableControl';
import { NumberInput } from './NumberInput';
export const Knob = (props) => {
// IE8: I don't want to support a yet another component on IE8.
// IE8: It also can't handle SVG.
if (Byond.IS_LTE_IE8) {
return <NumberInput {...props} />;
}
const {
// Draggable props (passthrough)
animated,
format,
maxValue,
minValue,
unclamped,
onChange,
onDrag,
step,
stepPixelSize,
suppressFlicker,
unit,
value,
// Own props
className,
style,
fillValue,
color,
ranges = {},
size = 1,
bipolar,
children,
...rest
} = props;
return (
<DraggableControl
dragMatrix={[0, -1]}
{...{
animated,
format,
maxValue,
minValue,
unclamped,
onChange,
onDrag,
step,
stepPixelSize,
suppressFlicker,
unit,
value,
}}>
{(control) => {
const { dragging, editing, value, displayValue, displayElement, inputElement, handleDragStart } = control;
const scaledFillValue = scale(fillValue ?? displayValue, minValue, maxValue);
const scaledDisplayValue = scale(displayValue, minValue, maxValue);
const effectiveColor = color || keyOfMatchingRange(fillValue ?? value, ranges) || 'default';
const rotation = Math.min((scaledDisplayValue - 0.5) * 270, 225);
return (
<div
className={classes([
'Knob',
'Knob--color--' + effectiveColor,
bipolar && 'Knob--bipolar',
className,
computeBoxClassName(rest),
])}
{...computeBoxProps({
style: {
'font-size': size + 'em',
...style,
},
...rest,
})}
onMouseDown={handleDragStart}>
<div className="Knob__circle">
<div
className="Knob__cursorBox"
style={{
transform: `rotate(${rotation}deg)`,
}}>
<div className="Knob__cursor" />
</div>
</div>
{dragging && <div className="Knob__popupValue">{displayElement}</div>}
<svg className="Knob__ring Knob__ringTrackPivot" viewBox="0 0 100 100">
<circle className="Knob__ringTrack" cx="50" cy="50" r="50" />
</svg>
<svg className="Knob__ring Knob__ringFillPivot" viewBox="0 0 100 100">
<circle
className="Knob__ringFill"
style={{
'stroke-dashoffset': Math.max(((bipolar ? 2.75 : 2.0) - scaledFillValue * 1.5) * Math.PI * 50, 0),
}}
cx="50"
cy="50"
r="50"
/>
</svg>
{inputElement}
</div>
);
}}
</DraggableControl>
);
};

View File

@@ -0,0 +1,31 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { Flex } from './Flex';
export const LabeledControls = (props) => {
const { children, wrap, ...rest } = props;
return (
<Flex mx={-0.5} wrap={wrap} align="stretch" justify="space-between" {...rest}>
{children}
</Flex>
);
};
const LabeledControlsItem = (props) => {
const { label, children, mx = 1, ...rest } = props;
return (
<Flex.Item mx={mx}>
<Flex height="100%" direction="column" align="center" textAlign="center" justify="space-between" {...rest}>
<Flex.Item />
<Flex.Item>{children}</Flex.Item>
<Flex.Item color="label">{label}</Flex.Item>
</Flex>
</Flex.Item>
);
};
LabeledControls.Item = LabeledControlsItem;

View File

@@ -0,0 +1,100 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { BooleanLike, classes, pureComponentHooks } from 'common/react';
import { InfernoNode } from 'inferno';
import { Box, unit } from './Box';
import { Divider } from './Divider';
type LabeledListProps = {
children?: any;
};
export const LabeledList = (props: LabeledListProps) => {
const { children } = props;
return <table className="LabeledList">{children}</table>;
};
LabeledList.defaultHooks = pureComponentHooks;
type LabeledListItemProps = {
className?: string | BooleanLike;
label?: string | BooleanLike;
labelColor?: string | BooleanLike;
color?: string | BooleanLike;
textAlign?: string | BooleanLike;
verticalAlign?: string | BooleanLike; // VOREStation Addition
buttons?: InfernoNode;
/** @deprecated */
content?: any;
children?: InfernoNode;
};
const LabeledListItem = (props: LabeledListItemProps) => {
const {
className,
label,
labelColor = 'label',
color,
textAlign,
verticalAlign, // VOREStation Addition
buttons,
content,
children,
...rest // VOREStation Addition
} = props;
return (
<tr className={classes(['LabeledList__row', className])}>
<Box
as="td"
verticalAlign={verticalAlign} // VOREStation Addition
color={labelColor}
className={classes(['LabeledList__cell', 'LabeledList__label'])}>
{label ? label + ':' : null}
</Box>
<Box
as="td"
color={color}
textAlign={textAlign}
verticalAlign={verticalAlign} // VOREStation Addition
className={classes(['LabeledList__cell', 'LabeledList__content'])}
colSpan={buttons ? undefined : 2}
{...rest} /* VOREStation Addition*/
>
{content}
{children}
</Box>
{buttons && <td className="LabeledList__cell LabeledList__buttons">{buttons}</td>}
</tr>
);
};
LabeledListItem.defaultHooks = pureComponentHooks;
type LabeledListDividerProps = {
size?: number;
};
const LabeledListDivider = (props: LabeledListDividerProps) => {
const padding = props.size ? unit(Math.max(0, props.size - 1)) : 0;
return (
<tr className="LabeledList__row">
<td
colSpan={3}
style={{
'padding-top': padding,
'padding-bottom': padding,
}}>
<Divider />
</td>
</tr>
);
};
LabeledListDivider.defaultHooks = pureComponentHooks;
LabeledList.Item = LabeledListItem;
LabeledList.Divider = LabeledListDivider;

View File

@@ -0,0 +1,36 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
import { Dimmer } from './Dimmer';
export const Modal = (props) => {
const {
className,
children,
onEnter, // VOREStation Addition
...rest
} = props;
// VOREStation Addition Start
let handleKeyDown;
if (onEnter) {
handleKeyDown = (e) => {
let key = e.which || e.keyCode;
if (key === 13) {
onEnter(e);
}
};
}
// VOREStation Addition End
return (
<Dimmer onKeyDown={handleKeyDown /* VOREStation Edit */}>
<div className={classes(['Modal', className, computeBoxClassName(rest)])} {...computeBoxProps(rest)}>
{children}
</div>
</Dimmer>
);
};

View File

@@ -0,0 +1,219 @@
import { Component } from 'inferno';
import { Box, Button, Icon, Tooltip, LabeledList, Slider } from '.';
import { useBackend } from '../backend';
const pauseEvent = (e) => {
if (e.stopPropagation) {
e.stopPropagation();
}
if (e.preventDefault) {
e.preventDefault();
}
e.cancelBubble = true;
e.returnValue = false;
return false;
};
const zoomScale = 280;
export class NanoMap extends Component {
constructor(props) {
super(props);
// Auto center based on window size
const Xcenter = window.innerWidth / 2 - 256;
const Ycenter = window.innerHeight / 2 - 256;
this.state = {
offsetX: Xcenter,
offsetY: Ycenter,
transform: 'none',
dragging: false,
originX: null,
originY: null,
zoom: 1,
};
// Dragging
this.handleDragStart = (e) => {
this.ref = e.target;
this.setState({
dragging: false,
originX: e.screenX,
originY: e.screenY,
});
document.addEventListener('mousemove', this.handleDragMove);
document.addEventListener('mouseup', this.handleDragEnd);
pauseEvent(e);
};
this.handleDragMove = (e) => {
this.setState((prevState) => {
const state = { ...prevState };
const newOffsetX = e.screenX - state.originX;
const newOffsetY = e.screenY - state.originY;
if (prevState.dragging) {
state.offsetX += newOffsetX;
state.offsetY += newOffsetY;
state.originX = e.screenX;
state.originY = e.screenY;
} else {
state.dragging = true;
}
return state;
});
pauseEvent(e);
};
this.handleDragEnd = (e) => {
this.setState({
dragging: false,
originX: null,
originY: null,
});
document.removeEventListener('mousemove', this.handleDragMove);
document.removeEventListener('mouseup', this.handleDragEnd);
pauseEvent(e);
};
this.handleOnClick = (e) => {
let byondX = e.offsetX / this.state.zoom / zoomScale;
let byondY = 1 - e.offsetY / this.state.zoom / zoomScale; // Byond origin is bottom left, this is top left
e.byondX = byondX;
e.byondY = byondY;
if (typeof this.props.onClick === 'function') {
this.props.onClick(e);
}
};
this.handleZoom = (_e, value) => {
this.setState((state) => {
const newZoom = Math.min(Math.max(value, 1), 8);
let zoomDiff = (newZoom - state.zoom) * 1.5;
state.zoom = newZoom;
let newOffsetX = state.offsetX - 262 * zoomDiff;
if (newOffsetX < -500) {
newOffsetX = -500;
}
if (newOffsetX > 500) {
newOffsetX = 500;
}
let newOffsetY = state.offsetY - 256 * zoomDiff;
if (newOffsetY < -200) {
newOffsetY = -200;
}
if (newOffsetY > 200) {
newOffsetY = 200;
}
state.offsetX = newOffsetX;
state.offsetY = newOffsetY;
if (props.onZoom) {
props.onZoom(state.zoom);
}
return state;
});
};
}
render() {
const { config } = useBackend(this.context);
const { dragging, offsetX, offsetY, zoom = 1 } = this.state;
const { children } = this.props;
const mapUrl = config.map + '_nanomap_z' + config.mapZLevel + '.png';
// (x * zoom), x Needs to be double the turf- map size. (for virgo, 140x140)
const mapSize = zoomScale * zoom + 'px';
const newStyle = {
width: mapSize,
height: mapSize,
'margin-top': offsetY + 'px',
'margin-left': offsetX + 'px',
'overflow': 'hidden',
'position': 'relative',
'background-image': 'url(' + mapUrl + ')',
'background-size': 'cover',
'background-repeat': 'no-repeat',
'text-align': 'center',
'cursor': dragging ? 'move' : 'auto',
};
return (
<Box className="NanoMap__container">
<Box style={newStyle} textAlign="center" onMouseDown={this.handleDragStart} onClick={this.handleOnClick}>
<Box>{children}</Box>
</Box>
<NanoMapZoomer zoom={zoom} onZoom={this.handleZoom} />
</Box>
);
}
}
const NanoMapMarker = (props, context) => {
const { x, y, zoom = 1, icon, tooltip, color, onClick } = props;
const handleOnClick = (e) => {
pauseEvent(e);
if (onClick) {
onClick(e);
}
};
const rx = x * 2 * zoom - zoom - 3;
const ry = y * 2 * zoom - zoom - 3;
return (
<div>
<Box
position="absolute"
className="NanoMap__marker"
lineHeight="0"
bottom={ry + 'px'}
left={rx + 'px'}
onMouseDown={handleOnClick}>
<Icon name={icon} color={color} fontSize="6px" />
<Tooltip content={tooltip} />
</Box>
</div>
);
};
NanoMap.Marker = NanoMapMarker;
const NanoMapZoomer = (props, context) => {
const { act, config, data } = useBackend(context);
return (
<Box className="NanoMap__zoomer">
<LabeledList>
<LabeledList.Item label="Zoom">
<Slider
minValue="1"
maxValue="8"
stepPixelSize="10"
format={(v) => v + 'x'}
value={props.zoom}
onDrag={(e, v) => props.onZoom(e, v)}
/>
</LabeledList.Item>
<LabeledList.Item label="Z-Level">
{data.map_levels
.sort((a, b) => Number(a) - Number(b))
.map((level) => (
<Button
key={level}
selected={~~level === ~~config.mapZLevel}
content={level}
onClick={() => {
act('setZLevel', { 'mapZLevel': level });
}}
/>
))}
</LabeledList.Item>
</LabeledList>
</Box>
);
};
NanoMap.Zoomer = NanoMapZoomer;

View File

@@ -0,0 +1,27 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes, pureComponentHooks } from 'common/react';
import { Box } from './Box';
export const NoticeBox = (props) => {
const { className, color, info, warning, success, danger, ...rest } = props;
return (
<Box
className={classes([
'NoticeBox',
color && 'NoticeBox--color--' + color,
info && 'NoticeBox--type--info',
success && 'NoticeBox--type--success',
danger && 'NoticeBox--type--danger',
className,
])}
{...rest}
/>
);
};
NoticeBox.defaultHooks = pureComponentHooks;

View File

@@ -0,0 +1,262 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { clamp } from 'common/math';
import { classes, pureComponentHooks } from 'common/react';
import { Component, createRef } from 'inferno';
import { AnimatedNumber } from './AnimatedNumber';
import { Box } from './Box';
const DEFAULT_UPDATE_RATE = 400;
export class NumberInput extends Component {
constructor(props) {
super(props);
const { value } = props;
this.inputRef = createRef();
this.state = {
value,
dragging: false,
editing: false,
internalValue: null,
origin: null,
suppressingFlicker: false,
};
// Suppresses flickering while the value propagates through the backend
this.flickerTimer = null;
this.suppressFlicker = () => {
const { suppressFlicker } = this.props;
if (suppressFlicker > 0) {
this.setState({
suppressingFlicker: true,
});
clearTimeout(this.flickerTimer);
this.flickerTimer = setTimeout(
() =>
this.setState({
suppressingFlicker: false,
}),
suppressFlicker
);
}
};
this.handleDragStart = (e) => {
const { value } = this.props;
const { editing } = this.state;
if (editing) {
return;
}
document.body.style['pointer-events'] = 'none';
this.ref = e.target;
this.setState({
dragging: false,
origin: e.screenY,
value,
internalValue: value,
});
this.timer = setTimeout(() => {
this.setState({
dragging: true,
});
}, 250);
this.dragInterval = setInterval(() => {
const { dragging, value } = this.state;
const { onDrag } = this.props;
if (dragging && onDrag) {
onDrag(e, value);
}
}, this.props.updateRate || DEFAULT_UPDATE_RATE);
document.addEventListener('mousemove', this.handleDragMove);
document.addEventListener('mouseup', this.handleDragEnd);
};
this.handleDragMove = (e) => {
const { minValue, maxValue, step, stepPixelSize } = this.props;
this.setState((prevState) => {
const state = { ...prevState };
const offset = state.origin - e.screenY;
if (prevState.dragging) {
const stepOffset = Number.isFinite(minValue) ? minValue % step : 0;
// Translate mouse movement to value
// Give it some headroom (by increasing clamp range by 1 step)
state.internalValue = clamp(
state.internalValue + (offset * step) / stepPixelSize,
minValue - step,
maxValue + step
);
// Clamp the final value
state.value = clamp(state.internalValue - (state.internalValue % step) + stepOffset, minValue, maxValue);
state.origin = e.screenY;
} else if (Math.abs(offset) > 4) {
state.dragging = true;
}
return state;
});
};
this.handleDragEnd = (e) => {
const { onChange, onDrag } = this.props;
const { dragging, value, internalValue } = this.state;
document.body.style['pointer-events'] = 'auto';
clearTimeout(this.timer);
clearInterval(this.dragInterval);
this.setState({
dragging: false,
editing: !dragging,
origin: null,
});
document.removeEventListener('mousemove', this.handleDragMove);
document.removeEventListener('mouseup', this.handleDragEnd);
if (dragging) {
this.suppressFlicker();
if (onChange) {
onChange(e, value);
}
if (onDrag) {
onDrag(e, value);
}
} else if (this.inputRef) {
const input = this.inputRef.current;
input.value = internalValue;
// IE8: Dies when trying to focus a hidden element
// (Error: Object does not support this action)
try {
input.focus();
input.select();
} catch {}
}
};
}
render() {
const { dragging, editing, value: intermediateValue, suppressingFlicker } = this.state;
const {
className,
fluid,
animated,
value,
unit,
minValue,
maxValue,
height,
width,
lineHeight,
fontSize,
format,
onChange,
onDrag,
} = this.props;
let displayValue = value;
if (dragging || suppressingFlicker) {
displayValue = intermediateValue;
}
// IE8: Use an "unselectable" prop because "user-select" doesn't work.
const renderContentElement = (value) => (
<div className="NumberInput__content" unselectable={Byond.IS_LTE_IE8}>
{value + (unit ? ' ' + unit : '')}
</div>
);
const contentElement =
(animated && !dragging && !suppressingFlicker && (
<AnimatedNumber value={displayValue} format={format}>
{renderContentElement}
</AnimatedNumber>
)) ||
renderContentElement(format ? format(displayValue) : displayValue);
return (
<Box
className={classes(['NumberInput', fluid && 'NumberInput--fluid', className])}
minWidth={width}
minHeight={height}
lineHeight={lineHeight}
fontSize={fontSize}
onMouseDown={this.handleDragStart}>
<div className="NumberInput__barContainer">
<div
className="NumberInput__bar"
style={{
height: clamp(((displayValue - minValue) / (maxValue - minValue)) * 100, 0, 100) + '%',
}}
/>
</div>
{contentElement}
<input
ref={this.inputRef}
className="NumberInput__input"
style={{
display: !editing ? 'none' : undefined,
height: height,
'line-height': lineHeight,
'font-size': fontSize,
}}
onBlur={(e) => {
if (!editing) {
return;
}
const value = clamp(parseFloat(e.target.value), minValue, maxValue);
if (Number.isNaN(value)) {
this.setState({
editing: false,
});
return;
}
this.setState({
editing: false,
value,
});
this.suppressFlicker();
if (onChange) {
onChange(e, value);
}
if (onDrag) {
onDrag(e, value);
}
}}
onKeyDown={(e) => {
if (e.keyCode === 13) {
const value = clamp(parseFloat(e.target.value), minValue, maxValue);
if (Number.isNaN(value)) {
this.setState({
editing: false,
});
return;
}
this.setState({
editing: false,
value,
});
this.suppressFlicker();
if (onChange) {
onChange(e, value);
}
if (onDrag) {
onDrag(e, value);
}
return;
}
if (e.keyCode === 27) {
this.setState({
editing: false,
});
return;
}
}}
/>
</Box>
);
}
}
NumberInput.defaultHooks = pureComponentHooks;
NumberInput.defaultProps = {
minValue: -Infinity,
maxValue: +Infinity,
step: 1,
stepPixelSize: 1,
suppressFlicker: 50,
};

View File

@@ -0,0 +1,74 @@
import { createPopper } from '@popperjs/core';
import { ArgumentsOf } from 'common/types';
import { Component, findDOMfromVNode, InfernoNode, render } from 'inferno';
type PopperProps = {
popperContent: InfernoNode;
options?: ArgumentsOf<typeof createPopper>[2];
additionalStyles?: CSSProperties;
};
export class Popper extends Component<PopperProps> {
static id: number = 0;
renderedContent: HTMLDivElement;
popperInstance: ReturnType<typeof createPopper>;
constructor() {
super();
Popper.id += 1;
}
componentDidMount() {
const { additionalStyles, options } = this.props;
this.renderedContent = document.createElement('div');
if (additionalStyles) {
for (const [attribute, value] of Object.entries(additionalStyles)) {
this.renderedContent.style[attribute] = value;
}
}
this.renderPopperContent(() => {
document.body.appendChild(this.renderedContent);
// HACK: We don't want to create a wrapper, as it could break the layout
// of consumers, so we do the inferno equivalent of `findDOMNode(this)`.
// This is usually bad as refs are usually better, but refs did
// not work in this case, as they weren't propagating correctly.
// A previous attempt was made as a render prop that passed an ID,
// but this made consuming use too unwieldly.
// This code is copied from `findDOMNode` in inferno-extras.
// Because this component is written in TypeScript, we will know
// immediately if this internal variable is removed.
const domNode = findDOMfromVNode(this.$LI, true);
if (!domNode) {
return;
}
this.popperInstance = createPopper(domNode, this.renderedContent, options);
});
}
componentDidUpdate() {
this.renderPopperContent(() => this.popperInstance?.update());
}
componentWillUnmount() {
this.popperInstance?.destroy();
render(null, this.renderedContent, () => {
this.renderedContent.remove();
});
}
renderPopperContent(callback: () => void) {
// `render` errors when given false, so we convert it to `null`,
// which is supported.
render(this.props.popperContent || null, this.renderedContent, callback, this.context);
}
render() {
return this.props.children;
}
}

View File

@@ -0,0 +1,36 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { clamp01, scale, keyOfMatchingRange, toFixed } from 'common/math';
import { classes, pureComponentHooks } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
export const ProgressBar = (props) => {
const { className, value, minValue = 0, maxValue = 1, color, ranges = {}, children, ...rest } = props;
const scaledValue = scale(value, minValue, maxValue);
const hasContent = children !== undefined;
const effectiveColor = color || keyOfMatchingRange(value, ranges) || 'default';
return (
<div
className={classes([
'ProgressBar',
'ProgressBar--color--' + effectiveColor,
className,
computeBoxClassName(rest),
])}
{...computeBoxProps(rest)}>
<div
className="ProgressBar__fill ProgressBar__fill--animated"
style={{
width: clamp01(scaledValue) * 100 + '%',
}}
/>
<div className="ProgressBar__content">{hasContent ? children : toFixed(scaledValue * 100) + '%'}</div>
</div>
);
};
ProgressBar.defaultHooks = pureComponentHooks;

View File

@@ -0,0 +1,150 @@
import { classes } from 'common/react';
import { clamp } from 'common/math';
import { Component, createRef } from 'inferno';
import { Box } from './Box';
import { KEY_ESCAPE, KEY_ENTER } from 'common/keycodes';
const DEFAULT_MIN = 0;
const DEFAULT_MAX = 10000;
/**
* Takes a string input and parses integers from it.
* If none: Minimum is set.
* Else: Clamps it to the given range.
*/
const getClampedNumber = (value, minValue, maxValue) => {
const minimum = minValue || DEFAULT_MIN;
const maximum = maxValue || maxValue === 0 ? maxValue : DEFAULT_MAX;
if (!value || !value.length) {
return String(minimum);
}
// let parsedValue = parseInt(value.replace(/\D/g, ''), 10);
let parsedValue = parseFloat(value);
if (isNaN(parsedValue)) {
return String(minimum);
} else {
return String(clamp(parsedValue, minimum, maximum));
}
};
export class RestrictedInput extends Component {
constructor() {
super();
this.inputRef = createRef();
this.state = {
editing: false,
};
this.handleBlur = (e) => {
const { editing } = this.state;
if (editing) {
this.setEditing(false);
}
};
this.handleChange = (e) => {
const { maxValue, minValue, onChange } = this.props;
e.target.value = getClampedNumber(e.target.value, minValue, maxValue);
if (onChange) {
onChange(e, +e.target.value);
}
};
this.handleFocus = (e) => {
const { editing } = this.state;
if (!editing) {
this.setEditing(true);
}
};
this.handleInput = (e) => {
const { editing } = this.state;
const { onInput } = this.props;
if (!editing) {
this.setEditing(true);
}
if (onInput) {
onInput(e, +e.target.value);
}
};
this.handleKeyDown = (e) => {
const { maxValue, minValue, onChange, onEnter } = this.props;
if (e.keyCode === KEY_ENTER) {
const safeNum = getClampedNumber(e.target.value, minValue, maxValue);
this.setEditing(false);
if (onChange) {
onChange(e, +safeNum);
}
if (onEnter) {
onEnter(e, +safeNum);
}
e.target.blur();
return;
}
if (e.keyCode === KEY_ESCAPE) {
if (this.props.onEscape) {
this.props.onEscape(e);
return;
}
this.setEditing(false);
e.target.value = this.props.value;
e.target.blur();
return;
}
};
}
componentDidMount() {
const { maxValue, minValue } = this.props;
const nextValue = this.props.value?.toString();
const input = this.inputRef.current;
if (input) {
input.value = getClampedNumber(nextValue, minValue, maxValue);
}
if (this.props.autoFocus || this.props.autoSelect) {
setTimeout(() => {
input.focus();
if (this.props.autoSelect) {
input.select();
}
}, 1);
}
}
componentDidUpdate(prevProps, _) {
const { maxValue, minValue } = this.props;
const { editing } = this.state;
const prevValue = prevProps.value?.toString();
const nextValue = this.props.value?.toString();
const input = this.inputRef.current;
if (input && !editing) {
if (nextValue !== prevValue && nextValue !== input.value) {
input.value = getClampedNumber(nextValue, minValue, maxValue);
}
}
}
setEditing(editing) {
this.setState({ editing });
}
render() {
const { props } = this;
const { onChange, onEnter, onInput, value, ...boxProps } = props;
const { className, fluid, monospace, ...rest } = boxProps;
return (
<Box
className={classes(['Input', fluid && 'Input--fluid', monospace && 'Input--monospace', className])}
{...rest}>
<div className="Input__baseline">.</div>
<input
className="Input__input"
onChange={this.handleChange}
onInput={this.handleInput}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
ref={this.inputRef}
type="number"
/>
</Box>
);
}
}

View File

@@ -0,0 +1,82 @@
/**
* @file
* @copyright 2020 bobbahbrown (https://github.com/bobbahbrown)
* @license MIT
*/
import { clamp01, keyOfMatchingRange, scale } from 'common/math';
import { classes } from 'common/react';
import { AnimatedNumber } from './AnimatedNumber';
import { Box, computeBoxClassName, computeBoxProps } from './Box';
export const RoundGauge = (props) => {
// Support for IE8 is for losers sorry B)
if (Byond.IS_LTE_IE8) {
return <AnimatedNumber {...props} />;
}
const { value, minValue = 1, maxValue = 1, ranges, alertAfter, format, size = 1, className, style, ...rest } = props;
const scaledValue = scale(value, minValue, maxValue);
const clampedValue = clamp01(scaledValue);
let scaledRanges = ranges ? {} : { 'primary': [0, 1] };
if (ranges) {
Object.keys(ranges).forEach((x) => {
const range = ranges[x];
scaledRanges[x] = [scale(range[0], minValue, maxValue), scale(range[1], minValue, maxValue)];
});
}
let alertColor = null;
if (alertAfter < value) {
alertColor = keyOfMatchingRange(clampedValue, scaledRanges);
}
return (
<Box inline>
<div
className={classes(['RoundGauge', className, computeBoxClassName(rest)])}
{...computeBoxProps({
style: {
'font-size': size + 'em',
...style,
},
...rest,
})}>
<svg viewBox="0 0 100 50">
{alertAfter && (
<g className={classes(['RoundGauge__alert', alertColor ? `active RoundGauge__alert--${alertColor}` : ''])}>
<path d="M48.211,14.578C48.55,13.9 49.242,13.472 50,13.472C50.758,13.472 51.45,13.9 51.789,14.578C54.793,20.587 60.795,32.589 63.553,38.106C63.863,38.726 63.83,39.462 63.465,40.051C63.101,40.641 62.457,41 61.764,41C55.996,41 44.004,41 38.236,41C37.543,41 36.899,40.641 36.535,40.051C36.17,39.462 36.137,38.726 36.447,38.106C39.205,32.589 45.207,20.587 48.211,14.578ZM50,34.417C51.426,34.417 52.583,35.574 52.583,37C52.583,38.426 51.426,39.583 50,39.583C48.574,39.583 47.417,38.426 47.417,37C47.417,35.574 48.574,34.417 50,34.417ZM50,32.75C50,32.75 53,31.805 53,22.25C53,20.594 51.656,19.25 50,19.25C48.344,19.25 47,20.594 47,22.25C47,31.805 50,32.75 50,32.75Z" />
</g>
)}
<g>
<circle className="RoundGauge__ringTrack" cx="50" cy="50" r="45" />
</g>
<g>
{Object.keys(scaledRanges).map((x, i) => {
const col_ranges = scaledRanges[x];
return (
<circle
className={`RoundGauge__ringFill RoundGauge--color--${x}`}
key={i}
style={{
'stroke-dashoffset': Math.max((2.0 - (col_ranges[1] - col_ranges[0])) * Math.PI * 50, 0),
}}
transform={`rotate(${180 + 180 * col_ranges[0]} 50 50)`}
cx="50"
cy="50"
r="45"
/>
);
})}
</g>
<g className="RoundGauge__needle" transform={`rotate(${clampedValue * 180 - 90} 50 50)`}>
<polygon className="RoundGauge__needleLine" points="46,50 50,0 54,50" />
<circle className="RoundGauge__needleMiddle" cx="50" cy="50" r="8" />
</g>
</svg>
</div>
<AnimatedNumber value={value} format={format} size={size} />
</Box>
);
};

View File

@@ -0,0 +1,107 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { canRender, classes } from 'common/react';
import { Component, createRef, InfernoNode, RefObject } from 'inferno';
import { addScrollableNode, removeScrollableNode } from '../events';
import { BoxProps, computeBoxClassName, computeBoxProps } from './Box';
interface SectionProps extends BoxProps {
className?: string;
title?: string | InfernoElement<string>;
buttons?: InfernoNode;
fill?: boolean;
fitted?: boolean;
scrollable?: boolean;
flexGrow?: boolean; // VOREStation Addition
noTopPadding?: boolean; // VOREStation Addition
stretchContents?: boolean; // VOREStation Addition
/** @deprecated This property no longer works, please remove it. */
level?: boolean;
/** @deprecated Please use `scrollable` property */
overflowY?: any;
}
export class Section extends Component<SectionProps> {
scrollableRef: RefObject<HTMLDivElement>;
scrollable: boolean;
constructor(props) {
super(props);
this.scrollableRef = createRef();
this.scrollable = props.scrollable;
}
componentDidMount() {
if (this.scrollable) {
addScrollableNode(this.scrollableRef.current);
}
if (this.props.autoFocus) {
setTimeout(() => {
if (this.scrollableRef.current) {
return this.scrollableRef.current.focus();
}
}, 1);
}
}
componentWillUnmount() {
if (this.scrollable) {
removeScrollableNode(this.scrollableRef.current);
}
}
render() {
const {
className,
title,
buttons,
fill,
fitted,
scrollable,
flexGrow, // VOREStation Addition
noTopPadding, // VOREStation Addition
stretchContents, // VOREStation Addition
children,
...rest
} = this.props;
const hasTitle = canRender(title) || canRender(buttons);
return (
<div
className={classes([
'Section',
Byond.IS_LTE_IE8 && 'Section--iefix',
fill && 'Section--fill',
fitted && 'Section--fitted',
scrollable && 'Section--scrollable',
flexGrow && 'Section--flex', // VOREStation Addition
className,
computeBoxClassName(rest),
])}
{...computeBoxProps(rest)}>
{hasTitle && (
<div className="Section__title">
<span className="Section__titleText">{title}</span>
<div className="Section__buttons">{buttons}</div>
</div>
)}
<div className="Section__rest">
{/* Vorestation Edit Start */}
<div
ref={this.scrollableRef}
className={classes([
'Section__content',
!!stretchContents && 'Section__content--stretchContents',
!!noTopPadding && 'Section__content--noTopPadding',
])}>
{children}
</div>
{/* Vorestation Edit End */}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,103 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { clamp01, keyOfMatchingRange, scale } from 'common/math';
import { classes } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
import { DraggableControl } from './DraggableControl';
import { NumberInput } from './NumberInput';
export const Slider = (props) => {
// IE8: I don't want to support a yet another component on IE8.
if (Byond.IS_LTE_IE8) {
return <NumberInput {...props} />;
}
const {
// Draggable props (passthrough)
animated,
format,
maxValue,
minValue,
onChange,
onDrag,
step,
stepPixelSize,
suppressFlicker,
unit,
value,
// Own props
className,
fillValue,
color,
ranges = {},
children,
...rest
} = props;
const hasContent = children !== undefined;
return (
<DraggableControl
dragMatrix={[1, 0]}
{...{
animated,
format,
maxValue,
minValue,
onChange,
onDrag,
step,
stepPixelSize,
suppressFlicker,
unit,
value,
}}>
{(control) => {
const { dragging, editing, value, displayValue, displayElement, inputElement, handleDragStart } = control;
const hasFillValue = fillValue !== undefined && fillValue !== null;
const scaledValue = scale(value, minValue, maxValue);
const scaledFillValue = scale(fillValue ?? displayValue, minValue, maxValue);
const scaledDisplayValue = scale(displayValue, minValue, maxValue);
const effectiveColor = color || keyOfMatchingRange(fillValue ?? value, ranges) || 'default';
return (
<div
className={classes([
'Slider',
'ProgressBar',
'ProgressBar--color--' + effectiveColor,
className,
computeBoxClassName(rest),
])}
{...computeBoxProps(rest)}
onMouseDown={handleDragStart}>
<div
className={classes(['ProgressBar__fill', hasFillValue && 'ProgressBar__fill--animated'])}
style={{
width: clamp01(scaledFillValue) * 100 + '%',
opacity: 0.4,
}}
/>
<div
className="ProgressBar__fill"
style={{
width: clamp01(Math.min(scaledFillValue, scaledDisplayValue)) * 100 + '%',
}}
/>
<div
className="Slider__cursorOffset"
style={{
width: clamp01(scaledDisplayValue) * 100 + '%',
}}>
<div className="Slider__cursor" />
<div className="Slider__pointer" />
{dragging && <div className="Slider__popupValue">{displayElement}</div>}
</div>
<div className="ProgressBar__content">{hasContent ? children : displayElement}</div>
{inputElement}
</div>
);
}}
</DraggableControl>
);
};

View File

@@ -0,0 +1,57 @@
/**
* @file
* @copyright 2021 Aleksej Komarov
* @license MIT
*/
import { classes } from 'common/react';
import { RefObject } from 'inferno';
import { Flex, FlexItemProps, FlexProps } from './Flex';
interface StackProps extends FlexProps {
vertical?: boolean;
fill?: boolean;
}
export const Stack = (props: StackProps) => {
const { className, vertical, fill, ...rest } = props;
return (
<Flex
className={classes([
'Stack',
fill && 'Stack--fill',
vertical ? 'Stack--vertical' : 'Stack--horizontal',
className,
])}
direction={vertical ? 'column' : 'row'}
{...rest}
/>
);
};
type StackItemProps = FlexProps & {
innerRef?: RefObject<HTMLDivElement>;
};
const StackItem = (props: StackItemProps) => {
const { className, innerRef, ...rest } = props;
return <Flex.Item className={classes(['Stack__item', className])} ref={innerRef} {...rest} />;
};
Stack.Item = StackItem;
interface StackDividerProps extends FlexItemProps {
hidden?: boolean;
}
const StackDivider = (props: StackDividerProps) => {
const { className, hidden, ...rest } = props;
return (
<Flex.Item
className={classes(['Stack__item', 'Stack__divider', hidden && 'Stack__divider--hidden', className])}
{...rest}
/>
);
};
Stack.Divider = StackDivider;

View File

@@ -0,0 +1,54 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes, pureComponentHooks } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
export const Table = (props) => {
const { className, collapsing, children, ...rest } = props;
return (
<table
className={classes(['Table', collapsing && 'Table--collapsing', className, computeBoxClassName(rest)])}
{...computeBoxProps(rest)}>
<tbody>{children}</tbody>
</table>
);
};
Table.defaultHooks = pureComponentHooks;
export const TableRow = (props) => {
const { className, header, ...rest } = props;
return (
<tr
className={classes(['Table__row', header && 'Table__row--header', className, computeBoxClassName(props)])}
{...computeBoxProps(rest)}
/>
);
};
TableRow.defaultHooks = pureComponentHooks;
export const TableCell = (props) => {
const { className, collapsing, header, ...rest } = props;
return (
<td
className={classes([
'Table__cell',
collapsing && 'Table__cell--collapsing',
header && 'Table__cell--header',
className,
computeBoxClassName(props),
])}
{...computeBoxProps(rest)}
/>
);
};
TableCell.defaultHooks = pureComponentHooks;
Table.Row = TableRow;
Table.Cell = TableCell;

View File

@@ -0,0 +1,54 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { canRender, classes } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
import { Icon } from './Icon';
export const Tabs = (props) => {
const { className, vertical, fill, fluid, children, ...rest } = props;
return (
<div
className={classes([
'Tabs',
vertical ? 'Tabs--vertical' : 'Tabs--horizontal',
fill && 'Tabs--fill',
fluid && 'Tabs--fluid',
className,
computeBoxClassName(rest),
])}
{...computeBoxProps(rest)}>
{children}
</div>
);
};
const Tab = (props) => {
const { className, selected, color, icon, leftSlot, rightSlot, children, ...rest } = props;
return (
<div
className={classes([
'Tab',
'Tabs__Tab',
'Tab--color--' + color,
selected && 'Tab--selected',
className,
...computeBoxClassName(rest),
])}
{...computeBoxProps(rest)}>
{(canRender(leftSlot) && <div className="Tab__left">{leftSlot}</div>) ||
(!!icon && (
<div className="Tab__left">
<Icon name={icon} />
</div>
))}
<div className="Tab__text">{children}</div>
{canRender(rightSlot) && <div className="Tab__right">{rightSlot}</div>}
</div>
);
};
Tabs.Tab = Tab;

View File

@@ -0,0 +1,234 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @author Warlockd
* @license MIT
*/
import { classes } from 'common/react';
import { Component, createRef } from 'inferno';
import { Box } from './Box';
import { toInputValue } from './Input';
import { KEY_ENTER, KEY_ESCAPE, KEY_TAB } from 'common/keycodes';
export class TextArea extends Component {
constructor(props, context) {
super(props, context);
this.textareaRef = props.innerRef || createRef();
// CHOMPedit
this.state = {
editing: false,
scrolledAmount: 0,
};
const { dontUseTabForIndent = false } = props;
this.handleOnInput = (e) => {
const { editing } = this.state;
const { onInput } = this.props;
if (!editing) {
this.setEditing(true);
}
if (onInput) {
onInput(e, e.target.value);
}
};
this.handleOnChange = (e) => {
const { editing } = this.state;
const { onChange } = this.props;
if (editing) {
this.setEditing(false);
}
if (onChange) {
onChange(e, e.target.value);
}
};
this.handleKeyPress = (e) => {
const { editing } = this.state;
const { onKeyPress } = this.props;
if (!editing) {
this.setEditing(true);
}
if (onKeyPress) {
onKeyPress(e, e.target.value);
}
};
this.handleKeyDown = (e) => {
const { editing } = this.state;
// CHOMPedit
const { onChange, onInput, onEnter, onKey } = this.props;
if (e.keyCode === KEY_ENTER) {
this.setEditing(false);
if (onChange) {
onChange(e, e.target.value);
}
if (onInput) {
onInput(e, e.target.value);
}
if (onEnter) {
onEnter(e, e.target.value);
}
if (this.props.selfClear) {
e.target.value = '';
e.target.blur();
}
return;
}
if (e.keyCode === KEY_ESCAPE) {
if (this.props.onEscape) {
this.props.onEscape(e);
}
this.setEditing(false);
if (this.props.selfClear) {
e.target.value = '';
} else {
e.target.value = toInputValue(this.props.value);
e.target.blur();
}
return;
}
if (!editing) {
this.setEditing(true);
}
// CHOMPedit
// Custom key handler
if (onKey) {
onKey(e, e.target.value);
}
if (!dontUseTabForIndent) {
const keyCode = e.keyCode || e.which;
if (keyCode === KEY_TAB) {
e.preventDefault();
const { value, selectionStart, selectionEnd } = e.target;
e.target.value = value.substring(0, selectionStart) + '\t' + value.substring(selectionEnd);
e.target.selectionEnd = selectionStart + 1;
// CHOMPedit
if (onInput) {
onInput(e, e.target.value);
}
}
}
};
this.handleFocus = (e) => {
const { editing } = this.state;
if (!editing) {
this.setEditing(true);
}
};
this.handleBlur = (e) => {
const { editing } = this.state;
const { onChange } = this.props;
if (editing) {
this.setEditing(false);
if (onChange) {
onChange(e, e.target.value);
}
}
};
// CHOMPedit Start
this.handleScroll = (e) => {
const { displayedValue } = this.props;
const input = this.textareaRef.current;
if (displayedValue && input) {
this.setState({
scrolledAmount: input.scrollTop,
});
}
};
}
componentDidMount() {
const nextValue = this.props.value;
const input = this.textareaRef.current;
if (input) {
input.value = toInputValue(nextValue);
}
if (this.props.autoFocus || this.props.autoSelect) {
setTimeout(() => {
input.focus();
if (this.props.autoSelect) {
input.select();
}
}, 1);
// CHOMPedit End
}
}
componentDidUpdate(prevProps, prevState) {
const prevValue = prevProps.value;
const nextValue = this.props.value;
const input = this.textareaRef.current;
if (input && typeof nextValue === 'string' && prevValue !== nextValue) {
input.value = toInputValue(nextValue);
}
}
setEditing(editing) {
this.setState({ editing });
}
getValue() {
return this.textareaRef.current && this.textareaRef.current.value;
}
render() {
// Input only props
const {
onChange,
onKeyDown,
onKeyPress,
onInput,
onFocus,
onBlur,
onEnter,
value,
maxLength,
placeholder,
// CHOMPedit Start
scrollbar,
noborder,
displayedValue,
...boxProps
} = this.props;
// Box props
const { className, fluid, nowrap, ...rest } = boxProps;
const { scrolledAmount } = this.state;
return (
<Box
className={classes(['TextArea', fluid && 'TextArea--fluid', noborder && 'TextArea--noborder', className])}
{...rest}>
{!!displayedValue && (
<Box position="absolute" width="100%" height="100%" overflow="hidden">
<div
className={classes(['TextArea__textarea', 'TextArea__textarea_custom'])}
style={{
'transform': `translateY(-${scrolledAmount}px)`,
}}>
{displayedValue}
</div>
</Box>
)}
<textarea
ref={this.textareaRef}
className={classes([
'TextArea__textarea',
scrollbar && 'TextArea__textarea--scrollable',
nowrap && 'TextArea__nowrap',
])}
placeholder={placeholder}
onChange={this.handleOnChange}
onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
onInput={this.handleOnInput}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
onScroll={this.handleScroll}
maxLength={maxLength}
style={{
'color': displayedValue ? 'rgba(0, 0, 0, 0)' : 'inherit',
}}
/>
{/* CHOMPedit End */}
</Box>
);
}
}

View File

@@ -0,0 +1,61 @@
import { formatTime } from '../format';
import { Component } from 'inferno';
// AnimatedNumber Copypaste
const isSafeNumber = (value) => {
return typeof value === 'number' && Number.isFinite(value) && !Number.isNaN(value);
};
export class TimeDisplay extends Component {
constructor(props) {
super(props);
this.timer = null;
this.last_seen_value = undefined;
this.state = {
value: 0,
};
// Set initial state with value provided in props
if (isSafeNumber(props.value)) {
this.state.value = Number(props.value);
this.last_seen_value = Number(props.value);
}
}
componentDidUpdate() {
if (this.props.auto !== undefined) {
clearInterval(this.timer);
this.timer = setInterval(() => this.tick(), 1000); // every 1 s
}
}
tick() {
let current = Number(this.state.value);
if (this.props.value !== this.last_seen_value) {
this.last_seen_value = this.props.value;
current = this.props.value;
}
const mod = this.props.auto === 'up' ? 10 : -10; // Time down by default.
const value = Math.max(0, current + mod); // one sec tick
this.setState({ value });
}
componentDidMount() {
if (this.props.auto !== undefined) {
this.timer = setInterval(() => this.tick(), 1000); // every 1 s
}
}
componentWillUnmount() {
clearInterval(this.timer);
}
render() {
const val = this.state.value;
// Directly display weird stuff
if (!isSafeNumber(val)) {
return this.state.value || null;
}
return formatTime(val);
}
}

View File

@@ -0,0 +1,138 @@
import { createPopper, Placement, VirtualElement } from '@popperjs/core';
import { Component, findDOMfromVNode, InfernoNode, render } from 'inferno';
type TooltipProps = {
children?: InfernoNode;
content: InfernoNode;
position?: Placement;
};
type TooltipState = {
hovered: boolean;
};
const DEFAULT_OPTIONS = {
modifiers: [
{
name: 'eventListeners',
enabled: false,
},
],
};
const NULL_RECT = {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
};
export class Tooltip extends Component<TooltipProps, TooltipState> {
// Mounting poppers is really laggy because popper.js is very slow.
// Thus, instead of using the Popper component, Tooltip creates ONE popper
// and stores every tooltip inside that.
// This means you can never have two tooltips at once, for instance.
static renderedTooltip: HTMLDivElement | undefined;
static singletonPopper: ReturnType<typeof createPopper> | undefined;
static currentHoveredElement: Element | undefined;
static virtualElement: VirtualElement = {
getBoundingClientRect: () => (Tooltip.currentHoveredElement?.getBoundingClientRect() as DOMRect) ?? NULL_RECT,
};
getDOMNode() {
// HACK: We don't want to create a wrapper, as it could break the layout
// of consumers, so we do the inferno equivalent of `findDOMNode(this)`.
// My attempt to avoid this was a render prop that passed in
// callbacks to onmouseenter and onmouseleave, but this was unwiedly
// to consumers, specifically buttons.
// This code is copied from `findDOMNode` in inferno-extras.
// Because this component is written in TypeScript, we will know
// immediately if this internal variable is removed.
return findDOMfromVNode(this.$LI, true);
}
componentDidMount() {
const domNode = this.getDOMNode();
if (!domNode) {
return;
}
domNode.addEventListener('mouseenter', () => {
let renderedTooltip = Tooltip.renderedTooltip;
if (renderedTooltip === undefined) {
renderedTooltip = document.createElement('div');
renderedTooltip.className = 'Tooltip';
document.body.appendChild(renderedTooltip);
Tooltip.renderedTooltip = renderedTooltip;
}
Tooltip.currentHoveredElement = domNode;
renderedTooltip.style.opacity = '1';
this.renderPopperContent();
});
domNode.addEventListener('mouseleave', () => {
this.fadeOut();
});
}
fadeOut() {
if (Tooltip.currentHoveredElement !== this.getDOMNode()) {
return;
}
Tooltip.currentHoveredElement = undefined;
Tooltip.renderedTooltip!.style.opacity = '0';
}
renderPopperContent() {
const renderedTooltip = Tooltip.renderedTooltip;
if (!renderedTooltip) {
return;
}
render(
<span>{this.props.content}</span>,
renderedTooltip,
() => {
let singletonPopper = Tooltip.singletonPopper;
if (singletonPopper === undefined) {
singletonPopper = createPopper(Tooltip.virtualElement, renderedTooltip!, {
...DEFAULT_OPTIONS,
placement: this.props.position || 'auto',
});
Tooltip.singletonPopper = singletonPopper;
} else {
singletonPopper.setOptions({
...DEFAULT_OPTIONS,
placement: this.props.position || 'auto',
});
singletonPopper.update();
}
},
this.context
);
}
componentDidUpdate() {
if (Tooltip.currentHoveredElement !== this.getDOMNode()) {
return;
}
this.renderPopperContent();
}
componentWillUnmount() {
this.fadeOut();
}
render() {
return this.props.children;
}
}

View File

@@ -0,0 +1,33 @@
import { Component, createRef } from 'inferno';
export class TrackOutsideClicks extends Component<{
onOutsideClick: () => void;
}> {
ref = createRef<HTMLDivElement>();
constructor() {
super();
this.handleOutsideClick = this.handleOutsideClick.bind(this);
document.addEventListener('click', this.handleOutsideClick);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleOutsideClick);
}
handleOutsideClick(event: MouseEvent) {
if (!(event.target instanceof Node)) {
return;
}
if (this.ref.current && !this.ref.current.contains(event.target)) {
this.props.onOutsideClick();
}
}
render() {
return <div ref={this.ref}>{this.props.children}</div>;
}
}

View File

@@ -0,0 +1,46 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export { AnimatedNumber } from './AnimatedNumber';
export { Autofocus } from './Autofocus';
export { Blink } from './Blink';
export { BlockQuote } from './BlockQuote';
export { Box } from './Box';
export { Button } from './Button';
export { ByondUi } from './ByondUi';
export { Chart } from './Chart';
export { Collapsible } from './Collapsible';
export { ColorBox } from './ColorBox';
export { Dimmer } from './Dimmer';
export { Divider } from './Divider';
export { DraggableControl } from './DraggableControl';
export { Dropdown } from './Dropdown';
export { Flex } from './Flex';
export { Grid } from './Grid';
export { Icon } from './Icon';
export { InfinitePlane } from './InfinitePlane';
export { Input } from './Input';
export { KeyListener } from './KeyListener';
export { Knob } from './Knob';
export { LabeledControls } from './LabeledControls';
export { LabeledList } from './LabeledList';
export { Modal } from './Modal';
export { NanoMap } from './NanoMap';
export { NoticeBox } from './NoticeBox';
export { NumberInput } from './NumberInput';
export { ProgressBar } from './ProgressBar';
export { Popper } from './Popper';
export { RestrictedInput } from './RestrictedInput';
export { RoundGauge } from './RoundGauge';
export { Section } from './Section';
export { Slider } from './Slider';
export { Stack } from './Stack';
export { Table } from './Table';
export { Tabs } from './Tabs';
export { TextArea } from './TextArea';
export { TimeDisplay } from './TimeDisplay';
export { TrackOutsideClicks } from './TrackOutsideClicks';
export { Tooltip } from './Tooltip';

View File

@@ -0,0 +1,294 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
// UI states, which are mirrored from the BYOND code.
export const UI_INTERACTIVE = 2;
export const UI_UPDATE = 1;
export const UI_DISABLED = 0;
export const UI_CLOSE = -1;
// All game related colors are stored here
export const COLORS = {
// Department colors
department: {
captain: '#c06616',
security: '#e74c3c',
medbay: '#3498db',
science: '#9b59b6',
engineering: '#f1c40f',
cargo: '#f39c12',
centcom: '#00c100',
other: '#c38312',
},
// VOREStation Addition begin
manifest: {
command: '#3333FF',
security: '#8e0000',
medical: '#006600',
engineering: '#b27300',
science: '#a65ba6',
cargo: '#bb9040',
planetside: '#555555',
civilian: '#a32800',
miscellaneous: '#666666',
silicon: '#222222',
},
// VOREStation Addition end
// Damage type colors
damageType: {
oxy: '#3498db',
toxin: '#2ecc71',
burn: '#e67e22',
brute: '#e74c3c',
},
// reagent / chemistry related colours
reagent: {
acidicbuffer: '#fbc314',
basicbuffer: '#3853a4',
},
};
// Colors defined in CSS
export const CSS_COLORS = [
'black',
'white',
'red',
'orange',
'yellow',
'olive',
'green',
'teal',
'blue',
'violet',
'purple',
'pink',
'brown',
'grey',
'good',
'average',
'bad',
'label',
];
// VOREStation Edit Start
// If you ever add a new radio channel, you can either manually update this, or
// go use /client/verb/generate_tgui_radio_constants() in communications.dm.
export const RADIO_CHANNELS = [
{
'name': 'Mercenary',
'freq': 1213,
'color': '#6D3F40',
},
{
'name': 'Raider',
'freq': 1277,
'color': '#6D3F40',
},
{
'name': 'Special Ops',
'freq': 1341,
'color': '#5C5C8A',
},
{
'name': 'AI Private',
'freq': 1343,
'color': '#FF00FF',
},
{
'name': 'Response Team',
'freq': 1345,
'color': '#5C5C8A',
},
{
'name': 'Supply',
'freq': 1347,
'color': '#5F4519',
},
{
'name': 'Service',
'freq': 1349,
'color': '#6eaa2c',
},
{
'name': 'Science',
'freq': 1351,
'color': '#993399',
},
{
'name': 'Command',
'freq': 1353,
'color': '#193A7A',
},
{
'name': 'Medical',
'freq': 1355,
'color': '#008160',
},
{
'name': 'Engineering',
'freq': 1357,
'color': '#A66300',
},
{
'name': 'Security',
'freq': 1359,
'color': '#A30000',
},
{
'name': 'Explorer',
'freq': 1361,
'color': '#555555',
},
{
'name': 'Talon',
'freq': 1363,
'color': '#555555',
},
{
'name': 'Common',
'freq': 1459,
'color': '#008000',
},
{
'name': 'Entertainment',
'freq': 1461,
'color': '#339966',
},
{
'name': 'Security(I)',
'freq': 1475,
'color': '#008000',
},
{
'name': 'Medical(I)',
'freq': 1485,
'color': '#008000',
},
];
const GASES = [
{
'id': 'oxygen',
'name': 'Oxygen',
'label': 'O₂',
'color': 'blue',
},
{
'id': 'n2',
'name': 'Nitrogen',
'label': 'N₂',
'color': 'red',
},
{
'id': 'carbon dioxide',
'name': 'Carbon Dioxide',
'label': 'CO₂',
'color': 'grey',
},
{
'id': 'phoron',
'name': 'Phoron',
'label': 'Phoron',
'color': 'pink',
},
{
'id': 'water_vapor',
'name': 'Water Vapor',
'label': 'H₂O',
'color': 'grey',
},
{
'id': 'nob',
'name': 'Hyper-noblium',
'label': 'Hyper-nob',
'color': 'teal',
},
{
'id': 'n2o',
'name': 'Nitrous Oxide',
'label': 'N₂O',
'color': 'red',
},
{
'id': 'no2',
'name': 'Nitryl',
'label': 'NO₂',
'color': 'brown',
},
{
'id': 'tritium',
'name': 'Tritium',
'label': 'Tritium',
'color': 'green',
},
{
'id': 'bz',
'name': 'BZ',
'label': 'BZ',
'color': 'purple',
},
{
'id': 'stim',
'name': 'Stimulum',
'label': 'Stimulum',
'color': 'purple',
},
{
'id': 'pluox',
'name': 'Pluoxium',
'label': 'Pluoxium',
'color': 'blue',
},
{
'id': 'miasma',
'name': 'Miasma',
'label': 'Miasma',
'color': 'olive',
},
{
'id': 'hydrogen',
'name': 'Hydrogen',
'label': 'H₂',
'color': 'white',
},
{
'id': 'other',
'name': 'Other',
'label': 'Other',
'color': 'white',
},
{
'id': 'pressure',
'name': 'Pressure',
'label': 'Pressure',
'color': 'average',
},
{
'id': 'temperature',
'name': 'Temperature',
'label': 'Temperature',
'color': 'yellow',
},
];
// VOREStation Edit End
export const getGasLabel = (gasId, fallbackValue) => {
const gasSearchString = String(gasId).toLowerCase();
const gas = GASES.find((gas) => gas.id === gasSearchString || gas.name.toLowerCase() === gasSearchString);
return (gas && gas.label) || fallbackValue || gasId;
};
export const getGasColor = (gasId) => {
const gasSearchString = String(gasId).toLowerCase();
const gas = GASES.find((gas) => gas.id === gasSearchString || gas.name.toLowerCase() === gasSearchString);
return gas && gas.color;
};
// VOREStation Addition start
/** 0.0 Degrees Celsius in Kelvin */
export const T0C = 273.15;
// VOREStation Addition end

View File

@@ -0,0 +1,50 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { useLocalState } from '../backend';
import { Flex, Section, Tabs } from '../components';
import { Pane, Window } from '../layouts';
const r = require.context('../stories', false, /\.stories\.js$/);
/**
* @returns {{
* meta: {
* title: string,
* render: () => any,
* },
* }[]}
*/
const getStories = () => r.keys().map((path) => r(path));
export const KitchenSink = (props, context) => {
const { panel } = props;
const [theme] = useLocalState(context, 'kitchenSinkTheme');
const [pageIndex, setPageIndex] = useLocalState(context, 'pageIndex', 0);
const stories = getStories();
const story = stories[pageIndex];
const Layout = panel ? Pane : Window;
return (
<Layout title="Kitchen Sink" width={600} height={500} theme={theme}>
<Flex height="100%">
<Flex.Item m={1} mr={0}>
<Section fill fitted>
<Tabs vertical>
{stories.map((story, i) => (
<Tabs.Tab key={i} color="transparent" selected={i === pageIndex} onClick={() => setPageIndex(i)}>
{story.meta.title}
</Tabs.Tab>
))}
</Tabs>
</Section>
</Flex.Item>
<Flex.Item position="relative" grow={1}>
<Layout.Content scrollable>{story.meta.render()}</Layout.Content>
</Flex.Item>
</Flex>
</Layout>
);
};

View File

@@ -0,0 +1,11 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { createAction } from 'common/redux';
export const toggleKitchenSink = createAction('debug/toggleKitchenSink');
export const toggleDebugLayout = createAction('debug/toggleDebugLayout');
export const openExternalBrowser = createAction('debug/openExternalBrowser');

View File

@@ -0,0 +1,10 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { useSelector } from 'common/redux';
import { selectDebug } from './selectors';
export const useDebug = (context) => useSelector(context, selectDebug);

View File

@@ -0,0 +1,10 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export { useDebug } from './hooks';
export { KitchenSink } from './KitchenSink';
export { debugMiddleware, relayMiddleware } from './middleware';
export { debugReducer } from './reducer';

View File

@@ -0,0 +1,77 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { KEY_BACKSPACE, KEY_F10, KEY_F11, KEY_F12 } from 'common/keycodes';
import { globalEvents } from '../events';
import { acquireHotKey } from '../hotkeys';
import { openExternalBrowser, toggleDebugLayout, toggleKitchenSink } from './actions';
const relayedTypes = ['backend/update', 'chat/message'];
export const debugMiddleware = (store) => {
acquireHotKey(KEY_F11);
acquireHotKey(KEY_F12);
globalEvents.on('keydown', (key) => {
if (key.code === KEY_F11) {
store.dispatch(toggleDebugLayout());
}
if (key.code === KEY_F12) {
store.dispatch(toggleKitchenSink());
}
if (key.ctrl && key.alt && key.code === KEY_BACKSPACE) {
// NOTE: We need to call this in a timeout, because we need a clean
// stack in order for this to be a fatal error.
setTimeout(() => {
throw new Error(
'OOPSIE WOOPSIE!! UwU We made a fucky wucky!! A wittle' +
' fucko boingo! The code monkeys at our headquarters are' +
' working VEWY HAWD to fix this!'
);
});
}
});
return (next) => (action) => next(action);
};
export const relayMiddleware = (store) => {
const devServer = require('tgui-dev-server/link/client.cjs');
const externalBrowser = location.search === '?external';
if (externalBrowser) {
devServer.subscribe((msg) => {
const { type, payload } = msg;
if (type === 'relay' && payload.windowId === Byond.windowId) {
store.dispatch({
...payload.action,
relayed: true,
});
}
});
} else {
acquireHotKey(KEY_F10);
globalEvents.on('keydown', (key) => {
if (key === KEY_F10) {
store.dispatch(openExternalBrowser());
}
});
}
return (next) => (action) => {
const { type, payload, relayed } = action;
if (type === openExternalBrowser.type) {
window.open(location.href + '?external', '_blank');
return;
}
if (relayedTypes.includes(type) && !relayed && !externalBrowser) {
devServer.sendMessage({
type: 'relay',
payload: {
windowId: Byond.windowId,
action,
},
});
}
return next(action);
};
};

View File

@@ -0,0 +1,22 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const debugReducer = (state = {}, action) => {
const { type, payload } = action;
if (type === 'debug/toggleKitchenSink') {
return {
...state,
kitchenSink: !state.kitchenSink,
};
}
if (type === 'debug/toggleDebugLayout') {
return {
...state,
debugLayout: !state.debugLayout,
};
}
return state;
};

View File

@@ -0,0 +1,7 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const selectDebug = (state) => state.debug;

View File

@@ -0,0 +1,225 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { storage } from 'common/storage';
import { vecAdd, vecSubtract, vecMultiply, vecScale } from 'common/vector';
import { createLogger } from './logging';
const logger = createLogger('drag');
const pixelRatio = window.devicePixelRatio ?? 1;
let windowKey = Byond.windowId;
let dragging = false;
let resizing = false;
let screenOffset = [0, 0];
let screenOffsetPromise;
let dragPointOffset;
let resizeMatrix;
let initialSize;
let size;
export const setWindowKey = (key) => {
windowKey = key;
};
const getWindowPosition = () => [window.screenLeft * pixelRatio, window.screenTop * pixelRatio];
const getWindowSize = () => [window.innerWidth * pixelRatio, window.innerHeight * pixelRatio];
const setWindowPosition = (vec) => {
const byondPos = vecAdd(vec, screenOffset);
return Byond.winset(Byond.windowId, {
pos: byondPos[0] + ',' + byondPos[1],
});
};
const setWindowSize = (vec) => {
return Byond.winset(Byond.windowId, {
size: vec[0] + 'x' + vec[1],
});
};
const getScreenPosition = () => [0 - screenOffset[0], 0 - screenOffset[1]];
const getScreenSize = () => [window.screen.availWidth * pixelRatio, window.screen.availHeight * pixelRatio];
/**
* Moves an item to the top of the recents array, and keeps its length
* limited to the number in `limit` argument.
*
* Uses a strict equality check for comparisons.
*
* Returns new recents and an item which was trimmed.
*/
const touchRecents = (recents, touchedItem, limit = 50) => {
const nextRecents = [touchedItem];
let trimmedItem;
for (let i = 0; i < recents.length; i++) {
const item = recents[i];
if (item === touchedItem) {
continue;
}
if (nextRecents.length < limit) {
nextRecents.push(item);
} else {
trimmedItem = item;
}
}
return [nextRecents, trimmedItem];
};
const storeWindowGeometry = async () => {
logger.log('storing geometry');
const geometry = {
pos: getWindowPosition(),
size: getWindowSize(),
};
storage.set(windowKey, geometry);
// Update the list of stored geometries
const [geometries, trimmedKey] = touchRecents((await storage.get('geometries')) || [], windowKey);
if (trimmedKey) {
storage.remove(trimmedKey);
}
storage.set('geometries', geometries);
};
export const recallWindowGeometry = async (options = {}) => {
// Only recall geometry in fancy mode
const geometry = options.fancy && (await storage.get(windowKey));
if (geometry) {
logger.log('recalled geometry:', geometry);
}
// options.pos is assumed to already be in display-pixels
let pos = geometry?.pos || options.pos;
let size = options.size;
// Convert size from css-pixels to display-pixels
if (size) {
size = [size[0] * pixelRatio, size[1] * pixelRatio];
}
// Wait until screen offset gets resolved
await screenOffsetPromise;
const areaAvailable = getScreenSize();
// Set window size
if (size) {
// Constraint size to not exceed available screen area.
size = [Math.min(areaAvailable[0], size[0]), Math.min(areaAvailable[1], size[1])];
setWindowSize(size);
}
// Set window position
if (pos) {
// Constraint window position if monitor lock was set in preferences.
if (size && options.locked) {
pos = constraintPosition(pos, size)[1];
}
setWindowPosition(pos);
}
// Set window position at the center of the screen.
else if (size) {
pos = vecAdd(vecScale(areaAvailable, 0.5), vecScale(size, -0.5), vecScale(screenOffset, -1.0));
setWindowPosition(pos);
}
};
export const setupDrag = async () => {
// Calculate screen offset caused by the windows taskbar
let windowPosition = getWindowPosition();
screenOffsetPromise = Byond.winget(Byond.windowId, 'pos').then((pos) => [
pos.x - windowPosition[0],
pos.y - windowPosition[1],
]);
screenOffset = await screenOffsetPromise;
logger.debug('screen offset', screenOffset);
};
/**
* Constraints window position to safe screen area, accounting for safe
* margins which could be a system taskbar.
*/
const constraintPosition = (pos, size) => {
const screenPos = getScreenPosition();
const screenSize = getScreenSize();
const nextPos = [pos[0], pos[1]];
let relocated = false;
for (let i = 0; i < 2; i++) {
const leftBoundary = screenPos[i];
const rightBoundary = screenPos[i] + screenSize[i];
if (pos[i] < leftBoundary) {
nextPos[i] = leftBoundary;
relocated = true;
} else if (pos[i] + size[i] > rightBoundary) {
nextPos[i] = rightBoundary - size[i];
relocated = true;
}
}
return [relocated, nextPos];
};
export const dragStartHandler = (event) => {
logger.log('drag start');
dragging = true;
let windowPosition = getWindowPosition();
dragPointOffset = vecSubtract([event.screenX, event.screenY], getWindowPosition());
// Focus click target
event.target?.focus();
document.addEventListener('mousemove', dragMoveHandler);
document.addEventListener('mouseup', dragEndHandler);
dragMoveHandler(event);
};
const dragEndHandler = (event) => {
logger.log('drag end');
dragMoveHandler(event);
document.removeEventListener('mousemove', dragMoveHandler);
document.removeEventListener('mouseup', dragEndHandler);
dragging = false;
storeWindowGeometry();
};
const dragMoveHandler = (event) => {
if (!dragging) {
return;
}
event.preventDefault();
setWindowPosition(vecSubtract([event.screenX, event.screenY], dragPointOffset));
};
export const resizeStartHandler = (x, y) => (event) => {
resizeMatrix = [x, y];
logger.log('resize start', resizeMatrix);
resizing = true;
dragPointOffset = vecSubtract([event.screenX, event.screenY], getWindowPosition());
initialSize = getWindowSize();
// Focus click target
event.target?.focus();
document.addEventListener('mousemove', resizeMoveHandler);
document.addEventListener('mouseup', resizeEndHandler);
resizeMoveHandler(event);
};
const resizeEndHandler = (event) => {
logger.log('resize end', size);
resizeMoveHandler(event);
document.removeEventListener('mousemove', resizeMoveHandler);
document.removeEventListener('mouseup', resizeEndHandler);
resizing = false;
storeWindowGeometry();
};
const resizeMoveHandler = (event) => {
if (!resizing) {
return;
}
event.preventDefault();
const currentOffset = vecSubtract([event.screenX, event.screenY], getWindowPosition());
const delta = vecSubtract(currentOffset, dragPointOffset);
// Extra 1x1 area is added to ensure the browser can see the cursor
size = vecAdd(initialSize, vecMultiply(resizeMatrix, delta), [1, 1]);
// Sane window size values
size[0] = Math.max(size[0], 150 * pixelRatio);
size[1] = Math.max(size[1], 50 * pixelRatio);
setWindowSize(size);
};

View File

@@ -0,0 +1,219 @@
/**
* Normalized browser focus events and BYOND-specific focus helpers.
*
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { EventEmitter } from 'common/events';
import { KEY_ALT, KEY_CTRL, KEY_F1, KEY_F12, KEY_SHIFT } from 'common/keycodes';
export const globalEvents = new EventEmitter();
let ignoreWindowFocus = false;
export const setupGlobalEvents = (options = {}) => {
ignoreWindowFocus = !!options.ignoreWindowFocus;
};
// Window focus
// --------------------------------------------------------
let windowFocusTimeout;
let windowFocused = true;
const setWindowFocus = (value, delayed) => {
// Pretend to always be in focus.
if (ignoreWindowFocus) {
windowFocused = true;
return;
}
if (windowFocusTimeout) {
clearTimeout(windowFocusTimeout);
windowFocusTimeout = null;
}
if (delayed) {
windowFocusTimeout = setTimeout(() => setWindowFocus(value));
return;
}
if (windowFocused !== value) {
windowFocused = value;
globalEvents.emit(value ? 'window-focus' : 'window-blur');
globalEvents.emit('window-focus-change', value);
}
};
// Focus stealing
// --------------------------------------------------------
let focusStolenBy = null;
export const canStealFocus = (node) => {
const tag = String(node.tagName).toLowerCase();
return tag === 'input' || tag === 'textarea';
};
const stealFocus = (node) => {
releaseStolenFocus();
focusStolenBy = node;
focusStolenBy.addEventListener('blur', releaseStolenFocus);
};
const releaseStolenFocus = () => {
if (focusStolenBy) {
focusStolenBy.removeEventListener('blur', releaseStolenFocus);
focusStolenBy = null;
}
};
// Focus follows the mouse
// --------------------------------------------------------
let focusedNode = null;
let lastVisitedNode = null;
const trackedNodes = [];
export const addScrollableNode = (node) => {
trackedNodes.push(node);
};
export const removeScrollableNode = (node) => {
const index = trackedNodes.indexOf(node);
if (index >= 0) {
trackedNodes.splice(index, 1);
}
};
const focusNearestTrackedParent = (node) => {
if (focusStolenBy || !windowFocused) {
return;
}
const body = document.body;
while (node && node !== body) {
if (trackedNodes.includes(node)) {
// NOTE: Contains is a DOM4 method - VORESTATION REMOVAL
// if (node.contains(focusedNode)) {
// return;
// }
focusedNode = node;
node.focus();
return;
}
node = node.parentNode;
}
};
window.addEventListener('mousemove', (e) => {
const node = e.target;
if (node !== lastVisitedNode) {
lastVisitedNode = node;
focusNearestTrackedParent(node);
}
});
// Focus event hooks
// --------------------------------------------------------
window.addEventListener('focusin', (e) => {
lastVisitedNode = null;
focusedNode = e.target;
setWindowFocus(true);
if (canStealFocus(e.target)) {
stealFocus(e.target);
return;
}
});
window.addEventListener('focusout', (e) => {
lastVisitedNode = null;
setWindowFocus(false, true);
});
window.addEventListener('blur', (e) => {
lastVisitedNode = null;
setWindowFocus(false, true);
});
window.addEventListener('beforeunload', (e) => {
setWindowFocus(false);
});
// Key events
// --------------------------------------------------------
const keyHeldByCode = {};
export class KeyEvent {
constructor(e, type, repeat) {
this.event = e;
this.type = type;
this.code = window.event ? e.which : e.keyCode;
this.ctrl = e.ctrlKey;
this.shift = e.shiftKey;
this.alt = e.altKey;
this.repeat = !!repeat;
}
hasModifierKeys() {
return this.ctrl || this.alt || this.shift;
}
isModifierKey() {
return this.code === KEY_CTRL || this.code === KEY_SHIFT || this.code === KEY_ALT;
}
isDown() {
return this.type === 'keydown';
}
isUp() {
return this.type === 'keyup';
}
toString() {
if (this._str) {
return this._str;
}
this._str = '';
if (this.ctrl) {
this._str += 'Ctrl+';
}
if (this.alt) {
this._str += 'Alt+';
}
if (this.shift) {
this._str += 'Shift+';
}
if (this.code >= 48 && this.code <= 90) {
this._str += String.fromCharCode(this.code);
} else if (this.code >= KEY_F1 && this.code <= KEY_F12) {
this._str += 'F' + (this.code - 111);
} else {
this._str += '[' + this.code + ']';
}
return this._str;
}
}
// IE8: Keydown event is only available on document.
document.addEventListener('keydown', (e) => {
if (canStealFocus(e.target)) {
return;
}
const code = e.keyCode;
const key = new KeyEvent(e, 'keydown', keyHeldByCode[code]);
globalEvents.emit('keydown', key);
globalEvents.emit('key', key);
keyHeldByCode[code] = true;
});
document.addEventListener('keyup', (e) => {
if (canStealFocus(e.target)) {
return;
}
const code = e.keyCode;
const key = new KeyEvent(e, 'keyup');
globalEvents.emit('keyup', key);
globalEvents.emit('key', key);
keyHeldByCode[code] = false;
});

View File

@@ -0,0 +1,25 @@
/**
* Various focus helpers.
*
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/**
* Moves focus to the BYOND map window.
*/
export const focusMap = () => {
Byond.winset('mapwindow.map', {
focus: true,
});
};
/**
* Moves focus to the browser window.
*/
export const focusWindow = () => {
Byond.winset(Byond.windowId, {
focus: true,
});
};

View File

@@ -0,0 +1,183 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { clamp, round, toFixed } from 'common/math';
const SI_SYMBOLS = [
'f', // femto
'p', // pico
'n', // nano
'μ', // micro
'm', // milli
// NOTE: This is a space for a reason. When we right align si numbers,
// in monospace mode, we want to units and numbers stay in their respective
// columns. If rendering in HTML mode, this space will collapse into
// a single space anyway.
' ',
'k', // kilo
'M', // mega
'G', // giga
'T', // tera
'P', // peta
'E', // exa
'Z', // zetta
'Y', // yotta
'R', // ronna
'Q', // quecca
'F',
'N',
'H',
];
const SI_BASE_INDEX = SI_SYMBOLS.indexOf(' ');
/**
* Formats a number to a human readable form, by reducing it to SI units.
* TODO: This is quite a shit code and shit math, needs optimization.
*/
export const formatSiUnit = (value, minBase1000 = -SI_BASE_INDEX, unit = '') => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return value;
}
const realBase10 = Math.floor(Math.log10(value));
const base10 = Math.floor(Math.max(minBase1000 * 3, realBase10));
const realBase1000 = Math.floor(realBase10 / 3);
const base1000 = Math.floor(base10 / 3);
const symbolIndex = clamp(SI_BASE_INDEX + base1000, 0, SI_SYMBOLS.length);
const symbol = SI_SYMBOLS[symbolIndex];
const scaledNumber = value / Math.pow(1000, base1000);
const scaledPrecision = realBase1000 > minBase1000 ? 2 + base1000 * 3 - base10 : 0;
// TODO: Make numbers bigger than precision value show
// up to 2 decimal numbers.
const finalString = toFixed(scaledNumber, scaledPrecision) + ' ' + symbol + unit;
return finalString.trim();
};
export const formatPower = (value, minBase1000 = 0) => {
return formatSiUnit(value, minBase1000, 'W');
};
export const formatMoney = (value, precision = 0) => {
if (!Number.isFinite(value)) {
return value;
}
// Round the number and make it fixed precision
let fixed = round(value, precision);
if (precision > 0) {
fixed = toFixed(value, precision);
}
fixed = String(fixed);
// Place thousand separators
const length = fixed.length;
let indexOfPoint = fixed.indexOf('.');
if (indexOfPoint === -1) {
indexOfPoint = length;
}
let result = '';
for (let i = 0; i < length; i++) {
if (i > 0 && i < indexOfPoint && (indexOfPoint - i) % 3 === 0) {
// Thin space
result += '\u2009';
}
result += fixed.charAt(i);
}
return result;
};
/**
* Formats a floating point number as a number on the decibel scale.
*/
export const formatDb = (value) => {
const db = (20 * Math.log(value)) / Math.log(10);
const sign = db >= 0 ? '+' : '';
let formatted = Math.abs(db);
if (formatted === Infinity) {
formatted = 'Inf';
} else {
formatted = toFixed(formatted, 2);
}
return sign + formatted + ' dB';
};
const SI_BASE_TEN_UNIT = [
'',
'· 10³', // kilo
'· 10⁶', // mega
'· 10⁹', // giga
'· 10¹²', // tera
'· 10¹⁵', // peta
'· 10¹⁸', // exa
'· 10²¹', // zetta
'· 10²⁴', // yotta
'· 10²⁷', // ronna
'· 10³⁰', // quecca
'· 10³³',
'· 10³⁶',
'· 10³⁹',
];
const SI_BASE_TEN_INDEX = SI_BASE_TEN_UNIT.indexOf(' ');
/**
* Formats a number to a human readable form, by reducing it to SI units.
* TODO: This is quite a shit code and shit math, needs optimization.
*/
export const formatSiBaseTenUnit = (value, minBase1000 = -SI_BASE_TEN_INDEX, unit = '') => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return value;
}
const realBase10 = Math.floor(Math.log10(value));
const base10 = Math.floor(Math.max(minBase1000 * 3, realBase10));
const realBase1000 = Math.floor(realBase10 / 3);
const base1000 = Math.floor(base10 / 3);
const symbolIndex = clamp(SI_BASE_TEN_INDEX + base1000, 0, SI_BASE_TEN_UNIT.length);
const symbol = SI_BASE_TEN_UNIT[symbolIndex];
const scaledNumber = value / Math.pow(1000, base1000);
const scaledPrecision = realBase1000 > minBase1000 ? 2 + base1000 * 3 - base10 : 0;
// TODO: Make numbers bigger than precision value show
// up to 2 decimal numbers.
const finalString = toFixed(scaledNumber, scaledPrecision) + ' ' + symbol + ' ' + unit;
return finalString.trim();
};
/**
* Formats decisecond count into HH::MM::SS display by default
* "short" format does not pad and adds hms suffixes
*/
export const formatTime = (val, formatType) => {
// THERE IS AS YET INSUFFICIENT DATA FOR A MEANINGFUL ANSWER
// HH:MM:SS
// 00:02:13
const seconds = toFixed(Math.floor((val / 10) % 60));
const minutes = toFixed(Math.floor((val / (10 * 60)) % 60));
const hours = toFixed(Math.floor((val / (10 * 60 * 60)) % 24));
switch (formatType) {
case 'short': {
const hours_truncated = hours > 0 ? `${hours}h` : '';
const minutes_truncated = minutes > 0 ? `${minutes}m` : '';
const seconds_truncated = seconds > 0 ? `${seconds}s` : '';
return `${hours_truncated}${minutes_truncated}${seconds_truncated}`;
}
default: {
const seconds_padded = seconds.padStart(2, '0');
const minutes_padded = minutes.padStart(2, '0');
const hours_padded = hours.padStart(2, '0');
return `${hours_padded}:${minutes_padded}:${seconds_padded}`;
}
}
};
/* VOREStation Addition Start */
export const formatCommaNumber = (value) => {
if (!Number.isFinite(value)) {
return value;
}
// From http://stackoverflow.com/questions/2901102/how-to-print-a-number-with-commas-as-thousands-separators-in-javascript
let parts = value.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
};
/* VOREStation Addition End */

View File

@@ -0,0 +1,212 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import * as keycodes from 'common/keycodes';
import { globalEvents, KeyEvent } from './events';
import { createLogger } from './logging';
const logger = createLogger('hotkeys');
// BYOND macros, in `key: command` format.
const byondMacros: Record<string, string> = {};
// Default set of acquired keys, which will not be sent to BYOND.
const hotKeysAcquired = [
keycodes.KEY_ESCAPE,
keycodes.KEY_ENTER,
keycodes.KEY_SPACE,
keycodes.KEY_TAB,
keycodes.KEY_CTRL,
keycodes.KEY_SHIFT,
keycodes.KEY_UP,
keycodes.KEY_DOWN,
keycodes.KEY_LEFT,
keycodes.KEY_RIGHT,
keycodes.KEY_F5,
];
// State of passed-through keys.
const keyState: Record<string, boolean> = {};
// Custom listeners for key events
const keyListeners: ((key: KeyEvent) => void)[] = [];
/**
* Converts a browser keycode to BYOND keycode.
*/
const keyCodeToByond = (keyCode: number) => {
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: KeyEvent) => {
const keyString = String(key);
// In addition to F5, support reloading with Ctrl+R and Ctrl+F5
if (keyString === 'Ctrl+F5' || keyString === 'Ctrl+R') {
location.reload();
return;
}
// Prevent passthrough on Ctrl+F
if (keyString === 'Ctrl+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 = `TguiKeyDown "${byondKeyCode}"`;
logger.debug(command);
return Byond.command(command);
}
// KeyUp
if (key.isUp() && keyState[byondKeyCode]) {
keyState[byondKeyCode] = false;
const command = `TguiKeyUp "${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: number) => {
hotKeysAcquired.push(keyCode);
};
/**
* Makes the hotkey available to BYOND again.
*/
export const releaseHotKey = (keyCode: number) => {
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(`TguiKeyUp "${byondKeyCode}"`);
}
}
};
type ByondSkinMacro = {
command: string;
name: string;
};
export const setupHotKeys = () => {
// Read macros
Byond.winget('default.*').then((data: Record<string, string>) => {
// Group each macro by ref
const groupedByRef: Record<string, ByondSkinMacro> = {};
for (let key of Object.keys(data)) {
const keyPath = key.split('.');
const ref = keyPath[1];
const prop = keyPath[2];
if (ref && prop) {
// This piece of code imperatively adds each property to a
// ByondSkinMacro object in the order we meet it, which is hard
// to express safely in typescript.
if (!groupedByRef[ref]) {
groupedByRef[ref] = {} as any;
}
groupedByRef[ref][prop] = data[key];
}
}
// Insert macros
const escapedQuotRegex = /\\"/g;
const unescape = (str: string) => 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: KeyEvent) => {
for (const keyListener of keyListeners) {
keyListener(key);
}
handlePassthrough(key);
});
};
/**
* Registers for any key events, such as key down or key up.
* This should be preferred over directly connecting to keydown/keyup
* as it lets tgui prevent the key from reaching BYOND.
*
* If using in a component, prefer KeyListener, which automatically handles
* stopping listening when unmounting.
*
* @param callback The function to call whenever a key event occurs
* @returns A callback to stop listening
*/
export const listenForKeyEvents = (callback: (key: KeyEvent) => void): (() => void) => {
keyListeners.push(callback);
let removed = false;
return () => {
if (removed) {
return;
}
removed = true;
keyListeners.splice(keyListeners.indexOf(callback), 1);
};
};

View File

@@ -0,0 +1,12 @@
/**
* An equivalent to `fetch`, except will automatically retry.
*/
export const fetchRetry = (url: string, options?: RequestInit, retryTimer: number = 1000): Promise<Response> => {
return fetch(url, options).catch(() => {
return new Promise((resolve) => {
setTimeout(() => {
fetchRetry(url, options, retryTimer).then(resolve);
}, retryTimer);
});
});
};

View File

@@ -0,0 +1,71 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
// Themes
import './styles/main.scss';
import './styles/themes/abductor.scss';
import './styles/themes/cardtable.scss';
import './styles/themes/hackerman.scss';
import './styles/themes/malfunction.scss';
import './styles/themes/neutral.scss';
import './styles/themes/ntos.scss';
import './styles/themes/paper.scss';
import './styles/themes/pda-retro.scss';
import './styles/themes/retro.scss';
import './styles/themes/syndicate.scss';
import './styles/themes/wizard.scss';
import './styles/themes/abstract.scss';
import { perf } from 'common/perf';
import { setupHotReloading } from 'tgui-dev-server/link/client.cjs';
import { setupHotKeys } from './hotkeys';
import { captureExternalLinks } from './links';
import { createRenderer } from './renderer';
import { configureStore, StoreProvider } from './store';
import { setupGlobalEvents } from './events';
perf.mark('inception', window.performance?.timing?.navigationStart);
perf.mark('init');
const store = configureStore();
const renderApp = createRenderer(() => {
const { getRoutedComponent } = require('./routes');
const Component = getRoutedComponent(store);
return (
<StoreProvider store={store}>
<Component />
</StoreProvider>
);
});
const setupApp = () => {
// Delay setup
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupApp);
return;
}
setupGlobalEvents();
setupHotKeys();
captureExternalLinks();
// Re-render UI on store updates
store.subscribe(renderApp);
// Dispatch incoming messages as store actions
Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
// Enable hot module reloading
if (module.hot) {
setupHotReloading();
module.hot.accept(['./components', './debug', './layouts', './routes'], () => {
renderApp();
});
}
};
setupApp();

View File

@@ -0,0 +1,114 @@
import { useBackend } from '../backend';
import { Button, ProgressBar, LabeledList, Box, Section } from '../components';
import { Window } from '../layouts';
export const AICard = (props, context) => {
const { act, data } = useBackend(context);
const { has_ai, integrity, backup_capacitor, flushing, has_laws, laws, wireless, radio } = data;
if (has_ai === 0) {
return (
<Window width={600} height={470} resizable>
<Window.Content>
<Section title="Stored AI">
<Box>
<h3>No AI detected.</h3>
</Box>
</Section>
</Window.Content>
</Window>
);
} else {
let integrityColor = null; // Handles changing color of the integrity bar
if (integrity >= 75) {
integrityColor = 'green';
} else if (integrity >= 25) {
integrityColor = 'yellow';
} else {
integrityColor = 'red';
}
let powerColor = null;
if (backup_capacitor >= 75) {
powerColor = 'green';
}
if (backup_capacitor >= 25) {
powerColor = 'yellow';
} else {
powerColor = 'red';
}
return (
<Window width={600} height={470} resizable>
<Window.Content scrollable>
<Section title="Stored AI">
<Box bold display="inline-block">
<h3>{name}</h3>
</Box>
<Box>
<LabeledList>
<LabeledList.Item label="Integrity">
<ProgressBar color={integrityColor} value={integrity / 100} />
</LabeledList.Item>
<LabeledList.Item label="Power">
<ProgressBar color={powerColor} value={backup_capacitor / 100} />
</LabeledList.Item>
</LabeledList>
</Box>
<Box color="red">
<h2>{flushing === 1 ? 'Wipe of AI in progress...' : ''}</h2>
</Box>
</Section>
<Section title="Laws">
{(!!has_laws && (
<Box>
{laws.map((value, key) => (
<Box key={key} display="inline-block">
{value}
</Box>
))}
</Box>
)) || ( // Else, no laws.
<Box color="red">
<h3>No laws detected.</h3>
</Box>
)}
</Section>
<Section title="Actions">
<LabeledList>
<LabeledList.Item label="Wireless Activity">
<Button
icon={wireless ? 'check' : 'times'}
content={wireless ? 'Enabled' : 'Disabled'}
color={wireless ? 'green' : 'red'}
onClick={() => act('wireless')}
/>
</LabeledList.Item>
<LabeledList.Item label="Subspace Transceiver">
<Button
icon={radio ? 'check' : 'times'}
content={radio ? 'Enabled' : 'Disabled'}
color={radio ? 'green' : 'red'}
onClick={() => act('radio')}
/>
</LabeledList.Item>
<LabeledList.Item label="AI Power">
<Button.Confirm
icon="radiation"
confirmIcon="radiation"
disabled={flushing || integrity === 0}
confirmColor="red"
content="Shutdown"
onClick={() => act('wipe')}
/>
</LabeledList.Item>
</LabeledList>
</Section>
</Window.Content>
</Window>
);
}
};

View File

@@ -0,0 +1,283 @@
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { Box, Button, Dimmer, Icon, LabeledList, ProgressBar, Section } from '../components';
import { Window } from '../layouts';
import { InterfaceLockNoticeBox } from './common/InterfaceLockNoticeBox';
import { FullscreenNotice } from './common/FullscreenNotice';
export const APC = (props, context) => {
const { act, data } = useBackend(context);
let body = <ApcContent />;
if (data.gridCheck) {
body = <GridCheck />;
} else if (data.failTime) {
body = <ApcFailure />;
}
return (
<Window width={450} height={475} resizable>
<Window.Content scrollable>{body}</Window.Content>
</Window>
);
};
const powerStatusMap = {
2: {
color: 'good',
externalPowerText: 'External Power',
chargingText: 'Fully Charged',
},
1: {
color: 'average',
externalPowerText: 'Low External Power',
chargingText: 'Charging',
},
0: {
color: 'bad',
externalPowerText: 'No External Power',
chargingText: 'Not Charging',
},
};
const malfMap = {
1: {
icon: 'terminal',
content: 'Override Programming',
action: 'hack',
},
// 2: {
// icon: 'caret-square-down',
// content: 'Shunt Core Process',
// action: 'occupy',
// },
// 3: {
// icon: 'caret-square-left',
// content: 'Return to Main Core',
// action: 'deoccupy',
// },
// 4: {
// icon: 'caret-square-down',
// content: 'Shunt Core Process',
// action: 'occupy',
// },
};
const ApcContent = (props, context) => {
const { act, data } = useBackend(context);
const locked = data.locked && !data.siliconUser;
const normallyLocked = data.normallyLocked;
const externalPowerStatus = powerStatusMap[data.externalPower] || powerStatusMap[0];
const chargingStatus = powerStatusMap[data.chargingStatus] || powerStatusMap[0];
const channelArray = data.powerChannels || [];
// const malfStatus = malfMap[data.malfStatus] || null;
const adjustedCellChange = data.powerCellStatus / 100;
return (
<Fragment>
<InterfaceLockNoticeBox
deny={data.emagged}
denialMessage={
<Fragment>
<Box color="bad" fontSize="1.5rem">
Fault in ID authenticator.
</Box>
<Box color="bad">Please contact maintenance for service.</Box>
</Fragment>
}
/>
<Section title="Power Status">
<LabeledList>
<LabeledList.Item
label="Main Breaker"
color={externalPowerStatus.color}
buttons={
<Button
icon={data.isOperating ? 'power-off' : 'times'}
content={data.isOperating ? 'On' : 'Off'}
selected={data.isOperating && !locked}
color={data.isOperating ? '' : 'bad'}
disabled={locked}
onClick={() => act('breaker')}
/>
}>
[ {externalPowerStatus.externalPowerText} ]
</LabeledList.Item>
<LabeledList.Item label="Power Cell">
<ProgressBar color="good" value={adjustedCellChange} />
</LabeledList.Item>
<LabeledList.Item
label="Charge Mode"
color={chargingStatus.color}
buttons={
<Button
icon={data.chargeMode ? 'sync' : 'times'}
content={data.chargeMode ? 'Auto' : 'Off'}
selected={data.chargeMode}
disabled={locked}
onClick={() => act('charge')}
/>
}>
[ {chargingStatus.chargingText} ]
</LabeledList.Item>
</LabeledList>
</Section>
<Section title="Power Channels">
<LabeledList>
{channelArray.map((channel) => {
const { topicParams } = channel;
return (
<LabeledList.Item
key={channel.title}
label={channel.title}
buttons={
<Fragment>
<Box inline mx={2} color={channel.status >= 2 ? 'good' : 'bad'}>
{channel.status >= 2 ? 'On' : 'Off'}
</Box>
<Button
icon="sync"
content="Auto"
selected={!locked && (channel.status === 1 || channel.status === 3)}
disabled={locked}
onClick={() => act('channel', topicParams.auto)}
/>
<Button
icon="power-off"
content="On"
selected={!locked && channel.status === 2}
disabled={locked}
onClick={() => act('channel', topicParams.on)}
/>
<Button
icon="times"
content="Off"
selected={!locked && channel.status === 0}
disabled={locked}
onClick={() => act('channel', topicParams.off)}
/>
</Fragment>
}>
{channel.powerLoad} W
</LabeledList.Item>
);
})}
<LabeledList.Item label="Total Load">
{data.totalCharging ? (
<b>
{data.totalLoad} W (+ {data.totalCharging} W charging)
</b>
) : (
<b>{data.totalLoad} W</b>
)}
</LabeledList.Item>
</LabeledList>
</Section>
<Section
title="Misc"
buttons={
!!data.siliconUser && <Button icon="lightbulb-o" content="Overload" onClick={() => act('overload')} />
}>
<LabeledList>
<LabeledList.Item
label="Cover Lock"
buttons={
<Button
icon={data.coverLocked ? 'lock' : 'unlock'}
content={data.coverLocked ? 'Engaged' : 'Disengaged'}
selected={data.coverLocked}
disabled={locked}
onClick={() => act('cover')}
/>
}
/>
<LabeledList.Item
label="Night Shift Lighting"
buttons={
<Fragment>
<Button
icon="lightbulb-o"
content="Disabled"
selected={data.nightshiftSetting === 2}
onClick={() =>
act('nightshift', {
nightshift: 2,
})
}
/>
<Button
icon="lightbulb-o"
content="Automatic"
selected={data.nightshiftSetting === 1}
onClick={() =>
act('nightshift', {
nightshift: 1,
})
}
/>
<Button
icon="lightbulb-o"
content="Enabled"
selected={data.nightshiftSetting === 3}
onClick={() =>
act('nightshift', {
nightshift: 3,
})
}
/>
</Fragment>
}
/>
<LabeledList.Item
label="Emergency Lighting"
buttons={
<Button
icon="lightbulb-o"
content={data.emergencyLights ? 'Enabled' : 'Disabled'}
selected={data.emergencyLights}
onClick={() => act('emergency_lighting')}
/>
}
/>
</LabeledList>
</Section>
</Fragment>
);
};
const GridCheck = (props, context) => {
return (
<FullscreenNotice title="System Failure">
<Box fontSize="1.5rem" bold>
<Icon name="exclamation-triangle" verticalAlign="middle" size={3} mr="1rem" />
</Box>
<Box fontSize="1.5rem" bold>
Power surge detected, grid check in effect...
</Box>
</FullscreenNotice>
);
};
const ApcFailure = (props, context) => {
const { data, act } = useBackend(context);
let rebootOptions = <Button icon="repeat" content="Restart Now" color="good" onClick={() => act('reboot')} />;
if (data.locked && !data.siliconUser) {
rebootOptions = <Box color="bad">Swipe an ID card for manual reboot.</Box>;
}
return (
<Dimmer textAlign="center">
<Box color="bad">
<h1>SYSTEM FAILURE</h1>
</Box>
<Box color="average">
<h2>I/O regulators malfunction detected! Waiting for system reboot...</h2>
</Box>
<Box color="good">Automatic reboot in {data.failTime} seconds...</Box>
<Box mt={4}>{rebootOptions}</Box>
</Dimmer>
);
};

View File

@@ -0,0 +1,184 @@
import { useBackend, useSharedState } from '../backend';
import { Box, Button, LabeledList, Input, Section, Table, Tabs } from '../components';
import { Window } from '../layouts';
export const AccountsTerminal = (props, context) => {
const { act, data } = useBackend(context);
const { id_inserted, id_card, access_level, machine_id } = data;
return (
<Window width={400} height={640}>
<Window.Content scrollable>
<Section>
<LabeledList>
<LabeledList.Item label="Machine" color="average">
{machine_id}
</LabeledList.Item>
<LabeledList.Item label="ID">
<Button
icon={id_inserted ? 'eject' : 'sign-in-alt'}
fluid
content={id_card}
onClick={() => act('insert_card')}
/>
</LabeledList.Item>
</LabeledList>
</Section>
{access_level > 0 && <AccountTerminalContent />}
</Window.Content>
</Window>
);
};
const AccountTerminalContent = (props, context) => {
const { act, data } = useBackend(context);
const { creating_new_account, detailed_account_view } = data;
return (
<Section title="Menu">
<Tabs>
<Tabs.Tab
selected={!creating_new_account && !detailed_account_view}
icon="home"
onClick={() => act('view_accounts_list')}>
Home
</Tabs.Tab>
<Tabs.Tab selected={creating_new_account} icon="cog" onClick={() => act('create_account')}>
New Account
</Tabs.Tab>
<Tabs.Tab disabled={creating_new_account} icon="print" onClick={() => act('print')}>
Print
</Tabs.Tab>
</Tabs>
{(creating_new_account && <NewAccountView />) || (detailed_account_view && <DetailedView />) || <ListView />}
</Section>
);
};
const NewAccountView = (props, context) => {
const { act } = useBackend(context);
const [holder, setHolder] = useSharedState(context, 'holder', '');
const [newMoney, setMoney] = useSharedState(context, 'money', '');
return (
<Section title="Create Account" level={2}>
<LabeledList>
<LabeledList.Item label="Account Holder">
<Input value={holder} fluid onInput={(e, val) => setHolder(val)} />
</LabeledList.Item>
<LabeledList.Item label="Initial Deposit">
<Input value={newMoney} fluid onInput={(e, val) => setMoney(val)} />
</LabeledList.Item>
</LabeledList>
<Button
disabled={!holder || !newMoney}
mt={1}
fluid
icon="plus"
onClick={() =>
act('finalise_create_account', {
holder_name: holder,
starting_funds: newMoney,
})
}
content="Create"
/>
</Section>
);
};
const DetailedView = (props, context) => {
const { act, data } = useBackend(context);
const { access_level, station_account_number, account_number, owner_name, money, suspended, transactions } = data;
return (
<Section
title="Account Details"
level={2}
buttons={<Button icon="ban" selected={suspended} content="Suspend" onClick={() => act('toggle_suspension')} />}>
<LabeledList>
<LabeledList.Item label="Account Number">#{account_number}</LabeledList.Item>
<LabeledList.Item label="Holder">{owner_name}</LabeledList.Item>
<LabeledList.Item label="Balance">{money}</LabeledList.Item>
<LabeledList.Item label="Status" color={suspended ? 'bad' : 'good'}>
{suspended ? 'SUSPENDED' : 'Active'}
</LabeledList.Item>
</LabeledList>
<Section title="CentCom Administrator" level={2} mt={1}>
<LabeledList>
<LabeledList.Item label="Payroll">
<Button.Confirm
color="bad"
fluid
icon="ban"
confirmIcon="ban"
content="Revoke"
confirmContent="This cannot be undone."
disabled={account_number === station_account_number}
onClick={() => act('revoke_payroll')}
/>
</LabeledList.Item>
</LabeledList>
</Section>
{access_level >= 2 && (
<Section title="Silent Funds Transfer" level={2}>
<Button icon="plus" onClick={() => act('add_funds')} content="Add Funds" />
<Button icon="plus" onClick={() => act('remove_funds')} content="Remove Funds" />
</Section>
)}
<Section title="Transactions" level={2} mt={1}>
<Table>
<Table.Row header>
<Table.Cell>Timestamp</Table.Cell>
<Table.Cell>Target</Table.Cell>
<Table.Cell>Reason</Table.Cell>
<Table.Cell>Value</Table.Cell>
<Table.Cell>Terminal</Table.Cell>
</Table.Row>
{transactions.map((trans, i) => (
<Table.Row key={i}>
<Table.Cell>
{trans.date} {trans.time}
</Table.Cell>
<Table.Cell>{trans.target_name}</Table.Cell>
<Table.Cell>{trans.purpose}</Table.Cell>
<Table.Cell>{trans.amount}</Table.Cell>
<Table.Cell>{trans.source_terminal}</Table.Cell>
</Table.Row>
))}
</Table>
</Section>
</Section>
);
};
const ListView = (props, context) => {
const { act, data } = useBackend(context);
const { accounts } = data;
return (
<Section title="NanoTrasen Accounts" level={2}>
{(accounts.length && (
<LabeledList>
{accounts.map((acc) => (
<LabeledList.Item
label={acc.owner_name + acc.suspended}
color={acc.suspended ? 'bad' : null}
key={acc.account_index}>
<Button
fluid
content={'#' + acc.account_number}
onClick={() => act('view_account_detail', { 'account_index': acc.account_index })}
/>
</LabeledList.Item>
))}
</LabeledList>
)) || <Box color="bad">There are no accounts available.</Box>}
</Section>
);
};

View File

@@ -0,0 +1,88 @@
import { sortBy } from 'common/collections';
import { useBackend } from '../backend';
import { Button, Section, Table } from '../components';
import { Window } from '../layouts';
type Data = {
shuttles: Shuttle[];
overmap_ships: OvermapShip[];
};
type Shuttle = {
ref: string;
name: string;
current_location;
status;
};
type OvermapShip = {
ref: string;
name: string;
};
export const AdminShuttleController = () => {
return (
<Window width={600} height={600} resizable>
<Window.Content scrollable>
<ShuttleList />
</Window.Content>
</Window>
);
};
export const ShuttleList = (props, context) => {
const { act, data } = useBackend<Data>(context);
const { shuttles, overmap_ships } = data;
return (
<Section noTopPadding>
<Section title="Classic Shuttles">
<Table>
{sortBy((f: Shuttle) => f.name)(shuttles).map((shuttle) => (
<Table.Row key={shuttle.ref}>
<Table.Cell collapsing>
<Button m={0} content="JMP" onClick={() => act('adminobserve', { ref: shuttle.ref })} />
</Table.Cell>
<Table.Cell collapsing>
<Button m={0} content="Fly" onClick={() => act('classicmove', { ref: shuttle.ref })} />
</Table.Cell>
<Table.Cell>{shuttle.name}</Table.Cell>
<Table.Cell>{shuttle.current_location}</Table.Cell>
<Table.Cell>{shuttleStatusToWords(shuttle.status)}</Table.Cell>
</Table.Row>
))}
</Table>
</Section>
<Section title="Overmap Ships">
<Table>
{sortBy((f: OvermapShip) => f.name?.toLowerCase() || f.name || f.ref)(overmap_ships).map((ship) => (
<Table.Row key={ship.ref}>
<Table.Cell collapsing>
<Button content="JMP" onClick={() => act('adminobserve', { ref: ship.ref })} />
</Table.Cell>
<Table.Cell collapsing>
<Button content="Control" onClick={() => act('overmap_control', { ref: ship.ref })} />
</Table.Cell>
<Table.Cell>{ship.name}</Table.Cell>
</Table.Row>
))}
</Table>
</Section>
</Section>
);
};
/* Helpers */
const shuttleStatusToWords = (status) => {
switch (status) {
case 0:
return 'Idle';
case 1:
return 'Warmup';
case 2:
return 'Transit';
default:
return 'UNK';
}
};

View File

@@ -0,0 +1,68 @@
/* eslint react/no-danger: "off" */
import { useBackend } from '../backend';
import { Box, Button, LabeledList, Section } from '../components';
import { Window } from '../layouts';
const State = {
'open': 'Open',
'resolved': 'Resolved',
'closed': 'Closed',
'unknown': 'Unknown',
};
type Data = {
id: number;
title: string;
name: string;
state: string;
opened_at: number;
closed_at: number;
opened_at_date: string;
closed_at_date: string;
actions: string;
log: string[];
};
export const AdminTicketPanel = (props, context) => {
const { act, data } = useBackend<Data>(context);
const { id, title, name, state, opened_at, closed_at, opened_at_date, closed_at_date, actions, log } = data;
return (
<Window width={900} height={600}>
<Window.Content scrollable>
<Section
title={'Ticket #' + id}
buttons={
<Box nowrap>
<Button icon="pen" content="Rename Ticket" onClick={() => act('retitle')} />{' '}
<Button content="Legacy UI" onClick={() => act('legacy')} />
</Box>
}>
<LabeledList>
<LabeledList.Item label="Admin Help Ticket">
#{id}: <div dangerouslySetInnerHTML={{ __html: name }} />
</LabeledList.Item>
<LabeledList.Item label="State">{State[state]}</LabeledList.Item>
{State[state] === State.open ? (
<LabeledList.Item label="Opened At">
{opened_at_date} ({Math.round((opened_at / 600) * 10) / 10} minutes ago.)
</LabeledList.Item>
) : (
<LabeledList.Item label="Closed At">
{closed_at_date} ({Math.round((closed_at / 600) * 10) / 10} minutes ago.){' '}
<Button content="Reopen" onClick={() => act('reopen')} />
</LabeledList.Item>
)}
<LabeledList.Item label="Actions">
<div dangerouslySetInnerHTML={{ __html: actions }} />
</LabeledList.Item>
<LabeledList.Item label="Log">
{Object.keys(log).map((L) => (
<div dangerouslySetInnerHTML={{ __html: log[L] }} />
))}
</LabeledList.Item>
</LabeledList>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,46 @@
import { BooleanLike } from 'common/react';
import { useBackend } from '../backend';
import { Button, Section, Table } from '../components';
import { Window } from '../layouts';
type Data = {
entries: { name: string; value: string }[];
electronic_warfare: BooleanLike;
};
export const AgentCard = (props, context) => {
const { act, data } = useBackend<Data>(context);
const { entries, electronic_warfare } = data;
return (
<Window width={550} height={400} theme="syndicate">
<Window.Content>
<Section title="Info">
<Table>
{entries.map((a) => (
<Table.Row key={a.name}>
<Table.Cell>
<Button onClick={() => act(a.name.toLowerCase().replace(/ /g, ''))} icon="cog" />
</Table.Cell>
<Table.Cell>{a.name}</Table.Cell>
<Table.Cell>{a.value}</Table.Cell>
</Table.Row>
))}
</Table>
</Section>
<Section title="Electronic Warfare">
<Button.Checkbox
checked={electronic_warfare}
content={
electronic_warfare
? 'Electronic warfare is enabled. This will prevent you from being tracked by the AI.'
: 'Electronic warfare disabled.'
}
onClick={() => act('electronic_warfare')}
/>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,192 @@
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { Button, LabeledList, Section } from '../components';
import { Window } from '../layouts';
const dangerMap = {
2: {
color: 'good',
localStatusText: 'Optimal',
},
1: {
color: 'average',
localStatusText: 'Caution',
},
0: {
color: 'bad',
localStatusText: 'Offline',
},
};
export const AiAirlock = (props, context) => {
const { act, data } = useBackend(context);
const statusMain = dangerMap[data.power.main] || dangerMap[0];
const statusBackup = dangerMap[data.power.backup] || dangerMap[0];
const statusElectrify = dangerMap[data.shock] || dangerMap[0];
return (
<Window width={500} height={390}>
<Window.Content>
<Section title="Power Status">
<LabeledList>
<LabeledList.Item
label="Main"
color={statusMain.color}
buttons={
<Button
icon="lightbulb-o"
disabled={!data.power.main}
content="Disrupt"
onClick={() => act('disrupt-main')}
/>
}>
{data.power.main ? 'Online' : 'Offline'}{' '}
{((!data.wires.main_1 || !data.wires.main_2) && '[Wires have been cut!]') ||
(data.power.main_timeleft > 0 && `[${data.power.main_timeleft}s]`)}
</LabeledList.Item>
<LabeledList.Item
label="Backup"
color={statusBackup.color}
buttons={
<Button
icon="lightbulb-o"
disabled={!data.power.backup}
content="Disrupt"
onClick={() => act('disrupt-backup')}
/>
}>
{data.power.backup ? 'Online' : 'Offline'}{' '}
{((!data.wires.backup_1 || !data.wires.backup_2) && '[Wires have been cut!]') ||
(data.power.backup_timeleft > 0 && `[${data.power.backup_timeleft}s]`)}
</LabeledList.Item>
<LabeledList.Item
label="Electrify"
color={statusElectrify.color}
buttons={
<Fragment>
<Button
icon="wrench"
disabled={!(data.wires.shock && data.shock === 0)}
content="Restore"
onClick={() => act('shock-restore')}
/>
<Button
icon="bolt"
disabled={!data.wires.shock}
content="Temporary"
onClick={() => act('shock-temp')}
/>
<Button
icon="bolt"
disabled={!data.wires.shock}
content="Permanent"
onClick={() => act('shock-perm')}
/>
</Fragment>
}>
{data.shock === 2 ? 'Safe' : 'Electrified'}{' '}
{(!data.wires.shock && '[Wires have been cut!]') ||
(data.shock_timeleft > 0 && `[${data.shock_timeleft}s]`) ||
(data.shock_timeleft === -1 && '[Permanent]')}
</LabeledList.Item>
</LabeledList>
</Section>
<Section title="Access and Door Control">
<LabeledList>
<LabeledList.Item
label="ID Scan"
color="bad"
buttons={
<Button
icon={data.id_scanner ? 'power-off' : 'times'}
content={data.id_scanner ? 'Enabled' : 'Disabled'}
selected={data.id_scanner}
disabled={!data.wires.id_scanner}
onClick={() => act('idscan-toggle')}
/>
}>
{!data.wires.id_scanner && '[Wires have been cut!]'}
</LabeledList.Item>
<LabeledList.Divider />
<LabeledList.Item
label="Door Bolts"
color="bad"
buttons={
<Button
icon={data.locked ? 'lock' : 'unlock'}
content={data.locked ? 'Lowered' : 'Raised'}
selected={data.locked}
disabled={!data.wires.bolts}
onClick={() => act('bolt-toggle')}
/>
}>
{!data.wires.bolts && '[Wires have been cut!]'}
</LabeledList.Item>
<LabeledList.Item
label="Door Bolt Lights"
color="bad"
buttons={
<Button
icon={data.lights ? 'power-off' : 'times'}
content={data.lights ? 'Enabled' : 'Disabled'}
selected={data.lights}
disabled={!data.wires.lights}
onClick={() => act('light-toggle')}
/>
}>
{!data.wires.lights && '[Wires have been cut!]'}
</LabeledList.Item>
<LabeledList.Item
label="Door Force Sensors"
color="bad"
buttons={
<Button
icon={data.safe ? 'power-off' : 'times'}
content={data.safe ? 'Enabled' : 'Disabled'}
selected={data.safe}
disabled={!data.wires.safe}
onClick={() => act('safe-toggle')}
/>
}>
{!data.wires.safe && '[Wires have been cut!]'}
</LabeledList.Item>
<LabeledList.Item
label="Door Timing Safety"
color="bad"
buttons={
<Button
icon={data.speed ? 'power-off' : 'times'}
content={data.speed ? 'Enabled' : 'Disabled'}
selected={data.speed}
disabled={!data.wires.timing}
onClick={() => act('speed-toggle')}
/>
}>
{!data.wires.timing && '[Wires have been cut!]'}
</LabeledList.Item>
<LabeledList.Divider />
<LabeledList.Item
label="Door Control"
color="bad"
buttons={
<Button
icon={data.opened ? 'sign-out-alt' : 'sign-in-alt'}
content={data.opened ? 'Open' : 'Closed'}
selected={data.opened}
disabled={data.locked || data.welded}
onClick={() => act('open-close')}
/>
}>
{!!(data.locked || data.welded) && (
<span>
[Door is {data.locked ? 'bolted' : ''}
{data.locked && data.welded ? ' and ' : ''}
{data.welded ? 'welded' : ''}!]
</span>
)}
</LabeledList.Item>
</LabeledList>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,77 @@
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { Box, Button, LabeledList, NoticeBox, ProgressBar, Section } from '../components';
import { Window } from '../layouts';
export const AiRestorer = () => {
return (
<Window width={370} height={360} resizable>
<Window.Content scrollable>
<AiRestorerContent />
</Window.Content>
</Window>
);
};
export const AiRestorerContent = (props, context) => {
const { act, data } = useBackend(context);
const { AI_present, error, name, laws, isDead, restoring, health, ejectable } = data;
return (
<Fragment>
{error && <NoticeBox textAlign="center">{error}</NoticeBox>}
{!!ejectable && (
<Button
fluid
icon="eject"
content={AI_present ? name : '----------'}
disabled={!AI_present}
onClick={() => act('PRG_eject')}
/>
)}
{!!AI_present && (
<Section
title={ejectable ? 'System Status' : name}
buttons={
<Box inline bold color={isDead ? 'bad' : 'good'}>
{isDead ? 'Nonfunctional' : 'Functional'}
</Box>
}>
<LabeledList>
<LabeledList.Item label="Integrity">
<ProgressBar
value={health}
minValue={0}
maxValue={100}
ranges={{
good: [70, Infinity],
average: [50, 70],
bad: [-Infinity, 50],
}}
/>
</LabeledList.Item>
</LabeledList>
{!!restoring && (
<Box bold textAlign="center" fontSize="20px" color="good" mt={1}>
RECONSTRUCTION IN PROGRESS
</Box>
)}
<Button
fluid
icon="plus"
content="Begin Reconstruction"
disabled={restoring}
mt={1}
onClick={() => act('PRG_beginReconstruction')}
/>
<Section title="Laws" level={2}>
{laws.map((law) => (
<Box key={law} className="candystripe">
{law}
</Box>
))}
</Section>
</Section>
)}
</Fragment>
);
};

View File

@@ -0,0 +1,68 @@
import { useBackend } from '../backend';
import { Box, Icon, LabeledList, ProgressBar, Section } from '../components';
import { Window } from '../layouts';
import { FullscreenNotice } from './common/FullscreenNotice';
export const AiSupermatter = (props, context) => {
const { data } = useBackend(context);
const { integrity_percentage, ambient_temp, ambient_pressure, detonating } = data;
let body = <AiSupermatterContent />;
if (detonating) {
body = <AiSupermatterDetonation />;
}
return (
<Window width={500} height={300}>
<Window.Content>{body}</Window.Content>
</Window>
);
};
const AiSupermatterDetonation = (props, context) => (
<FullscreenNotice title="DETONATION IMMINENT">
<Box fontSize="1.5rem" bold color="bad">
<Icon color="bad" name="exclamation-triangle" verticalAlign="middle" size={3} mr="1rem" />
<Box color="bad">CRYSTAL DELAMINATING</Box>
<Box color="bad">Evacuate area immediately</Box>
</Box>
</FullscreenNotice>
);
const AiSupermatterContent = (props, context) => {
const { data } = useBackend(context);
const { integrity_percentage, ambient_temp, ambient_pressure } = data;
return (
<Section title="Status">
<LabeledList>
<LabeledList.Item label="Crystal Integrity">
<ProgressBar
value={integrity_percentage}
maxValue={100}
ranges={{
good: [90, Infinity],
average: [25, 90],
bad: [-Infinity, 25],
}}
/>
</LabeledList.Item>
<LabeledList.Item label="Environment Temperature">
<ProgressBar
value={ambient_temp}
maxValue={10000}
ranges={{
bad: [5000, Infinity],
average: [4000, 5000],
good: [-Infinity, 4000],
}}>
{ambient_temp} K
</ProgressBar>
</LabeledList.Item>
<LabeledList.Item label="Environment Pressure">{ambient_pressure} kPa</LabeledList.Item>
</LabeledList>
</Section>
);
};

View File

@@ -0,0 +1,260 @@
import { toFixed } from 'common/math';
import { Fragment } from 'inferno';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, LabeledList, Section } from '../components';
import { getGasLabel, getGasColor } from '../constants';
import { Window } from '../layouts';
import { InterfaceLockNoticeBox } from './common/InterfaceLockNoticeBox';
import { Vent, Scrubber } from './common/AtmosControls';
export const AirAlarm = (props, context) => {
const { act, data } = useBackend(context);
const locked = data.locked && !data.siliconUser && !data.remoteUser;
return (
<Window width={440} height={650} resizable>
<Window.Content scrollable>
<InterfaceLockNoticeBox />
<AirAlarmStatus />
<AirAlarmUnlockedControl />
{!locked && <AirAlarmControl />}
</Window.Content>
</Window>
);
};
const AirAlarmStatus = (props, context) => {
const { data } = useBackend(context);
const entries = (data.environment_data || []).filter((entry) => entry.value >= 0.01);
const dangerMap = {
0: {
color: 'good',
localStatusText: 'Optimal',
},
1: {
color: 'average',
localStatusText: 'Caution',
},
2: {
color: 'bad',
localStatusText: 'Danger (Internals Required)',
},
};
const localStatus = dangerMap[data.danger_level] || dangerMap[0];
return (
<Section title="Air Status">
<LabeledList>
{(entries.length > 0 && (
<Fragment>
{entries.map((entry) => {
const status = dangerMap[entry.danger_level] || dangerMap[0];
return (
<LabeledList.Item key={entry.name} label={getGasLabel(entry.name)} color={status.color}>
{toFixed(entry.value, 2)}
{entry.unit}
</LabeledList.Item>
);
})}
<LabeledList.Item label="Local status" color={localStatus.color}>
{localStatus.localStatusText}
</LabeledList.Item>
<LabeledList.Item label="Area status" color={data.atmos_alarm || data.fire_alarm ? 'bad' : 'good'}>
{(data.atmos_alarm && 'Atmosphere Alarm') || (data.fire_alarm && 'Fire Alarm') || 'Nominal'}
</LabeledList.Item>
</Fragment>
)) || (
<LabeledList.Item label="Warning" color="bad">
Cannot obtain air sample for analysis.
</LabeledList.Item>
)}
{!!data.emagged && (
<LabeledList.Item label="Warning" color="bad">
Safety measures offline. Device may exhibit abnormal behavior.
</LabeledList.Item>
)}
</LabeledList>
</Section>
);
};
const AirAlarmUnlockedControl = (props, context) => {
const { act, data } = useBackend(context);
const { target_temperature, rcon } = data;
return (
<Section title="Comfort Settings">
<LabeledList>
<LabeledList.Item label="Remote Control">
<Button selected={rcon === 1} content="Off" onClick={() => act('rcon', { 'rcon': 1 })} />
<Button selected={rcon === 2} content="Auto" onClick={() => act('rcon', { 'rcon': 2 })} />
<Button selected={rcon === 3} content="On" onClick={() => act('rcon', { 'rcon': 3 })} />
</LabeledList.Item>
<LabeledList.Item label="Thermostat">
<Button content={target_temperature} onClick={() => act('temperature')} />
</LabeledList.Item>
</LabeledList>
</Section>
);
};
const AIR_ALARM_ROUTES = {
home: {
title: 'Air Controls',
component: () => AirAlarmControlHome,
},
vents: {
title: 'Vent Controls',
component: () => AirAlarmControlVents,
},
scrubbers: {
title: 'Scrubber Controls',
component: () => AirAlarmControlScrubbers,
},
modes: {
title: 'Operating Mode',
component: () => AirAlarmControlModes,
},
thresholds: {
title: 'Alarm Thresholds',
component: () => AirAlarmControlThresholds,
},
};
const AirAlarmControl = (props, context) => {
const [screen, setScreen] = useLocalState(context, 'screen');
const route = AIR_ALARM_ROUTES[screen] || AIR_ALARM_ROUTES.home;
const Component = route.component();
return (
<Section
title={route.title}
buttons={screen && <Button icon="arrow-left" content="Back" onClick={() => setScreen()} />}>
<Component />
</Section>
);
};
// Home screen
// --------------------------------------------------------
const AirAlarmControlHome = (props, context) => {
const { act, data } = useBackend(context);
const [screen, setScreen] = useLocalState(context, 'screen');
const { mode, atmos_alarm } = data;
return (
<Fragment>
<Button
icon={atmos_alarm ? 'exclamation-triangle' : 'exclamation'}
color={atmos_alarm && 'caution'}
content="Area Atmosphere Alarm"
onClick={() => act(atmos_alarm ? 'reset' : 'alarm')}
/>
<Box mt={1} />
<Button
icon={mode === 3 ? 'exclamation-triangle' : 'exclamation'}
color={mode === 3 && 'danger'}
content="Panic Siphon"
onClick={() =>
act('mode', {
mode: mode === 3 ? 1 : 3,
})
}
/>
<Box mt={2} />
<Button icon="sign-out-alt" content="Vent Controls" onClick={() => setScreen('vents')} />
<Box mt={1} />
<Button icon="filter" content="Scrubber Controls" onClick={() => setScreen('scrubbers')} />
<Box mt={1} />
<Button icon="cog" content="Operating Mode" onClick={() => setScreen('modes')} />
<Box mt={1} />
<Button icon="chart-bar" content="Alarm Thresholds" onClick={() => setScreen('thresholds')} />
</Fragment>
);
};
// Vents
// --------------------------------------------------------
const AirAlarmControlVents = (props, context) => {
const { data } = useBackend(context);
const { vents } = data;
if (!vents || vents.length === 0) {
return 'Nothing to show';
}
return vents.map((vent) => <Vent key={vent.id_tag} vent={vent} />);
};
// Scrubbers
// --------------------------------------------------------
const AirAlarmControlScrubbers = (props, context) => {
const { data } = useBackend(context);
const { scrubbers } = data;
if (!scrubbers || scrubbers.length === 0) {
return 'Nothing to show';
}
return scrubbers.map((scrubber) => <Scrubber key={scrubber.id_tag} scrubber={scrubber} />);
};
// Modes
// --------------------------------------------------------
const AirAlarmControlModes = (props, context) => {
const { act, data } = useBackend(context);
const { modes } = data;
if (!modes || modes.length === 0) {
return 'Nothing to show';
}
return modes.map((mode) => (
<Fragment key={mode.mode}>
<Button
icon={mode.selected ? 'check-square-o' : 'square-o'}
selected={mode.selected}
color={mode.selected && mode.danger && 'danger'}
content={mode.name}
onClick={() => act('mode', { mode: mode.mode })}
/>
<Box mt={1} />
</Fragment>
));
};
// Thresholds
// --------------------------------------------------------
const AirAlarmControlThresholds = (props, context) => {
const { act, data } = useBackend(context);
const { thresholds } = data;
return (
<table className="LabeledList" style={{ width: '100%' }}>
<thead>
<tr>
<td />
<td className="color-bad">min2</td>
<td className="color-average">min1</td>
<td className="color-average">max1</td>
<td className="color-bad">max2</td>
</tr>
</thead>
<tbody>
{thresholds.map((threshold) => (
<tr key={threshold.name}>
<td className="LabeledList__label">
<span className={'color-' + getGasColor(threshold.name)}>{getGasLabel(threshold.name)}</span>
</td>
{threshold.settings.map((setting) => (
<td key={setting.val}>
<Button
content={toFixed(setting.selected, 2)}
onClick={() =>
act('threshold', {
env: setting.env,
var: setting.val,
})
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
);
};

View File

@@ -0,0 +1,129 @@
import { Loader } from './common/Loader';
import { useBackend, useLocalState } from '../backend';
import { KEY_ENTER, KEY_ESCAPE, KEY_LEFT, KEY_RIGHT, KEY_SPACE, KEY_TAB } from '../../common/keycodes';
import { Autofocus, Box, Button, Flex, Section, Stack } from '../components';
import { Window } from '../layouts';
type AlertModalData = {
autofocus: boolean;
buttons: string[];
large_buttons: boolean;
message: string;
swapped_buttons: boolean;
timeout: number;
title: string;
};
const KEY_DECREMENT = -1;
const KEY_INCREMENT = 1;
export const AlertModal = (props, context) => {
const { act, data } = useBackend<AlertModalData>(context);
const { autofocus, buttons = [], large_buttons, message = '', timeout, title } = data;
const [selected, setSelected] = useLocalState<number>(context, 'selected', 0);
// Dynamically sets window dimensions
const windowHeight =
115 + (message.length > 30 ? Math.ceil(message.length / 4) : 0) + (message.length && large_buttons ? 5 : 0);
const windowWidth = 325 + (buttons.length > 2 ? 55 : 0);
const onKey = (direction: number) => {
if (selected === 0 && direction === KEY_DECREMENT) {
setSelected(buttons.length - 1);
} else if (selected === buttons.length - 1 && direction === KEY_INCREMENT) {
setSelected(0);
} else {
setSelected(selected + direction);
}
};
return (
<Window height={windowHeight} title={title} width={windowWidth}>
{!!timeout && <Loader value={timeout} />}
<Window.Content
onKeyDown={(e) => {
const keyCode = window.event ? e.which : e.keyCode;
/**
* Simulate a click when pressing space or enter,
* allow keyboard navigation, override tab behavior
*/
if (keyCode === KEY_SPACE || keyCode === KEY_ENTER) {
act('choose', { choice: buttons[selected] });
} else if (keyCode === KEY_ESCAPE) {
act('cancel');
} else if (keyCode === KEY_LEFT) {
e.preventDefault();
onKey(KEY_DECREMENT);
} else if (keyCode === KEY_TAB || keyCode === KEY_RIGHT) {
e.preventDefault();
onKey(KEY_INCREMENT);
}
}}>
<Section fill>
<Stack fill vertical>
<Stack.Item grow m={1}>
<Box color="label" overflow="hidden">
{message}
</Box>
</Stack.Item>
<Stack.Item>
{!!autofocus && <Autofocus />}
<ButtonDisplay selected={selected} />
</Stack.Item>
</Stack>
</Section>
</Window.Content>
</Window>
);
};
/**
* Displays a list of buttons ordered by user prefs.
* Technically this handles more than 2 buttons, but you
* should just be using a list input in that case.
*/
const ButtonDisplay = (props, context) => {
const { data } = useBackend<AlertModalData>(context);
const { buttons = [], large_buttons, swapped_buttons } = data;
const { selected } = props;
return (
<Flex align="center" direction={!swapped_buttons ? 'row-reverse' : 'row'} fill justify="space-around" wrap>
{buttons?.map((button, index) =>
!!large_buttons && buttons.length < 3 ? (
<Flex.Item grow key={index}>
<AlertButton button={button} id={index.toString()} selected={selected === index} />
</Flex.Item>
) : (
<Flex.Item key={index}>
<AlertButton button={button} id={index.toString()} selected={selected === index} />
</Flex.Item>
)
)}
</Flex>
);
};
/**
* Displays a button with variable sizing.
*/
const AlertButton = (props, context) => {
const { act, data } = useBackend<AlertModalData>(context);
const { large_buttons } = data;
const { button, selected } = props;
const buttonWidth = button.length > 7 ? button.length : 7;
return (
<Button
fluid={!!large_buttons}
height={!!large_buttons && 2}
onClick={() => act('choose', { choice: button })}
m={0.5}
pl={2}
pr={2}
pt={large_buttons ? 0.33 : 0}
selected={selected}
textAlign="center"
width={!large_buttons && buttonWidth}>
{!large_buttons ? button : button.toUpperCase()}
</Button>
);
};

View File

@@ -0,0 +1,82 @@
import { useBackend } from '../backend';
import { Box, Button, NoticeBox, LabeledList, ProgressBar, Section, Table } from '../components';
import { Window } from '../layouts';
import { capitalize } from 'common/string';
export const AlgaeFarm = (props, context) => {
const { act, data } = useBackend(context);
const { usePower, materials, last_flow_rate, last_power_draw, inputDir, outputDir, input, output, errorText } = data;
return (
<Window width={500} height={300} resizable>
<Window.Content>
{errorText && (
<NoticeBox warning>
<Box display="inline-block" verticalAlign="middle">
{errorText}
</Box>
</NoticeBox>
)}
<Section
title="Status"
buttons={
<Button icon="power-off" content="Processing" selected={usePower === 2} onClick={() => act('toggle')} />
}>
<LabeledList>
<LabeledList.Item label="Flow Rate">{last_flow_rate} L/s</LabeledList.Item>
<LabeledList.Item label="Power Draw">{last_power_draw} W</LabeledList.Item>
<LabeledList.Divider size={1} />
{materials.map((material) => (
<LabeledList.Item key={material.name} label={capitalize(material.display)}>
<ProgressBar width="80%" value={material.qty} maxValue={material.max}>
{material.qty}/{material.max}
</ProgressBar>
<Button
ml={1}
content="Eject"
onClick={() =>
act('ejectMaterial', {
mat: material.name,
})
}
/>
</LabeledList.Item>
))}
</LabeledList>
<Table mt={1}>
<Table.Row>
<Table.Cell>
<Section title={'Gas Input (' + inputDir + ')'}>
{input ? (
<LabeledList>
<LabeledList.Item label="Total Pressure">{input.pressure} kPa</LabeledList.Item>
<LabeledList.Item label={input.name}>
{input.percent}% ({input.moles} moles)
</LabeledList.Item>
</LabeledList>
) : (
<Box color="bad">No connection detected.</Box>
)}
</Section>
</Table.Cell>
<Table.Cell>
<Section title={'Gas Output (' + outputDir + ')'}>
{output ? (
<LabeledList>
<LabeledList.Item label="Total Pressure">{output.pressure} kPa</LabeledList.Item>
<LabeledList.Item label={output.name}>
{output.percent}% ({output.moles} moles)
</LabeledList.Item>
</LabeledList>
) : (
<Box color="bad">No connection detected.</Box>
)}
</Section>
</Table.Cell>
</Table.Row>
</Table>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,415 @@
import { sortBy } from 'common/collections';
import { capitalize, decodeHtmlEntities } from 'common/string';
import { Fragment } from 'inferno';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, ByondUi, Flex, LabeledList, Section, Tabs, ColorBox } from '../components';
import { Window } from '../layouts';
export const AppearanceChanger = (props, context) => {
const { act, config, data } = useBackend(context);
const {
name,
specimen,
gender,
gender_id,
hair_style,
facial_hair_style,
ear_style,
tail_style,
wing_style,
markings,
change_race,
change_gender,
change_eye_color,
change_skin_tone,
change_skin_color,
change_hair_color,
change_facial_hair_color,
change_hair,
change_facial_hair,
mapRef,
} = data;
const { title } = config;
const change_color =
change_eye_color || change_skin_tone || change_skin_color || change_hair_color || change_facial_hair_color;
let firstAccesibleTab = -1;
if (change_race) {
firstAccesibleTab = 0;
} else if (change_gender) {
firstAccesibleTab = 1;
} else if (change_color) {
firstAccesibleTab = 2;
} else if (change_hair) {
firstAccesibleTab = 4;
} else if (change_facial_hair) {
firstAccesibleTab = 5;
}
const [tabIndex, setTabIndex] = useLocalState(context, 'tabIndex', firstAccesibleTab);
return (
<Window width={700} height={650} title={decodeHtmlEntities(title)}>
<Window.Content>
<Section title="Reflection">
<Flex>
<Flex.Item grow={1}>
<LabeledList>
<LabeledList.Item label="Name">{name}</LabeledList.Item>
<LabeledList.Item label="Species" color={!change_race ? 'grey' : null}>
{specimen}
</LabeledList.Item>
<LabeledList.Item label="Biological Sex" color={!change_gender ? 'grey' : null}>
{gender ? capitalize(gender) : 'Not Set'}
</LabeledList.Item>
<LabeledList.Item label="Gender Identity" color={!change_color ? 'grey' : null}>
{gender_id ? capitalize(gender_id) : 'Not Set'}
</LabeledList.Item>
<LabeledList.Item label="Hair Style" color={!change_hair ? 'grey' : null}>
{hair_style ? capitalize(hair_style) : 'Not Set'}
</LabeledList.Item>
<LabeledList.Item label="Facial Hair Style" color={!change_facial_hair ? 'grey' : null}>
{facial_hair_style ? capitalize(facial_hair_style) : 'Not Set'}
</LabeledList.Item>
<LabeledList.Item label="Ear Style" color={!change_hair ? 'grey' : null}>
{ear_style ? capitalize(ear_style) : 'Not Set'}
</LabeledList.Item>
<LabeledList.Item label="Tail Style" color={!change_hair ? 'grey' : null}>
{tail_style ? capitalize(tail_style) : 'Not Set'}
</LabeledList.Item>
<LabeledList.Item label="Wing Style" color={!change_hair ? 'grey' : null}>
{wing_style ? capitalize(wing_style) : 'Not Set'}
</LabeledList.Item>
</LabeledList>
</Flex.Item>
<Flex.Item>
<ByondUi
style={{
width: '256px',
height: '256px',
}}
params={{
id: mapRef,
type: 'map',
}}
/>
</Flex.Item>
</Flex>
</Section>
<Tabs>
{change_race ? (
<Tabs.Tab selected={tabIndex === 0} onClick={() => setTabIndex(0)}>
Race
</Tabs.Tab>
) : null}
{change_gender ? (
<Tabs.Tab selected={tabIndex === 1} onClick={() => setTabIndex(1)}>
Gender & Sex
</Tabs.Tab>
) : null}
{change_color ? (
<Tabs.Tab selected={tabIndex === 2} onClick={() => setTabIndex(2)}>
Colors
</Tabs.Tab>
) : null}
{change_hair ? (
<Fragment>
<Tabs.Tab selected={tabIndex === 3} onClick={() => setTabIndex(3)}>
Hair
</Tabs.Tab>
<Tabs.Tab selected={tabIndex === 5} onClick={() => setTabIndex(5)}>
Ear
</Tabs.Tab>
<Tabs.Tab selected={tabIndex === 6} onClick={() => setTabIndex(6)}>
Tail
</Tabs.Tab>
<Tabs.Tab selected={tabIndex === 7} onClick={() => setTabIndex(7)}>
Wing
</Tabs.Tab>
<Tabs.Tab selected={tabIndex === 8} onClick={() => setTabIndex(8)}>
Markings
</Tabs.Tab>
</Fragment>
) : null}
{change_facial_hair ? (
<Tabs.Tab selected={tabIndex === 4} onClick={() => setTabIndex(4)}>
Facial Hair
</Tabs.Tab>
) : null}
</Tabs>
<Box height="43%">
{change_race && tabIndex === 0 ? <AppearanceChangerSpecies /> : null}
{change_gender && tabIndex === 1 ? <AppearanceChangerGender /> : null}
{change_color && tabIndex === 2 ? <AppearanceChangerColors /> : null}
{change_hair && tabIndex === 3 ? <AppearanceChangerHair /> : null}
{change_facial_hair && tabIndex === 4 ? <AppearanceChangerFacialHair /> : null}
{change_hair && tabIndex === 5 ? <AppearanceChangerEars /> : null}
{change_hair && tabIndex === 6 ? <AppearanceChangerTails /> : null}
{change_hair && tabIndex === 7 ? <AppearanceChangerWings /> : null}
{change_hair && tabIndex === 8 ? <AppearanceChangerMarkings /> : null}
</Box>
</Window.Content>
</Window>
);
};
const AppearanceChangerSpecies = (props, context) => {
const { act, data } = useBackend(context);
const { species, specimen } = data;
const sortedSpecies = sortBy((val) => val.specimen)(species || []);
return (
<Section title="Species" fill scrollable>
{sortedSpecies.map((spec) => (
<Button
key={spec.specimen}
content={spec.specimen}
selected={specimen === spec.specimen}
onClick={() => act('race', { race: spec.specimen })}
/>
))}
</Section>
);
};
const AppearanceChangerGender = (props, context) => {
const { act, data } = useBackend(context);
const { gender, gender_id, genders, id_genders } = data;
return (
<Section title="Gender & Sex" fill scrollable>
<LabeledList>
<LabeledList.Item label="Biological Sex">
{genders.map((g) => (
<Button
key={g.gender_key}
selected={g.gender_key === gender}
content={g.gender_name}
onClick={() => act('gender', { 'gender': g.gender_key })}
/>
))}
</LabeledList.Item>
<LabeledList.Item label="Gender Identity">
{id_genders.map((g) => (
<Button
key={g.gender_key}
selected={g.gender_key === gender_id}
content={g.gender_name}
onClick={() => act('gender_id', { 'gender_id': g.gender_key })}
/>
))}
</LabeledList.Item>
</LabeledList>
</Section>
);
};
const AppearanceChangerColors = (props, context) => {
const { act, data } = useBackend(context);
const {
change_eye_color,
change_skin_tone,
change_skin_color,
change_hair_color,
change_facial_hair_color,
eye_color,
skin_color,
hair_color,
facial_hair_color,
ears_color,
ears2_color,
tail_color,
tail2_color,
wing_color,
wing2_color,
} = data;
return (
<Section title="Colors" fill scrollable>
{change_eye_color ? (
<Box>
<ColorBox color={eye_color} mr={1} />
<Button content="Change Eye Color" onClick={() => act('eye_color')} />
</Box>
) : null}
{change_skin_tone ? (
<Box>
<Button content="Change Skin Tone" onClick={() => act('skin_tone')} />
</Box>
) : null}
{change_skin_color ? (
<Box>
<ColorBox color={skin_color} mr={1} />
<Button content="Change Skin Color" onClick={() => act('skin_color')} />
</Box>
) : null}
{change_hair_color ? (
<Fragment>
<Box>
<ColorBox color={hair_color} mr={1} />
<Button content="Change Hair Color" onClick={() => act('hair_color')} />
</Box>
<Box>
<ColorBox color={ears_color} mr={1} />
<Button content="Change Ears Color" onClick={() => act('ears_color')} />
</Box>
<Box>
<ColorBox color={ears2_color} mr={1} />
<Button content="Change Secondary Ears Color" onClick={() => act('ears2_color')} />
</Box>
<Box>
<ColorBox color={tail_color} mr={1} />
<Button content="Change Tail Color" onClick={() => act('tail_color')} />
</Box>
<Box>
<ColorBox color={tail2_color} mr={1} />
<Button content="Change Secondary Tail Color" onClick={() => act('tail2_color')} />
</Box>
<Box>
<ColorBox color={wing_color} mr={1} />
<Button content="Change Wing Color" onClick={() => act('wing_color')} />
</Box>
<Box>
<ColorBox color={wing2_color} mr={1} />
<Button content="Change Secondary Wing Color" onClick={() => act('wing2_color')} />
</Box>
</Fragment>
) : null}
{change_facial_hair_color ? (
<Box>
<ColorBox color={facial_hair_color} mr={1} />
<Button content="Change Facial Hair Color" onClick={() => act('facial_hair_color')} />
</Box>
) : null}
</Section>
);
};
const AppearanceChangerHair = (props, context) => {
const { act, data } = useBackend(context);
const { hair_style, hair_styles } = data;
return (
<Section title="Hair" fill scrollable>
{hair_styles.map((hair) => (
<Button
key={hair.hairstyle}
onClick={() => act('hair', { hair: hair.hairstyle })}
selected={hair.hairstyle === hair_style}
content={hair.hairstyle}
/>
))}
</Section>
);
};
const AppearanceChangerFacialHair = (props, context) => {
const { act, data } = useBackend(context);
const { facial_hair_style, facial_hair_styles } = data;
return (
<Section title="Facial Hair" fill scrollable>
{facial_hair_styles.map((hair) => (
<Button
key={hair.facialhairstyle}
onClick={() => act('facial_hair', { facial_hair: hair.facialhairstyle })}
selected={hair.facialhairstyle === facial_hair_style}
content={hair.facialhairstyle}
/>
))}
</Section>
);
};
const AppearanceChangerEars = (props, context) => {
const { act, data } = useBackend(context);
const { ear_style, ear_styles } = data;
return (
<Section title="Ears" fill scrollable>
<Button onClick={() => act('ear', { clear: true })} selected={ear_style === null} content="-- Not Set --" />
{sortBy((e) => e.name.toLowerCase())(ear_styles).map((ear) => (
<Button
key={ear.instance}
onClick={() => act('ear', { ref: ear.instance })}
selected={ear.name === ear_style}
content={ear.name}
/>
))}
</Section>
);
};
const AppearanceChangerTails = (props, context) => {
const { act, data } = useBackend(context);
const { tail_style, tail_styles } = data;
return (
<Section title="Tails" fill scrollable>
<Button onClick={() => act('tail', { clear: true })} selected={tail_style === null} content="-- Not Set --" />
{sortBy((e) => e.name.toLowerCase())(tail_styles).map((tail) => (
<Button
key={tail.instance}
onClick={() => act('tail', { ref: tail.instance })}
selected={tail.name === tail_style}
content={tail.name}
/>
))}
</Section>
);
};
const AppearanceChangerWings = (props, context) => {
const { act, data } = useBackend(context);
const { wing_style, wing_styles } = data;
return (
<Section title="Wings" fill scrollable>
<Button onClick={() => act('wing', { clear: true })} selected={wing_style === null} content="-- Not Set --" />
{sortBy((e) => e.name.toLowerCase())(wing_styles).map((wing) => (
<Button
key={wing.instance}
onClick={() => act('wing', { ref: wing.instance })}
selected={wing.name === wing_style}
content={wing.name}
/>
))}
</Section>
);
};
const AppearanceChangerMarkings = (props, context) => {
const { act, data } = useBackend(context);
const { markings } = data;
return (
<Section title="Markings" fill scrollable>
<Box>
<Button content="Add Marking" onClick={() => act('marking', { todo: 1, name: 'na' })} />
</Box>
<LabeledList>
{markings.map((m) => (
<LabeledList.Item key={m.marking_name} label={m.marking_name}>
<ColorBox color={m.marking_color} mr={1} />
<Button content="Change Color" onClick={() => act('marking', { todo: 4, name: m.marking_name })} />
<Button content="-" onClick={() => act('marking', { todo: 0, name: m.marking_name })} />
<Button content="Move down" onClick={() => act('marking', { todo: 3, name: m.marking_name })} />
<Button content="Move up" onClick={() => act('marking', { todo: 2, name: m.marking_name })} />
</LabeledList.Item>
))}
</LabeledList>
</Section>
);
};

View File

@@ -0,0 +1,107 @@
import { useBackend } from '../backend';
import { Box, Button, Flex, LabeledList, ProgressBar, Section } from '../components';
import { Window } from '../layouts';
export const ArcadeBattle = (props, context) => {
const { act, data } = useBackend(context);
const { name, temp, enemyAction, enemyName, playerHP, playerMP, enemyHP, enemyMP, gameOver } = data;
return (
<Window width={400} height={240} resizable>
<Window.Content>
<Section title={enemyName} textAlign="center">
<Section color="label">
<Box>{temp}</Box>
<Box>{!gameOver && enemyAction}</Box>
</Section>
<Flex spacing={1}>
<Flex.Item>
<LabeledList>
<LabeledList.Item label="Player Health">
<ProgressBar
value={playerHP}
minValue={0}
maxValue={30}
ranges={{
olive: [31, Infinity],
good: [20, 31],
average: [10, 20],
bad: [-Infinity, 10],
}}>
{playerHP}HP
</ProgressBar>
</LabeledList.Item>
<LabeledList.Item label="Player Magic">
<ProgressBar
value={playerMP}
minValue={0}
maxValue={10}
ranges={{
purple: [11, Infinity],
violet: [3, 11],
bad: [-Infinity, 3],
}}>
{playerMP}MP
</ProgressBar>
</LabeledList.Item>
</LabeledList>
</Flex.Item>
<Flex.Item>
<LabeledList>
<LabeledList.Item label="Enemy HP">
<ProgressBar
value={enemyHP}
minValue={0}
maxValue={45}
ranges={{
olive: [31, Infinity],
good: [20, 31],
average: [10, 20],
bad: [-Infinity, 10],
}}>
{enemyHP}HP
</ProgressBar>
</LabeledList.Item>
</LabeledList>
</Flex.Item>
</Flex>
{(gameOver && <Button fluid mt={1} color="green" content="New Game" onClick={() => act('newgame')} />) || (
<Flex mt={2} justify="space-between" spacing={1}>
<Flex.Item grow={1}>
<Button
fluid
icon="fist-raised"
tooltip="Go in for the kill!"
tooltipPosition="top"
onClick={() => act('attack')}
content="Attack!"
/>
</Flex.Item>
<Flex.Item grow={1}>
<Button
fluid
icon="band-aid"
tooltip="Heal yourself!"
tooltipPosition="top"
onClick={() => act('heal')}
content="Heal!"
/>
</Flex.Item>
<Flex.Item grow={1}>
<Button
fluid
icon="magic"
tooltip="Recharge your magic!"
tooltipPosition="top"
onClick={() => act('charge')}
content="Recharge!"
/>
</Flex.Item>
</Flex>
)}
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,82 @@
import { Flex, Button, Box, LabeledList, Section } from '../components';
import { useBackend, useLocalState } from '../backend';
import { Window } from '../layouts';
import { toTitleCase } from 'common/string';
export const AreaScrubberControl = (props, context) => {
const { act, data } = useBackend(context);
const [showArea, setShowArea] = useLocalState(context, 'showArea', false);
const { scrubbers } = data;
if (!scrubbers) {
return (
<Section title="Error">
<Box color="bad">No Scrubbers Detected.</Box>
<Button fluid icon="search" content="Scan" onClick={() => act('scan')} />
</Section>
);
}
return (
<Window width={600} height={400} resizable>
<Window.Content scrollable>
<Section>
<Flex wrap="wrap">
<Flex.Item m="2px" basis="49%">
<Button textAlign="center" fluid icon="search" content="Scan" onClick={() => act('scan')} />
</Flex.Item>
<Flex.Item m="2px" basis="49%" grow={1}>
<Button
textAlign="center"
fluid
icon="layer-group"
content="Show Areas"
selected={showArea}
onClick={() => setShowArea(!showArea)}
/>
</Flex.Item>
<Flex.Item m="2px" basis="49%">
<Button textAlign="center" fluid icon="toggle-on" content="All On" onClick={() => act('allon')} />
</Flex.Item>
<Flex.Item m="2px" basis="49%" grow={1}>
<Button textAlign="center" fluid icon="toggle-off" content="All Off" onClick={() => act('alloff')} />
</Flex.Item>
</Flex>
<Flex wrap="wrap">
{scrubbers.map((scrubber) => (
<Flex.Item m="2px" key={scrubber.id} basis="32%">
<BigScrubber scrubber={scrubber} showArea={showArea} />
</Flex.Item>
))}
</Flex>
</Section>
</Window.Content>
</Window>
);
};
const BigScrubber = (props, context) => {
const { act } = useBackend(context);
const { scrubber, showArea } = props;
return (
<Section title={scrubber.name}>
<Button
fluid
icon="power-off"
content={scrubber.on ? 'Enabled' : 'Disabled'}
selected={scrubber.on}
onClick={() => act('toggle', { id: scrubber.id })}
/>
<LabeledList>
<LabeledList.Item label="Pressure">{scrubber.pressure} kPa</LabeledList.Item>
<LabeledList.Item label="Flow Rate">{scrubber.flow_rate} L/s</LabeledList.Item>
<LabeledList.Item label="Load">{scrubber.load} W</LabeledList.Item>
{showArea && <LabeledList.Item label="Area">{toTitleCase(scrubber.area)}</LabeledList.Item>}
</LabeledList>
</Section>
);
};

View File

@@ -0,0 +1,34 @@
import { BooleanLike } from 'common/react';
import { useBackend } from '../backend';
import { Button, LabeledList, Section } from '../components';
import { Window } from '../layouts';
type Data = {
on: BooleanLike;
visible: BooleanLike;
};
export const AssemblyInfrared = (props, context) => {
const { act, data } = useBackend<Data>(context);
const { on, visible } = data;
return (
<Window>
<Window.Content>
<Section title="Infrared Unit">
<LabeledList>
<LabeledList.Item label="Laser">
<Button icon="power-off" fluid selected={on} onClick={() => act('state')}>
{on ? 'On' : 'Off'}
</Button>
</LabeledList.Item>
<LabeledList.Item label="Visibility">
<Button icon="eye" fluid selected={visible} onClick={() => act('visible')}>
{visible ? 'Able to be seen' : 'Invisible'}
</Button>
</LabeledList.Item>
</LabeledList>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,55 @@
import { round } from 'common/math';
import { useBackend } from '../backend';
import { Button, LabeledList, NumberInput, Section } from '../components';
import { Window } from '../layouts';
import { formatTime } from '../format';
export const AssemblyProx = (props, context) => {
const { act, data } = useBackend(context);
const { timing, time, range, maxRange, scanning } = data;
return (
<Window>
<Window.Content>
<Section title="Timing Unit">
<LabeledList>
<LabeledList.Item
label="Timer"
buttons={
<Button icon="stopwatch" selected={timing} onClick={() => act('timing')}>
{timing ? 'Counting Down' : 'Disabled'}
</Button>
}>
<NumberInput
animated
fluid
value={time / 10}
minValue={0}
maxValue={600}
format={(val) => formatTime(round(val))}
onDrag={(e, val) => act('set_time', { time: val })}
/>
</LabeledList.Item>
</LabeledList>
</Section>
<Section title="Prox Unit">
<LabeledList>
<LabeledList.Item label="Range">
<NumberInput
minValue={1}
value={range}
maxValue={maxRange}
onDrag={(e, val) => act('range', { range: val })}
/>
</LabeledList.Item>
<LabeledList.Item label="Armed">
<Button mr={1} icon={scanning ? 'lock' : 'lock-open'} selected={scanning} onClick={() => act('scanning')}>
{scanning ? 'ARMED' : 'Unarmed'}
</Button>
Movement sensor is active when armed!
</LabeledList.Item>
</LabeledList>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,37 @@
import { round } from 'common/math';
import { useBackend } from '../backend';
import { Button, LabeledList, NumberInput, Section } from '../components';
import { Window } from '../layouts';
import { formatTime } from '../format';
export const AssemblyTimer = (props, context) => {
const { act, data } = useBackend(context);
const { timing, time } = data;
return (
<Window>
<Window.Content>
<Section title="Timing Unit">
<LabeledList>
<LabeledList.Item
label="Timer"
buttons={
<Button icon="stopwatch" selected={timing} onClick={() => act('timing')}>
{timing ? 'Counting Down' : 'Disabled'}
</Button>
}>
<NumberInput
animated
fluid
value={time / 10}
minValue={0}
maxValue={600}
format={(val) => formatTime(round(val))}
onDrag={(e, val) => act('set_time', { time: val })}
/>
</LabeledList.Item>
</LabeledList>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,41 @@
import { useBackend } from '../backend';
import { Button, Section } from '../components';
import { Window } from '../layouts';
export const AtmosAlertConsole = (props, context) => {
const { act, data } = useBackend(context);
const priorityAlerts = data.priority_alarms || [];
const minorAlerts = data.minor_alarms || [];
return (
<Window width={350} height={300} resizable>
<Window.Content scrollable>
<Section title="Alarms">
<ul>
{priorityAlerts.length === 0 && <li className="color-good">No Priority Alerts</li>}
{priorityAlerts.map((alert) => (
<li key={alert.name}>
<Button
icon="times"
content={alert.name}
color="bad"
onClick={() => act('clear', { ref: alert.ref })}
/>
</li>
))}
{minorAlerts.length === 0 && <li className="color-good">No Minor Alerts</li>}
{minorAlerts.map((alert) => (
<li key={alert.name}>
<Button
icon="times"
content={alert.name}
color="average"
onClick={() => act('clear', { ref: alert.ref })}
/>
</li>
))}
</ul>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,83 @@
import { sortBy } from 'common/collections';
import { Window } from '../layouts';
import { Fragment } from 'inferno';
import { Button, Box, Tabs, Icon, Section, NanoMap } from '../components';
import { useBackend, useLocalState } from '../backend';
import { createLogger } from '../logging';
const logger = createLogger('fuck');
export const AtmosControl = (props, context) => {
return (
<Window width={600} height={440} resizable>
<Window.Content scrollable>
<AtmosControlContent />
</Window.Content>
</Window>
);
};
export const AtmosControlContent = (props, context) => {
const { act, data, config } = useBackend(context);
let sortedAlarms = sortBy((alarm) => alarm.name)(data.alarms || []);
// sortedAlarms = sortedAlarms.slice(1, 3);
const [tabIndex, setTabIndex] = useLocalState(context, 'tabIndex', 0);
const [zoom, setZoom] = useLocalState(context, 'zoom', 1);
let body;
// Alarms View
if (tabIndex === 0) {
body = (
<Section title="Alarms">
{sortedAlarms.map((alarm) => (
<Button
key={alarm.name}
content={alarm.name}
color={alarm.danger === 2 ? 'bad' : alarm.danger === 1 ? 'average' : ''}
onClick={() => act('alarm', { 'alarm': alarm.ref })}
/>
))}
</Section>
);
} else if (tabIndex === 1) {
// Please note, if you ever change the zoom values,
// you MUST update styles/components/Tooltip.scss
// and change the @for scss to match.
body = (
<Box height="526px" mb="0.5rem" overflow="hidden">
<NanoMap onZoom={(v) => setZoom(v)}>
{sortedAlarms
.filter((x) => ~~x.z === ~~config.mapZLevel)
.map((cm) => (
<NanoMap.Marker
key={cm.ref}
x={cm.x}
y={cm.y}
zoom={zoom}
icon="bell"
tooltip={cm.name}
color={cm.danger ? 'red' : 'green'}
onClick={() => act('alarm', { 'alarm': cm.ref })}
/>
))}
</NanoMap>
</Box>
);
}
return (
<Fragment>
<Tabs>
<Tabs.Tab key="AlarmView" selected={0 === tabIndex} onClick={() => setTabIndex(0)}>
<Icon name="table" /> Alarm View
</Tabs.Tab>
<Tabs.Tab key="MapView" selected={1 === tabIndex} onClick={() => setTabIndex(1)}>
<Icon name="map-marked-alt" /> Map View
</Tabs.Tab>
</Tabs>
<Box m={2}>{body}</Box>
</Fragment>
);
};

View File

@@ -0,0 +1,69 @@
import { useBackend } from '../backend';
import { Button, LabeledList, NumberInput, Section, AnimatedNumber, Box } from '../components';
import { Window } from '../layouts';
export const AtmosFilter = (props, context) => {
const { act, data } = useBackend(context);
const filterTypes = data.filter_types || [];
return (
<Window width={390} height={187} resizable>
<Window.Content>
<Section>
<LabeledList>
<LabeledList.Item label="Power">
<Button
icon={data.on ? 'power-off' : 'times'}
content={data.on ? 'On' : 'Off'}
selected={data.on}
onClick={() => act('power')}
/>
</LabeledList.Item>
<LabeledList.Item label="Transfer Rate">
<Box inline mr={1}>
<AnimatedNumber value={data.last_flow_rate} format={(val) => val + ' L/s'} />
</Box>
<NumberInput
animated
value={parseFloat(data.rate)}
width="63px"
unit="L/s"
minValue={0}
maxValue={200}
onDrag={(e, value) =>
act('rate', {
rate: value,
})
}
/>
<Button
ml={1}
icon="plus"
content="Max"
disabled={data.rate === data.max_rate}
onClick={() =>
act('rate', {
rate: 'max',
})
}
/>
</LabeledList.Item>
<LabeledList.Item label="Filter">
{filterTypes.map((filter) => (
<Button
key={filter.name}
selected={filter.selected}
content={filter.name}
onClick={() =>
act('filter', {
filterset: filter.f_type,
})
}
/>
))}
</LabeledList.Item>
</LabeledList>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,88 @@
import { useBackend } from '../backend';
import { Button, LabeledList, NumberInput, Section } from '../components';
import { Window } from '../layouts';
export const AtmosMixer = (props, context) => {
const { act, data } = useBackend(context);
return (
<Window width={370} height={195} resizable>
<Window.Content>
<Section>
<LabeledList>
<LabeledList.Item label="Power">
<Button
icon={data.on ? 'power-off' : 'times'}
content={data.on ? 'On' : 'Off'}
selected={data.on}
onClick={() => act('power')}
/>
</LabeledList.Item>
<LabeledList.Item label="Output Pressure">
<NumberInput
animated
value={parseFloat(data.set_pressure)}
unit="kPa"
width="75px"
minValue={0}
maxValue={data.max_pressure}
step={10}
onChange={(e, value) =>
act('pressure', {
pressure: value,
})
}
/>
<Button
ml={1}
icon="plus"
content="Max"
disabled={data.set_pressure === data.max_pressure}
onClick={() =>
act('pressure', {
pressure: 'max',
})
}
/>
</LabeledList.Item>
<LabeledList.Divider size={1} />
<LabeledList.Item color="label">
<u>Concentrations</u>
</LabeledList.Item>
<LabeledList.Item label={'Node 1 (' + data.node1_dir + ')'}>
<NumberInput
animated
value={data.node1_concentration}
unit="%"
width="60px"
minValue={0}
maxValue={100}
stepPixelSize={2}
onDrag={(e, value) =>
act('node1', {
concentration: value,
})
}
/>
</LabeledList.Item>
<LabeledList.Item label={'Node 2 (' + data.node2_dir + ')'}>
<NumberInput
animated
value={data.node2_concentration}
unit="%"
width="60px"
minValue={0}
maxValue={100}
stepPixelSize={2}
onDrag={(e, value) =>
act('node2', {
concentration: value,
})
}
/>
</LabeledList.Item>
</LabeledList>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,103 @@
import { flow } from 'common/fp';
import { filter, sortBy } from 'common/collections';
import { useBackend, useSharedState } from '../backend';
import { Box, Button, Flex, Input, Section, Dropdown } from '../components';
import { Window } from '../layouts';
import { Materials } from './ExosuitFabricator';
import { createSearch, toTitleCase } from 'common/string';
const canBeMade = (recipe, materials, mult = 1) => {
if (recipe.requirements === null) {
return true;
}
let recipeRequiredMaterials = Object.keys(recipe.requirements);
for (let mat_id of recipeRequiredMaterials) {
let material = materials.find((val) => val.name === mat_id);
if (!material) {
continue; // yes, if we cannot find the material, we just ignore it :V
}
if (material.amount < recipe.requirements[mat_id] * mult) {
return false;
}
}
return true;
};
export const Autolathe = (props, context) => {
const { act, data } = useBackend(context);
const { recipes, busy, materials, categories } = data;
const [category, setCategory] = useSharedState(context, 'category', 0);
const [searchText, setSearchText] = useSharedState(context, 'search_text', '');
const testSearch = createSearch(searchText, (recipe) => recipe.name);
const recipesToShow = flow([
filter((recipe) => recipe.category === categories[category]),
searchText && filter(testSearch),
sortBy((recipe) => recipe.name.toLowerCase()),
])(recipes);
return (
<Window width={550} height={700}>
<Window.Content scrollable>
<Section title="Materials">
<Materials disableEject />
</Section>
<Section
title="Recipes"
buttons={
<Dropdown
width="190px"
options={categories}
selected={categories[category]}
onSelected={(val) => setCategory(categories.indexOf(val))}
/>
}>
<Input fluid placeholder="Search for..." onInput={(e, v) => setSearchText(v)} mb={1} />
{recipesToShow.map((recipe) => (
<Flex justify="space-between" align="center" key={recipe.ref}>
<Flex.Item>
<Button
color={(recipe.hidden && 'red') || null}
icon="hammer"
iconSpin={busy === recipe.name}
disabled={!canBeMade(recipe, materials, 1)}
onClick={() => act('make', { make: recipe.ref })}>
{toTitleCase(recipe.name)}
</Button>
{(!recipe.is_stack && (
<Box as="span">
<Button
color={(recipe.hidden && 'red') || null}
disabled={!canBeMade(recipe, materials, 5)}
onClick={() => act('make', { make: recipe.ref, multiplier: 5 })}>
x5
</Button>
<Button
color={(recipe.hidden && 'red') || null}
disabled={!canBeMade(recipe, materials, 10)}
onClick={() => act('make', { make: recipe.ref, multiplier: 10 })}>
x10
</Button>
</Box>
)) ||
null}
</Flex.Item>
<Flex.Item>
{(recipe.requirements &&
Object.keys(recipe.requirements)
.map((mat) => toTitleCase(mat) + ': ' + recipe.requirements[mat])
.join(', ')) || <Box>No resources required.</Box>}
</Flex.Item>
</Flex>
))}
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,61 @@
import { useBackend } from '../backend';
import { AnimatedNumber, Box, Button, LabeledList, ProgressBar, Section, Table } from '../components';
import { Window } from '../layouts';
export const Batteryrack = (props, context) => {
const { act, data } = useBackend(context);
const { mode, transfer_max, output_load, input_load, equalise, blink_tick, cells_max, cells_cur, cells_list } = data;
return (
<Window width={500} height={430} resizable>
<Window.Content scrollable>
<Section title="Controls">
<LabeledList>
<LabeledList.Item label="Current Mode">
{(mode === 1 && <Box color="good">OUTPUT ONLY</Box>) ||
(mode === 2 && <Box color="good">INPUT ONLY</Box>) ||
(mode === 3 && <Box color="good">INPUT AND OUTPUT</Box>) || <Box color="bad">OFFLINE</Box>}
</LabeledList.Item>
<LabeledList.Item label="Input Status">
<AnimatedNumber value={input_load} /> / {transfer_max} W
</LabeledList.Item>
<LabeledList.Item label="Output Status">
<AnimatedNumber value={output_load} /> / {transfer_max} W
</LabeledList.Item>
<LabeledList.Item label="Control Panel">
<Button content="OFF" selected={mode === 0} onClick={() => act('disable')} />
<Button content="OUT" selected={mode === 1} onClick={() => act('enable', { enable: 1 })} />
<Button content="IN" selected={mode === 2} onClick={() => act('enable', { enable: 2 })} />
<Button content="IN/OUT" selected={mode === 3} onClick={() => act('enable', { enable: 3 })} />
{(equalise && (
<Button content="EQ" color={blink_tick ? 'red' : 'yellow'} onClick={() => act('equaliseoff')} />
)) || <Button content="EQ" onClick={() => act('equaliseon')} />}
</LabeledList.Item>
</LabeledList>
</Section>
<Section title="Cells">
<Table>
{cells_list.map((cell) => (
<Table.Row key={cell.slot}>
<Table.Cell collapsing>Cell {cell.slot}</Table.Cell>
<Table.Cell>
<ProgressBar
value={cell.used ? cell.percentage : 100}
minValue={0}
maxValue={100}
color={cell.used ? 'good' : 'bad'}>
{cell.used ? cell.percentage + '%' : 'N/C'}
</ProgressBar>
</Table.Cell>
<Table.Cell collapsing>
<Button icon="eject" disabled={!cell.used} onClick={() => act('ejectcell', { ejectcell: cell.id })} />
</Table.Cell>
</Table.Row>
))}
</Table>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,50 @@
import { toFixed, round } from 'common/math';
import { useBackend } from '../backend';
import { Box, Button, Icon, LabeledList, NumberInput, Section } from '../components';
import { Window } from '../layouts';
export const BeaconLocator = (props, context) => {
const { act, data } = useBackend(context);
const { scan_ticks, degrees, rawfreq, minFrequency, maxFrequency } = data;
return (
<Window width={300} height={220}>
<Window.Content>
<Section title="Beacon Locator">
{(scan_ticks && <Box color="label">Scanning...</Box>) || null}
{(degrees && (
<Box textAlign="center">
<Box textAlign="center">
<Icon size={4} name="arrow-up" rotation={degrees} />
</Box>
Locked on. Follow the arrow.
</Box>
)) || <Box color="average">No lock.</Box>}
<Button mt={1} mb={1} fluid icon="broadcast-tower" onClick={() => act('reset_tracking')}>
Reset tracker
</Button>
<LabeledList>
<LabeledList.Item label="Frequency">
<NumberInput
animated
unit="kHz"
step={0.2}
stepPixelSize={10}
minValue={minFrequency / 10}
maxValue={maxFrequency / 10}
value={rawfreq / 10}
format={(value) => toFixed(value, 1)}
onDrag={(e, value) =>
act('setFrequency', {
freq: round(value * 10),
})
}
/>
</LabeledList.Item>
</LabeledList>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,162 @@
import { createSearch } from 'common/string';
import { Fragment } from 'inferno';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, Collapsible, Dropdown, Flex, Input, Section } from '../components';
import { Window } from '../layouts';
import { refocusLayout } from '../layouts';
const sortTypes = {
'Alphabetical': (a, b) => a - b,
'By availability': (a, b) => -(a.affordable - b.affordable),
'By price': (a, b) => a.price - b.price,
};
export const Biogenerator = (props, context) => {
const { act, data } = useBackend(context);
return (
<Window width={400} height={450} resizable>
<Window.Content className="Layout__content--flexColumn" scrollable>
{(data.processing && <Section title="Processing">The biogenerator is processing reagents!</Section>) || (
<Fragment>
<Section>
{data.points} points available.
<Button ml={1} icon="blender" onClick={() => act('activate')}>
Activate
</Button>
<Button ml={1} icon="eject" disabled={!data.beaker} onClick={() => act('detach')}>
Eject Beaker
</Button>
</Section>
<BiogeneratorSearch />
<BiogeneratorItems />
</Fragment>
)}
</Window.Content>
</Window>
);
};
const BiogeneratorItems = (props, context) => {
const { act, data } = useBackend(context);
const { points, items } = data;
// Search thingies
const [searchText, _setSearchText] = useLocalState(context, 'search', '');
const [sortOrder, _setSortOrder] = useLocalState(context, 'sort', 'Alphabetical');
const [descending, _setDescending] = useLocalState(context, 'descending', false);
const searcher = createSearch(searchText, (item) => {
return item[0];
});
let has_contents = false;
let contents = Object.entries(items).map((kv, _i) => {
let items_in_cat = Object.entries(kv[1])
.filter(searcher)
.map((kv2) => {
kv2[1].affordable = points >= kv2[1].price / data.build_eff;
return kv2[1];
})
.sort(sortTypes[sortOrder]);
if (items_in_cat.length === 0) {
return;
}
if (descending) {
items_in_cat = items_in_cat.reverse();
}
has_contents = true;
return <BiogeneratorItemsCategory key={kv[0]} title={kv[0]} items={items_in_cat} />;
});
return (
<Flex.Item grow="1" overflow="auto">
<Section onClick={(e) => refocusLayout()}>
{has_contents ? contents : <Box color="label">No items matching your criteria was found!</Box>}
</Section>
</Flex.Item>
);
};
const BiogeneratorSearch = (props, context) => {
const [_searchText, setSearchText] = useLocalState(context, 'search', '');
const [_sortOrder, setSortOrder] = useLocalState(context, 'sort', '');
const [descending, setDescending] = useLocalState(context, 'descending', false);
return (
<Box mb="0.5rem">
<Flex width="100%">
<Flex.Item grow="1" mr="0.5rem">
<Input placeholder="Search by item name.." width="100%" onInput={(_e, value) => setSearchText(value)} />
</Flex.Item>
<Flex.Item basis="30%">
<Dropdown
selected="Alphabetical"
options={Object.keys(sortTypes)}
width="100%"
lineHeight="19px"
onSelected={(v) => setSortOrder(v)}
/>
</Flex.Item>
<Flex.Item>
<Button
icon={descending ? 'arrow-down' : 'arrow-up'}
height="19px"
tooltip={descending ? 'Descending order' : 'Ascending order'}
tooltipPosition="bottom-end"
ml="0.5rem"
onClick={() => setDescending(!descending)}
/>
</Flex.Item>
</Flex>
</Box>
);
};
const canBuyItem = (item, data) => {
if (!item.affordable) {
return false;
}
if (item.reagent && !data.beaker) {
return false;
}
return true;
};
const BiogeneratorItemsCategory = (properties, context) => {
const { act, data } = useBackend(context);
const { title, items, ...rest } = properties;
return (
<Collapsible open title={title} {...rest}>
{items.map((item) => (
<Box key={item.name}>
<Box
display="inline-block"
verticalAlign="middle"
lineHeight="20px"
style={{
float: 'left',
}}>
{item.name}
</Box>
<Button
disabled={!canBuyItem(item, data)}
content={(item.price / data.build_eff).toLocaleString('en-US')}
width="15%"
textAlign="center"
style={{
float: 'right',
}}
onClick={() =>
act('purchase', {
cat: title,
name: item.name,
})
}
/>
<Box
style={{
clear: 'both',
}}
/>
</Box>
))}
</Collapsible>
);
};

View File

@@ -0,0 +1,287 @@
import { capitalize } from 'common/string';
import { useBackend } from '../backend';
import { Box, ByondUi, Button, Flex, LabeledList, Section, ColorBox } from '../components';
import { Window } from '../layouts';
export const BodyDesigner = (props, context) => {
const { act, data } = useBackend(context);
const { menu, disk, diskStored, activeBodyRecord } = data;
let body = MenuToTemplate[menu];
return (
<Window width={400} height={650}>
<Window.Content>
{disk ? (
<Box>
<Button icon="save" content="Save To Disk" onClick={() => act('savetodisk')} disabled={!activeBodyRecord} />
<Button icon="save" content="Load From Disk" onClick={() => act('loadfromdisk')} disabled={!diskStored} />
<Button icon="eject" content="Eject" onClick={() => act('ejectdisk')} />
</Box>
) : null}
{body}
</Window.Content>
</Window>
);
};
const BodyDesignerMain = (props, context) => {
const { act, data } = useBackend(context);
return (
<Section title="Database Functions">
<Button icon="eye" content="View Individual Body Records" onClick={() => act('menu', { menu: 'Body Records' })} />
<Button icon="eye" content="View Stock Body Records" onClick={() => act('menu', { menu: 'Stock Records' })} />
</Section>
);
};
const BodyDesignerBodyRecords = (props, context) => {
const { act, data } = useBackend(context);
const { bodyrecords } = data;
return (
<Section
title="Body Records"
buttons={<Button icon="arrow-left" content="Back" onClick={() => act('menu', { menu: 'Main' })} />}>
{bodyrecords.map((record) => (
<Button
icon="eye"
key={record.name}
content={record.name}
onClick={() => act('view_brec', { view_brec: record.recref })}
/>
))}
</Section>
);
};
const BodyDesignerStockRecords = (props, context) => {
const { act, data } = useBackend(context);
const { stock_bodyrecords } = data;
return (
<Section
title="Stock Records"
buttons={<Button icon="arrow-left" content="Back" onClick={() => act('menu', { menu: 'Main' })} />}>
{stock_bodyrecords.map((record) => (
<Button
icon="eye"
key={record}
content={record}
onClick={() => act('view_stock_brec', { view_stock_brec: record })}
/>
))}
</Section>
);
};
const BodyDesignerSpecificRecord = (props, context) => {
const { act, data } = useBackend(context);
const { activeBodyRecord, mapRef } = data;
return activeBodyRecord ? (
<Flex direction="column">
<Flex.Item basis="165px">
<Section
title="Specific Record"
buttons={<Button icon="arrow-left" content="Back" onClick={() => act('menu', { menu: 'Main' })} />}>
<LabeledList>
<LabeledList.Item label="Name">{activeBodyRecord.real_name}</LabeledList.Item>
<LabeledList.Item label="Species">{activeBodyRecord.speciesname}</LabeledList.Item>
<LabeledList.Item label="Bio. Sex">
<Button
icon="pen"
content={capitalize(activeBodyRecord.gender)}
onClick={() =>
act('href_conversion', {
target_href: 'bio_gender',
target_value: 1,
})
}
/>
</LabeledList.Item>
<LabeledList.Item label="Synthetic">{activeBodyRecord.synthetic}</LabeledList.Item>
<LabeledList.Item label="Mind Compat">
{activeBodyRecord.locked}
<Button
ml={1}
icon="eye"
content="View OOC Notes"
disabled={!activeBodyRecord.booc}
onClick={() => act('boocnotes')}
/>
</LabeledList.Item>
</LabeledList>
</Section>
</Flex.Item>
<Flex.Item basis="130px">
<ByondUi
style={{
width: '100%',
height: '128px',
}}
params={{
id: mapRef,
type: 'map',
}}
/>
</Flex.Item>
<Flex.Item basis="300px">
<Section title="Customize" height="300px" style={{ overflow: 'auto' }}>
<LabeledList>
<LabeledList.Item label="Scale">
<Button
icon="pen"
content={activeBodyRecord.scale}
onClick={() =>
act('href_conversion', {
target_href: 'size_multiplier',
target_value: 1,
})
}
/>
</LabeledList.Item>
{Object.keys(activeBodyRecord.styles).map((key) => {
const style = activeBodyRecord.styles[key];
return (
<LabeledList.Item key={key} label={key}>
{style.styleHref ? (
<Button
icon="pen"
content={style.style}
onClick={() =>
act('href_conversion', {
target_href: style.styleHref,
target_value: 1,
})
}
/>
) : null}
{style.colorHref ? (
<Box>
<Button
icon="pen"
content={style.color}
onClick={() =>
act('href_conversion', {
target_href: style.colorHref,
target_value: 1,
})
}
/>
<ColorBox
verticalAlign="top"
width="32px"
height="20px"
color={style.color}
style={{
border: '1px solid #fff',
}}
/>
</Box>
) : null}
{style.colorHref2 ? (
<Box>
<Button
icon="pen"
content={style.color2}
onClick={() =>
act('href_conversion', {
target_href: style.colorHref2,
target_value: 1,
})
}
/>
<ColorBox
verticalAlign="top"
width="32px"
height="20px"
color={style.color2}
style={{
border: '1px solid #fff',
}}
/>
</Box>
) : null}
</LabeledList.Item>
);
})}
<LabeledList.Item label="Body Markings">
<Button
icon="plus"
content="Add Marking"
onClick={() =>
act('href_conversion', {
target_href: 'marking_style',
target_value: 1,
})
}
/>
<Flex wrap="wrap" justify="center" align="center">
{Object.keys(activeBodyRecord.markings).map((key) => {
const marking = activeBodyRecord.markings[key];
return (
<Flex.Item basis="100%" key={key}>
<Flex>
<Flex.Item>
<Button
mr={0.2}
fluid
icon="times"
color="red"
onClick={() =>
act('href_conversion', {
target_href: 'marking_remove',
target_value: key,
})
}
/>
</Flex.Item>
<Flex.Item grow={1}>
<Button
fluid
backgroundColor={marking}
content={key}
onClick={() =>
act('href_conversion', {
target_href: 'marking_color',
target_value: key,
})
}
/>
</Flex.Item>
</Flex>
</Flex.Item>
);
})}
</Flex>
</LabeledList.Item>
</LabeledList>
</Section>
</Flex.Item>
</Flex>
) : (
<Box color="bad">ERROR: Record Not Found!</Box>
);
};
const BodyDesignerOOCNotes = (props, context) => {
const { act, data } = useBackend(context);
const { activeBodyRecord } = data;
return (
<Section
title="Body OOC Notes (This is OOC!)"
height="100%"
scrollable
buttons={<Button icon="arrow-left" content="Back" onClick={() => act('menu', { menu: 'Specific Record' })} />}
style={{ 'word-break': 'break-all' }}>
{(activeBodyRecord && activeBodyRecord.booc) || 'ERROR: Body record not found!'}
</Section>
);
};
const MenuToTemplate = {
'Main': <BodyDesignerMain />,
'Body Records': <BodyDesignerBodyRecords />,
'Stock Records': <BodyDesignerStockRecords />,
'Specific Record': <BodyDesignerSpecificRecord />,
'OOC Notes': <BodyDesignerOOCNotes />,
};

View File

@@ -0,0 +1,446 @@
import { round } from 'common/math';
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { AnimatedNumber, Box, Button, Flex, Icon, LabeledList, ProgressBar, Section, Table, Tooltip } from '../components';
import { Window } from '../layouts';
const stats = [
['good', 'Alive'],
['average', 'Unconscious'],
['bad', 'DEAD'],
];
const abnormalities = [
[
'hasBorer',
'bad',
(occupant) => 'Large growth detected in frontal lobe,' + ' possibly cancerous. Surgical removal is recommended.',
],
['hasVirus', 'bad', (occupant) => 'Viral pathogen detected in blood stream.'],
['blind', 'average', (occupant) => 'Cataracts detected.'],
['colourblind', 'average', (occupant) => 'Photoreceptor abnormalities detected.'],
['nearsighted', 'average', (occupant) => 'Retinal misalignment detected.'],
/* VOREStation Add */
[
'humanPrey',
'average',
(occupant) => {
return 'Foreign Humanoid(s) detected: ' + occupant.humanPrey;
},
],
[
'livingPrey',
'average',
(occupant) => {
return 'Foreign Creature(s) detected: ' + occupant.livingPrey;
},
],
[
'objectPrey',
'average',
(occupant) => {
return 'Foreign Object(s) detected: ' + occupant.objectPrey;
},
],
/* VOREStation Add End */
];
const damages = [
['Respiratory', 'oxyLoss'],
['Brain', 'brainLoss'],
['Toxin', 'toxLoss'],
['Radiation', 'radLoss'],
['Brute', 'bruteLoss'],
['Genetic', 'cloneLoss'],
['Burn', 'fireLoss'],
['Paralysis', 'paralysis'],
];
const damageRange = {
average: [0.25, 0.5],
bad: [0.5, Infinity],
};
const mapTwoByTwo = (a, c) => {
let result = [];
for (let i = 0; i < a.length; i += 2) {
result.push(c(a[i], a[i + 1], i));
}
return result;
};
const reduceOrganStatus = (A) => {
return A.length > 0
? A.reduce((a, s) =>
a === null ? (
s
) : (
<Fragment>
{a}
{!!s && <Box>{s}</Box>}
</Fragment>
)
)
: null;
};
const germStatus = (i) => {
if (i > 100) {
if (i < 300) {
return 'mild infection';
}
if (i < 400) {
return 'mild infection+';
}
if (i < 500) {
return 'mild infection++';
}
if (i < 700) {
return 'acute infection';
}
if (i < 800) {
return 'acute infection+';
}
if (i < 900) {
return 'acute infection++';
}
if (i >= 900) {
return 'septic';
}
}
return '';
};
export const BodyScanner = (props, context) => {
const { data } = useBackend(context);
const { occupied, occupant = {} } = data;
const body = occupied ? <BodyScannerMain occupant={occupant} /> : <BodyScannerEmpty />;
return (
<Window width={690} height={600} resizable>
<Window.Content scrollable className="Layout__content--flexColumn">
{body}
</Window.Content>
</Window>
);
};
const BodyScannerMain = (props) => {
const { occupant } = props;
return (
<Box>
<BodyScannerMainOccupant occupant={occupant} />
<BodyScannerMainReagents occupant={occupant} />
<BodyScannerMainAbnormalities occupant={occupant} />
<BodyScannerMainDamage occupant={occupant} />
<BodyScannerMainOrgansExternal organs={occupant.extOrgan} />
<BodyScannerMainOrgansInternal organs={occupant.intOrgan} />
</Box>
);
};
const BodyScannerMainOccupant = (props, context) => {
const { act, data } = useBackend(context);
const { occupant } = data;
return (
<Section
title="Occupant"
buttons={
<Fragment>
<Button icon="user-slash" onClick={() => act('ejectify')}>
Eject
</Button>
<Button icon="print" onClick={() => act('print_p')}>
Print Report
</Button>
</Fragment>
}>
<LabeledList>
<LabeledList.Item label="Name">{occupant.name}</LabeledList.Item>
<LabeledList.Item label="Health">
<ProgressBar
min="0"
max={occupant.maxHealth}
value={occupant.health / occupant.maxHealth}
ranges={{
good: [0.5, Infinity],
average: [0, 0.5],
bad: [-Infinity, 0],
}}
/>
</LabeledList.Item>
<LabeledList.Item label="Status" color={stats[occupant.stat][0]}>
{stats[occupant.stat][1]}
</LabeledList.Item>
<LabeledList.Item label="Temperature">
<AnimatedNumber value={round(occupant.bodyTempC, 0)} />
&deg;C,&nbsp;
<AnimatedNumber value={round(occupant.bodyTempF, 0)} />
&deg;F
</LabeledList.Item>
<LabeledList.Item label="Blood Volume">
<AnimatedNumber value={round(occupant.blood.volume, 0)} /> units&nbsp;(
<AnimatedNumber value={round(occupant.blood.percent, 0)} />
%)
</LabeledList.Item>
{/* VOREStation Add */}
<LabeledList.Item label="Weight">
{round(data.occupant.weight) + 'lbs, ' + round(data.occupant.weight / 2.20463) + 'kgs'}
</LabeledList.Item>
{/* VOREStation Add End */}
</LabeledList>
</Section>
);
};
const BodyScannerMainReagents = (props) => {
const { occupant } = props;
return (
<Fragment>
<Section title="Blood Reagents">
{occupant.reagents ? (
<Table>
<Table.Row header>
<Table.Cell>Reagent</Table.Cell>
<Table.Cell textAlign="right">Amount</Table.Cell>
</Table.Row>
{occupant.reagents.map((reagent) => (
<Table.Row key={reagent.name}>
<Table.Cell>{reagent.name}</Table.Cell>
<Table.Cell textAlign="right">
{reagent.amount} Units {reagent.overdose ? <Box color="bad">OVERDOSING</Box> : null}
</Table.Cell>
</Table.Row>
))}
</Table>
) : (
<Box color="good">No Blood Reagents Detected</Box>
)}
</Section>
<Section title="Stomach Reagents">
{occupant.ingested ? (
<Table>
<Table.Row header>
<Table.Cell>Reagent</Table.Cell>
<Table.Cell textAlign="right">Amount</Table.Cell>
</Table.Row>
{occupant.ingested.map((reagent) => (
<Table.Row key={reagent.name}>
<Table.Cell>{reagent.name}</Table.Cell>
<Table.Cell textAlign="right">
{reagent.amount} Units {reagent.overdose ? <Box color="bad">OVERDOSING</Box> : null}
</Table.Cell>
</Table.Row>
))}
</Table>
) : (
<Box color="good">No Stomach Reagents Detected</Box>
)}
</Section>
</Fragment>
);
};
const BodyScannerMainAbnormalities = (props) => {
const { occupant } = props;
let hasAbnormalities =
occupant.hasBorer || occupant.blind || occupant.colourblind || occupant.nearsighted || occupant.hasVirus;
/* VOREStation Add */
hasAbnormalities = hasAbnormalities || occupant.humanPrey || occupant.livingPrey || occupant.objectPrey;
/* VOREStation Add End */
if (!hasAbnormalities) {
return (
<Section title="Abnormalities">
<Box color="label">No abnormalities found.</Box>
</Section>
);
}
return (
<Section title="Abnormalities">
{abnormalities.map((a, i) => {
if (occupant[a[0]]) {
return (
<Box color={a[1]} bold={a[1] === 'bad'}>
{a[2](occupant)}
</Box>
);
}
})}
</Section>
);
};
const BodyScannerMainDamage = (props) => {
const { occupant } = props;
return (
<Section title="Damage">
<Table>
{mapTwoByTwo(damages, (d1, d2, i) => (
<Fragment>
<Table.Row color="label">
<Table.Cell>{d1[0]}:</Table.Cell>
<Table.Cell>{!!d2 && d2[0] + ':'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>
<BodyScannerMainDamageBar value={occupant[d1[1]]} marginBottom={i < damages.length - 2} />
</Table.Cell>
<Table.Cell>{!!d2 && <BodyScannerMainDamageBar value={occupant[d2[1]]} />}</Table.Cell>
</Table.Row>
</Fragment>
))}
</Table>
</Section>
);
};
const BodyScannerMainDamageBar = (props) => {
return (
<ProgressBar
min="0"
max="100"
value={props.value / 100}
mt="0.5rem"
mb={!!props.marginBottom && '0.5rem'}
ranges={damageRange}>
{round(props.value, 0)}
</ProgressBar>
);
};
const BodyScannerMainOrgansExternal = (props) => {
if (props.organs.length === 0) {
return (
<Section title="External Organs">
<Box color="label">N/A</Box>
</Section>
);
}
return (
<Section title="External Organs">
<Table>
<Table.Row header>
<Table.Cell>Name</Table.Cell>
<Table.Cell textAlign="center">Damage</Table.Cell>
<Table.Cell textAlign="right">Injuries</Table.Cell>
</Table.Row>
{props.organs.map((o, i) => (
<Table.Row key={i} textTransform="capitalize">
<Table.Cell width="33%">{o.name}</Table.Cell>
<Table.Cell textAlign="center" q>
<ProgressBar
min="0"
max={o.maxHealth}
mt={i > 0 && '0.5rem'}
value={o.totalLoss / 100}
ranges={damageRange}>
<Box float="left" inline>
{!!o.bruteLoss && (
<Box inline position="relative">
<Icon name="bone" />
{round(o.bruteLoss, 0)}&nbsp;
<Tooltip position="top" content="Brute damage" />
</Box>
)}
{!!o.fireLoss && (
<Box inline position="relative">
<Icon name="fire" />
{round(o.fireLoss, 0)}
<Tooltip position="top" content="Burn damage" />
</Box>
)}
</Box>
<Box inline>{round(o.totalLoss, 0)}</Box>
</ProgressBar>
</Table.Cell>
<Table.Cell textAlign="right" width="33%">
<Box color="average" inline>
{reduceOrganStatus([
o.internalBleeding && 'Internal bleeding',
!!o.status.bleeding && 'External bleeding',
o.lungRuptured && 'Ruptured lung',
o.destroyed && 'Destroyed',
!!o.status.broken && o.status.broken,
germStatus(o.germ_level),
!!o.open && 'Open incision',
])}
</Box>
<Box inline>
{reduceOrganStatus([
!!o.status.splinted && 'Splinted',
!!o.status.robotic && 'Robotic',
!!o.status.dead && <Box color="bad">DEAD</Box>,
])}
{reduceOrganStatus(o.implants.map((s) => (s.known ? s.name : 'Unknown object')))}
</Box>
</Table.Cell>
</Table.Row>
))}
</Table>
</Section>
);
};
const BodyScannerMainOrgansInternal = (props) => {
if (props.organs.length === 0) {
return (
<Section title="Internal Organs">
<Box color="label">N/A</Box>
</Section>
);
}
return (
<Section title="Internal Organs">
<Table>
<Table.Row header>
<Table.Cell>Name</Table.Cell>
<Table.Cell textAlign="center">Damage</Table.Cell>
<Table.Cell textAlign="right">Injuries</Table.Cell>
</Table.Row>
{props.organs.map((o, i) => (
<Table.Row key={i} textTransform="capitalize">
<Table.Cell width="33%">{o.name}</Table.Cell>
<Table.Cell textAlign="center">
<ProgressBar min="0" max={o.maxHealth} value={o.damage / 100} mt={i > 0 && '0.5rem'} ranges={damageRange}>
{round(o.damage, 0)}
</ProgressBar>
</Table.Cell>
<Table.Cell textAlign="right" width="33%">
<Box color="average" inline>
{reduceOrganStatus([germStatus(o.germ_level), !!o.inflamed && 'Appendicitis detected.'])}
</Box>
<Box inline>
{reduceOrganStatus([
o.robotic === 1 && 'Robotic',
o.robotic === 2 && 'Assisted',
!!o.dead && <Box color="bad">DEAD</Box>,
])}
</Box>
</Table.Cell>
</Table.Row>
))}
</Table>
</Section>
);
};
const BodyScannerEmpty = () => {
return (
<Section textAlign="center" flexGrow="1">
<Flex height="100%">
<Flex.Item grow="1" align="center" color="label">
<Icon name="user-slash" mb="0.5rem" size="5" />
<br />
No occupant detected.
</Flex.Item>
</Flex>
</Section>
);
};

View File

@@ -0,0 +1,159 @@
import { Component } from 'inferno';
import { useBackend } from '../backend';
import { Box, Button, Icon, LabeledList, Section, Slider } from '../components';
import { Window } from '../layouts';
export const BombTester = (props, context) => {
const { act, data } = useBackend(context);
const { simulating, mode, tank1, tank1ref, tank2, tank2ref, canister, sim_canister_output } = data;
return (
<Window width={450} height={400}>
<Window.Content>
{(simulating && <BombTesterSimulation />) || (
<Section title="Virtual Explosive Simulator v2.01">
<LabeledList>
<LabeledList.Item label="Mode">
<Button onClick={() => act('set_mode', { mode: 1 })} selected={mode === 1}>
Single Tank
</Button>
<Button onClick={() => act('set_mode', { mode: 2 })} selected={mode === 2}>
Transfer Valve
</Button>
<Button onClick={() => act('set_mode', { mode: 3 })} selected={mode === 3}>
Canister
</Button>
</LabeledList.Item>
<LabeledList.Item label="Primary Slot">
{(tank1 && (
<Button onClick={() => act('remove_tank', { ref: tank1ref })} icon="eject">
{tank1}
</Button>
)) || (
<Button onClick={() => act('add_tank', { slot: 1 })} icon="upload">
Insert Tank
</Button>
)}
</LabeledList.Item>
<LabeledList.Item label="Secondary Slot">
{(tank2 && (
<Button onClick={() => act('remove_tank', { ref: tank2ref })} icon="eject">
{tank2}
</Button>
)) || (
<Button onClick={() => act('add_tank', { slot: 2 })} icon="upload">
Insert Tank
</Button>
)}
</LabeledList.Item>
<LabeledList.Item
label="Connected Canister"
buttons={
<Button onClick={() => act('canister_scan')} icon="search">
Scan
</Button>
}>
{(canister && <Box color="label">{canister}</Box>) || <Box color="bad">No tank connected.</Box>}
</LabeledList.Item>
{canister && (
<LabeledList.Item label="Canister Release Pressure">
<Slider
minValue={0}
value={sim_canister_output}
maxValue={1013.25}
onDrag={(e, val) => act('set_can_pressure', { pressure: val })}
/>
</LabeledList.Item>
)}
</LabeledList>
<Button mt={2} color="red" icon="bomb" fontSize={2} onClick={() => act('start_sim')} fluid>
Begin Simulation
</Button>
</Section>
)}
</Window.Content>
</Window>
);
};
class BombTesterSimulation extends Component {
constructor(props) {
super(props);
const BOUND_X = 340;
const BOUND_Y = 205;
const MOVEMENT_SPEED = 2;
let startRight = Math.random() > 0.5;
let startBottom = Math.random() > 0.5;
this.state = {
x: startRight ? BOUND_X : 0,
y: startBottom ? BOUND_Y : 0,
reverseX: false,
reverseY: false,
};
this.process = setInterval(() => {
this.setState((prevState) => {
const state = { ...prevState };
if (state.reverseX) {
if (state.x - MOVEMENT_SPEED < -5) {
state.reverseX = false;
state.x += MOVEMENT_SPEED;
} else {
state.x -= MOVEMENT_SPEED;
}
} else {
if (state.x + MOVEMENT_SPEED > BOUND_X) {
state.reverseX = true;
state.x -= MOVEMENT_SPEED;
} else {
state.x += MOVEMENT_SPEED;
}
}
if (state.reverseY) {
if (state.y - MOVEMENT_SPEED < -20) {
state.reverseY = false;
state.y += MOVEMENT_SPEED;
} else {
state.y -= MOVEMENT_SPEED;
}
} else {
if (state.y + MOVEMENT_SPEED > BOUND_Y) {
state.reverseY = true;
state.y -= MOVEMENT_SPEED;
} else {
state.y += MOVEMENT_SPEED;
}
}
return state;
});
}, 1);
}
componentWillUnmount() {
clearInterval(this.process);
}
render() {
const { x, y } = this.state;
const newStyle = {
position: 'relative',
'left': x + 'px',
'top': y + 'px',
};
return (
<Section title="Simulation in progress!" fill>
<Box position="absolute" style={{ overflow: 'hidden', width: '100%', height: '100%' }}>
<Icon style={newStyle} name="bomb" size={10} color="red" />
</Box>
</Section>
);
}
}

View File

@@ -0,0 +1,55 @@
import { useBackend } from '../backend';
import { Box, Button, LabeledList, Section, NoticeBox } from '../components';
import { Window } from '../layouts';
export const BotanyEditor = (props, context) => {
const { act, data } = useBackend(context);
const { activity, degradation, disk, sourceName, locus, loaded } = data;
if (activity) {
return (
<Window width={470} height={500} resizable>
<Window.Content scrollable>
<NoticeBox info>Scanning...</NoticeBox>
</Window.Content>
</Window>
);
}
return (
<Window width={470} height={500} resizable>
<Window.Content scrollable>
<Section title="Buffered Genetic Data">
{(disk && (
<Box>
<LabeledList>
<LabeledList.Item label="Source">{sourceName}</LabeledList.Item>
<LabeledList.Item label="Gene Decay">{degradation}%</LabeledList.Item>
<LabeledList.Item label="Locus">{locus}</LabeledList.Item>
</LabeledList>
<Button mt={1} icon="eject" onClick={() => act('eject_disk')}>
Eject Loaded Disk
</Button>
</Box>
)) || <NoticeBox warning>No disk loaded.</NoticeBox>}
</Section>
<Section title="Loaded Material">
{(loaded && (
<Box>
<LabeledList>
<LabeledList.Item label="Target">{loaded}</LabeledList.Item>
</LabeledList>
<Button mt={1} icon="cog" onClick={() => act('apply_gene')}>
Apply Gene Mods
</Button>
<Button mt={1} icon="eject" onClick={() => act('eject_packet')}>
Eject Target
</Button>
</Box>
)) || <NoticeBox warning>No target seed packet loaded.</NoticeBox>}
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,88 @@
import { useBackend } from '../backend';
import { Box, Button, LabeledList, Section, NoticeBox } from '../components';
import { Window } from '../layouts';
export const BotanyIsolator = (props, context) => {
const { act, data } = useBackend(context);
const { geneMasks, activity, degradation, disk, loaded, hasGenetics, sourceName } = data;
if (activity) {
return (
<Window width={470} height={500} resizable>
<Window.Content scrollable>
<NoticeBox info>Scanning...</NoticeBox>
</Window.Content>
</Window>
);
}
return (
<Window width={470} height={500} resizable>
<Window.Content scrollable>
<Section title="Buffered Genetic Data">
{(hasGenetics && (
<Box>
<LabeledList>
<LabeledList.Item label="Source">{sourceName}</LabeledList.Item>
<LabeledList.Item label="Gene decay">{degradation}%</LabeledList.Item>
{(disk &&
geneMasks.length &&
geneMasks.map((mask) => (
<LabeledList.Item key={mask.mask} label={mask.mask}>
<Button mb={-1} icon="download" onClick={() => act('get_gene', { get_gene: mask.tag })}>
Extract
</Button>
</LabeledList.Item>
))) ||
null}
</LabeledList>
{(disk && (
<Box mt={1}>
<Button icon="eject" onClick={() => act('eject_disk')}>
Eject Loaded Disk
</Button>
<Button icon="trash" onClick={() => act('clear_buffer')}>
Clear Genetic Buffer
</Button>
</Box>
)) || (
<NoticeBox mt={1} warning>
No disk inserted.
</NoticeBox>
)}
</Box>
)) || (
<Box>
<NoticeBox warning>No Data Buffered.</NoticeBox>
{(disk && (
<Button icon="eject" onClick={() => act('eject_disk')}>
Eject Loaded Disk
</Button>
)) || (
<NoticeBox mt={1} warning>
No disk inserted.
</NoticeBox>
)}
</Box>
)}
</Section>
<Section title="Loaded Material">
{(loaded && (
<Box>
<LabeledList>
<LabeledList.Item label="Packet Loaded">{loaded}</LabeledList.Item>
</LabeledList>
<Button mt={1} icon="cog" onClick={() => act('scan_genome')}>
Process Genome
</Button>
<Button icon="eject" onClick={() => act('eject_packet')}>
Eject Packet
</Button>
</Box>
)) || <NoticeBox warning>No packet loaded.</NoticeBox>}
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,73 @@
import { round } from 'common/math';
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { Button, Section, NumberInput, Flex } from '../components';
import { Window } from '../layouts';
import { formatTime } from '../format';
export const BrigTimer = (props, context) => {
const { act, data } = useBackend(context);
return (
<Window width={300} height={138} resizable>
<Window.Content scrollable>
<Section
title="Cell Timer"
buttons={
<Fragment>
<Button
icon="clock-o"
content={data.timing ? 'Stop' : 'Start'}
selected={data.timing}
onClick={() => act(data.timing ? 'stop' : 'start')}
/>
{(data.flash_found && (
<Button
icon="lightbulb-o"
content={data.flash_charging ? 'Recharging' : 'Flash'}
disabled={data.flash_charging}
onClick={() => act('flash')}
/>
)) ||
null}
</Fragment>
}>
<NumberInput
animated
fluid
value={data.time_left / 10}
minValue={0}
maxValue={data.max_time_left / 10}
format={(val) => formatTime(round(val))}
onDrag={(e, val) => act('time', { time: val })}
/>
<Flex mt={1}>
<Flex.Item grow={1}>
<Button
fluid
icon="hourglass-start"
content={'Add ' + formatTime(data.preset_short / 10)}
onClick={() => act('preset', { preset: 'short' })}
/>
</Flex.Item>
<Flex.Item grow={1}>
<Button
fluid
icon="hourglass-start"
content={'Add ' + formatTime(data.preset_medium / 10)}
onClick={() => act('preset', { preset: 'medium' })}
/>
</Flex.Item>
<Flex.Item grow={1}>
<Button
fluid
icon="hourglass-start"
content={'Add ' + formatTime(data.preset_long / 10)}
onClick={() => act('preset', { preset: 'long' })}
/>
</Flex.Item>
</Flex>
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,129 @@
import { filter, sortBy } from 'common/collections';
import { flow } from 'common/fp';
import { classes } from 'common/react';
import { createSearch } from 'common/string';
import { Fragment } from 'inferno';
import { useBackend, useLocalState } from '../backend';
import { Button, ByondUi, Input, Section, Dropdown } from '../components';
import { refocusLayout, Window } from '../layouts';
/**
* Returns previous and next camera names relative to the currently
* active camera.
*/
const prevNextCamera = (cameras, activeCamera) => {
if (!activeCamera) {
return [];
}
const index = cameras.findIndex((camera) => camera.name === activeCamera.name);
return [cameras[index - 1]?.name, cameras[index + 1]?.name];
};
/**
* Camera selector.
*
* Filters cameras, applies search terms and sorts the alphabetically.
*/
const selectCameras = (cameras, searchText = '', networkFilter = '') => {
const testSearch = createSearch(searchText, (camera) => camera.name);
let fl = flow([
// Null camera filter
filter((camera) => camera?.name),
// Optional search term
searchText && filter(testSearch),
// Optional network filter
networkFilter && filter((camera) => camera.networks.includes(networkFilter)),
// Slightly expensive, but way better than sorting in BYOND
sortBy((camera) => camera.name),
])(cameras);
return fl;
};
export const CameraConsole = (props, context) => {
return (
<Window width={870} height={708} resizable>
<CameraConsoleContent />
</Window>
);
};
export const CameraConsoleContent = (props, context) => {
const { act, data, config } = useBackend(context);
const { mapRef, activeCamera } = data;
const cameras = selectCameras(data.cameras);
const [prevCameraName, nextCameraName] = prevNextCamera(cameras, activeCamera);
return (
<Fragment>
<div className="CameraConsole__left">
<Window.Content scrollable>
<CameraConsoleSearch />
</Window.Content>
</div>
<div className="CameraConsole__right">
<div className="CameraConsole__toolbar">
<b>Camera: </b>
{(activeCamera && activeCamera.name) || '—'}
</div>
<div className="CameraConsole__toolbarRight">
<Button icon="chevron-left" onClick={() => act('pan', { dir: 8 })} />
<Button icon="chevron-up" onClick={() => act('pan', { dir: 1 })} />
<Button icon="chevron-right" onClick={() => act('pan', { dir: 4 })} />
<Button icon="chevron-down" onClick={() => act('pan', { dir: 2 })} />
</div>
<ByondUi
className="CameraConsole__map"
params={{
id: mapRef,
type: 'map',
}}
/>
</div>
</Fragment>
);
};
export const CameraConsoleSearch = (props, context) => {
const { act, data } = useBackend(context);
const [searchText, setSearchText] = useLocalState(context, 'searchText', '');
const [networkFilter, setNetworkFilter] = useLocalState(context, 'networkFilter', '');
const { activeCamera, allNetworks } = data;
allNetworks.sort();
const cameras = selectCameras(data.cameras, searchText, networkFilter);
return (
<Fragment>
<Input fluid mb={1} placeholder="Search for a camera" onInput={(e, value) => setSearchText(value)} />
<Dropdown
mb={1}
width="177px"
options={allNetworks}
placeholder="No Filter"
onSelected={(value) => setNetworkFilter(value)}
/>
<Section>
{cameras.map((camera) => (
// We're not using the component here because performance
// would be absolutely abysmal (50+ ms for each re-render).
<div
key={camera.name}
title={camera.name}
className={classes([
'Button',
'Button--fluid',
'Button--color--transparent',
'Button--ellipsis',
activeCamera && camera.name === activeCamera.name && 'Button--selected',
])}
onClick={() => {
refocusLayout();
act('switch_camera', {
name: camera.name,
});
}}>
{camera.name}
</div>
))}
</Section>
</Fragment>
);
};

View File

@@ -0,0 +1,124 @@
import { toFixed } from 'common/math';
import { useBackend } from '../backend';
import { AnimatedNumber, Box, Button, Icon, Knob, LabeledControls, LabeledList, Section, Tooltip } from '../components';
import { formatSiUnit } from '../format';
import { Window } from '../layouts';
export const Canister = (props, context) => {
const { act, data } = useBackend(context);
const {
connected,
can_relabel,
pressure,
releasePressure,
defaultReleasePressure,
minReleasePressure,
maxReleasePressure,
valveOpen,
holding,
} = data;
return (
<Window width={360} height={242} resizable>
<Window.Content>
<Section
title="Canister"
buttons={
<Button icon="pencil-alt" disabled={!can_relabel} content="Relabel" onClick={() => act('relabel')} />
}>
<LabeledControls>
<LabeledControls.Item minWidth="66px" label="Tank Pressure">
<AnimatedNumber
value={pressure}
format={(value) => {
if (value < 10000) {
return toFixed(value) + ' kPa';
}
return formatSiUnit(value * 1000, 1, 'Pa');
}}
/>
</LabeledControls.Item>
<LabeledControls.Item label="Regulator">
<Box position="relative" left="-8px">
<Knob
forcedInputWidth="60px"
size={1.25}
color={!!valveOpen && 'yellow'}
value={releasePressure}
unit="kPa"
minValue={minReleasePressure}
maxValue={maxReleasePressure}
stepPixelSize={1}
onDrag={(e, value) =>
act('pressure', {
pressure: value,
})
}
/>
<Button
fluid
position="absolute"
top="-2px"
right="-20px"
color="transparent"
icon="fast-forward"
onClick={() =>
act('pressure', {
pressure: maxReleasePressure,
})
}
/>
<Button
fluid
position="absolute"
top="16px"
right="-20px"
color="transparent"
icon="undo"
onClick={() =>
act('pressure', {
pressure: defaultReleasePressure,
})
}
/>
</Box>
</LabeledControls.Item>
<LabeledControls.Item label="Valve">
<Button
my={0.5}
width="50px"
lineHeight={2}
fontSize="11px"
color={valveOpen ? (holding ? 'caution' : 'danger') : null}
content={valveOpen ? 'Open' : 'Closed'}
onClick={() => act('valve')}
/>
</LabeledControls.Item>
<LabeledControls.Item mr={1} label="Port">
<Box position="relative">
<Icon size={1.25} name={connected ? 'plug' : 'times'} color={connected ? 'good' : 'bad'} />
<Tooltip content={connected ? 'Connected' : 'Disconnected'} position="top" />
</Box>
</LabeledControls.Item>
</LabeledControls>
</Section>
<Section
title="Holding Tank"
buttons={
!!holding && (
<Button icon="eject" color={valveOpen && 'danger'} content="Eject" onClick={() => act('eject')} />
)
}>
{!!holding && (
<LabeledList>
<LabeledList.Item label="Label">{holding.name}</LabeledList.Item>
<LabeledList.Item label="Pressure">
<AnimatedNumber value={holding.pressure} /> kPa
</LabeledList.Item>
</LabeledList>
)}
{!holding && <Box color="average">No Holding Tank</Box>}
</Section>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,98 @@
import { Component, createRef } from 'inferno';
import { useBackend } from '../backend';
import { Box, Button } from '../components';
import { Window } from '../layouts';
const PX_PER_UNIT = 24;
class PaintCanvas extends Component {
constructor(props) {
super(props);
this.canvasRef = createRef();
this.onCVClick = props.onCanvasClick;
}
componentDidMount() {
this.drawCanvas(this.props);
}
componentDidUpdate() {
this.drawCanvas(this.props);
}
drawCanvas(propSource) {
const ctx = this.canvasRef.current.getContext('2d');
const grid = propSource.value;
const x_size = grid.length;
if (!x_size) {
return;
}
const y_size = grid[0].length;
const x_scale = Math.round(this.canvasRef.current.width / x_size);
const y_scale = Math.round(this.canvasRef.current.height / y_size);
ctx.save();
ctx.scale(x_scale, y_scale);
for (let x = 0; x < grid.length; x++) {
const element = grid[x];
for (let y = 0; y < element.length; y++) {
const color = element[y];
ctx.fillStyle = color;
ctx.fillRect(x, y, 1, 1);
}
}
ctx.restore();
}
clickwrapper(event) {
const x_size = this.props.value.length;
if (!x_size) {
return;
}
const y_size = this.props.value[0].length;
const x_scale = this.canvasRef.current.width / x_size;
const y_scale = this.canvasRef.current.height / y_size;
const x = Math.floor(event.offsetX / x_scale) + 1;
const y = Math.floor(event.offsetY / y_scale) + 1;
this.onCVClick(x, y);
}
render() {
const { res = 1, value, dotsize = PX_PER_UNIT, ...rest } = this.props;
const [width, height] = getImageSize(value);
return (
<canvas
ref={this.canvasRef}
width={width * dotsize || 300}
height={height * dotsize || 300}
{...rest}
onClick={(e) => this.clickwrapper(e)}>
Canvas failed to render.
</canvas>
);
}
}
const getImageSize = (value) => {
const width = value.length;
const height = width !== 0 ? value[0].length : 0;
return [width, height];
};
export const Canvas = (props, context) => {
const { act, data } = useBackend(context);
const dotsize = PX_PER_UNIT;
const [width, height] = getImageSize(data.grid);
return (
<Window width={Math.min(700, width * dotsize + 72)} height={Math.min(700, height * dotsize + 72)}>
<Window.Content>
<Box textAlign="center">
<PaintCanvas value={data.grid} dotsize={dotsize} onCanvasClick={(x, y) => act('paint', { x, y })} />
<Box>
{!data.finalized && <Button.Confirm onClick={() => act('finalize')} content="Finalize" />}
&nbsp;{data.name}
</Box>
</Box>
</Window.Content>
</Window>
);
};

View File

@@ -0,0 +1,141 @@
import { createSearch } from 'common/string';
import { Fragment } from 'inferno';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, Collapsible, Dropdown, Flex, Input, Section } from '../components';
import { Window } from '../layouts';
import { refocusLayout } from '../layouts';
const sortTypes = {
'Alphabetical': (a, b) => a - b,
'By availability': (a, b) => -(a.affordable - b.affordable),
'By price': (a, b) => a.price - b.price,
};
export const CasinoPrizeDispenserCh = () => {
return (
<Window width={400} height={450} resizable>
<Window.Content className="Layout__content--flexColumn" scrollable>
<Fragment>
<CasinoPrizeDispenserChSearch />
<CasinoPrizeDispenserChItems />
</Fragment>
</Window.Content>
</Window>
);
};
const CasinoPrizeDispenserChSearch = (props, context) => {
const [_searchText, setSearchText] = useLocalState(context, 'search', '');
const [_sortOrder, setSortOrder] = useLocalState(context, 'sort', '');
const [descending, setDescending] = useLocalState(context, 'descending', false);
return (
<Box mb="0.5rem">
<Flex width="100%">
<Flex.Item grow="1" mr="0.5rem">
<Input placeholder="Search by item name.." width="100%" onInput={(_e, value) => setSearchText(value)} />
</Flex.Item>
<Flex.Item basis="30%">
<Dropdown
selected="Alphabetical"
options={Object.keys(sortTypes)}
width="100%"
lineHeight="19px"
onSelected={(v) => setSortOrder(v)}
/>
</Flex.Item>
<Flex.Item>
<Button
icon={descending ? 'arrow-down' : 'arrow-up'}
height="19px"
tooltip={descending ? 'Descending order' : 'Ascending order'}
tooltipPosition="bottom-end"
ml="0.5rem"
onClick={() => setDescending(!descending)}
/>
</Flex.Item>
</Flex>
</Box>
);
};
const CasinoPrizeDispenserChItems = (props, context) => {
const { act, data } = useBackend(context);
const { points, items } = data;
// Search thingies
const [searchText, _setSearchText] = useLocalState(context, 'search', '');
const [sortOrder, _setSortOrder] = useLocalState(context, 'sort', 'Alphabetical');
const [descending, _setDescending] = useLocalState(context, 'descending', false);
const searcher = createSearch(searchText, (item) => {
return item[0];
});
let has_contents = false;
let contents = Object.entries(items).map((kv, _i) => {
let items_in_cat = Object.entries(kv[1])
.filter(searcher)
.map((kv2) => {
kv2[1].affordable = points >= kv2[1].price;
return kv2[1];
})
.sort(sortTypes[sortOrder]);
if (items_in_cat.length === 0) {
return;
}
if (descending) {
items_in_cat = items_in_cat.reverse();
}
has_contents = true;
return <CasinoPrizeDispenserChItemsCategory key={kv[0]} title={kv[0]} items={items_in_cat} />;
});
return (
<Flex.Item grow="1" overflow="auto">
<Section onClick={(e) => refocusLayout()}>
{has_contents ? contents : <Box color="label">No items matching your criteria was found!</Box>}
</Section>
</Flex.Item>
);
};
const CasinoPrizeDispenserChItemsCategory = (properties, context) => {
const { act, data } = useBackend(context);
const { title, items, ...rest } = properties;
return (
<Collapsible open title={title} {...rest}>
{items.map((item) => (
<Box key={item.name}>
<Box
display="inline-block"
verticalAlign="middle"
lineHeight="20px"
style={{
float: 'left',
}}>
{item.name}
</Box>
<Button
content={item.price.toLocaleString('en-US')}
width="15%"
textAlign="center"
style={{
float: 'right',
}}
onClick={() =>
act('purchase', {
cat: title,
name: item.name,
price: item.price,
restriction: item.restriction,
})
}
/>
<Box
style={{
clear: 'both',
}}
/>
</Box>
))}
</Collapsible>
);
};

View File

@@ -0,0 +1,238 @@
import { Fragment } from 'inferno';
import { useBackend, useLocalState } from '../backend';
import { Box, Button, Icon, LabeledList, Section, Table } from '../components';
import { Window } from '../layouts';
const getTagColor = (tag) => {
switch (tag) {
case 'Unset':
return 'label';
case 'Pred':
return 'red';
case 'Pred-Pref':
return 'orange';
case 'Prey':
return 'blue';
case 'Prey-Pref':
return 'green';
case 'Switch':
return 'yellow';
case 'Non-Vore':
return 'black';
}
};
export const CharacterDirectory = (props, context) => {
const { act, data } = useBackend(context);
const { personalVisibility, personalTag, personalGenderTag, personalSexualityTag, personalErpTag, personalEventTag } =
data;
const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
const [overwritePrefs, setOverwritePrefs] = useLocalState(context, 'overwritePrefs', false);
return (
<Window width={816} height={722} resizeable>
<Window.Content scrollable>
{(overlay && <ViewCharacter />) || (
<Fragment>
<Section
title="Settings and Preferences"
buttons={
<Fragment>
<Box color="label" inline>
Save to current preferences slot:&nbsp;
</Box>
<Button
icon={overwritePrefs ? 'toggle-on' : 'toggle-off'}
selected={overwritePrefs}
content={overwritePrefs ? 'On' : 'Off'}
onClick={() => setOverwritePrefs(!overwritePrefs)}
/>
</Fragment>
}>
<LabeledList>
<LabeledList.Item label="Visibility">
<Button
fluid
content={personalVisibility ? 'Shown' : 'Not Shown'}
onClick={() => act('setVisible', { overwrite_prefs: overwritePrefs })}
/>
</LabeledList.Item>
<LabeledList.Item label="Vore Tag">
<Button
fluid
content={personalTag}
onClick={() => act('setTag', { overwrite_prefs: overwritePrefs })}
/>
</LabeledList.Item>
<LabeledList.Item label="Gender">
<Button
fluid
content={personalGenderTag}
onClick={() => act('setGenderTag', { overwrite_prefs: overwritePrefs })}
/>
</LabeledList.Item>
<LabeledList.Item label="Sexuality">
<Button
fluid
content={personalSexualityTag}
onClick={() => act('setSexualityTag', { overwrite_prefs: overwritePrefs })}
/>
</LabeledList.Item>
<LabeledList.Item label="ERP Tag">
<Button
fluid
content={personalErpTag}
onClick={() => act('setErpTag', { overwrite_prefs: overwritePrefs })}
/>
</LabeledList.Item>
<LabeledList.Item label="Event Pref">
<Button
fluid
content={personalEventTag}
onClick={() => act('setEventTag', { overwrite_prefs: overwritePrefs })}
/>
</LabeledList.Item>
<LabeledList.Item label="Advertisement">
<Button fluid content="Edit Ad" onClick={() => act('editAd', { overwrite_prefs: overwritePrefs })} />
</LabeledList.Item>
</LabeledList>
</Section>
<CharacterDirectoryList />
</Fragment>
)}
</Window.Content>
</Window>
);
};
const ViewCharacter = (props, context) => {
const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
return (
<Section
title={overlay.name}
buttons={<Button icon="arrow-left" content="Back" onClick={() => setOverlay(null)} />}>
<Section level={2} title="Species">
<Box>{overlay.species}</Box>
</Section>
<Section level={2} title="Vore Tag">
<Box p={1} backgroundColor={getTagColor(overlay.tag)}>
{overlay.tag}
</Box>
</Section>
<Section level={2} title="Gender">
<Box>{overlay.gendertag}</Box>
</Section>
<Section level={2} title="Sexuality">
<Box>{overlay.sexualitytag}</Box>
</Section>
<Section level={2} title="ERP Tag">
<Box>{overlay.erptag}</Box>
</Section>
<Section level={2} title="Event Pref">
<Box>{overlay.eventtag}</Box>
</Section>
<Section level={2} title="Character Ad">
<Box style={{ 'word-break': 'break-all' }} preserveWhitespace>
{overlay.character_ad || 'Unset.'}
</Box>
</Section>
<Section level={2} title="OOC Notes">
<Box style={{ 'word-break': 'break-all' }} preserveWhitespace>
{overlay.ooc_notes || 'Unset.'}
</Box>
</Section>
<Section level={2} title="Flavor Text">
<Box style={{ 'word-break': 'break-all' }} preserveWhitespace>
{overlay.flavor_text || 'Unset.'}
</Box>
</Section>
</Section>
);
};
const CharacterDirectoryList = (props, context) => {
const { act, data } = useBackend(context);
const { directory } = data;
const [sortId, _setSortId] = useLocalState(context, 'sortId', 'name');
const [sortOrder, _setSortOrder] = useLocalState(context, 'sortOrder', 'name');
const [overlay, setOverlay] = useLocalState(context, 'overlay', null);
return (
<Section title="Directory" buttons={<Button icon="sync" content="Refresh" onClick={() => act('refresh')} />}>
<Table>
<Table.Row bold>
<SortButton id="name">Name</SortButton>
<SortButton id="species">Species</SortButton>
<SortButton id="tag">Vore Tag</SortButton>
<SortButton id="gendertag">Gender</SortButton>
<SortButton id="sexualitytag">Sexuality</SortButton>
<SortButton id="erptag">ERP Tag</SortButton>
<SortButton id="eventtag">Event Pref</SortButton>
<Table.Cell collapsing textAlign="right">
View
</Table.Cell>
</Table.Row>
{directory
.sort((a, b) => {
const i = sortOrder ? 1 : -1;
return a[sortId].localeCompare(b[sortId]) * i;
})
.map((character, i) => (
<Table.Row key={i} backgroundColor={getTagColor(character.tag)}>
<Table.Cell p={1}>{character.name}</Table.Cell>
<Table.Cell>{character.species}</Table.Cell>
<Table.Cell>{character.tag}</Table.Cell>
<Table.Cell>{character.gendertag}</Table.Cell>
<Table.Cell>{character.sexualitytag}</Table.Cell>
<Table.Cell>{character.erptag}</Table.Cell>
<Table.Cell>{character.eventtag}</Table.Cell>
<Table.Cell collapsing textAlign="right">
<Button
onClick={() => setOverlay(character)}
color="transparent"
icon="sticky-note"
mr={1}
content="View"
/>
</Table.Cell>
</Table.Row>
))}
</Table>
</Section>
);
};
const SortButton = (props, context) => {
const { act, data } = useBackend(context);
const { id, children } = props;
// Hey, same keys mean same data~
const [sortId, setSortId] = useLocalState(context, 'sortId', 'name');
const [sortOrder, setSortOrder] = useLocalState(context, 'sortOrder', 'name');
return (
<Table.Cell collapsing>
<Button
width="100%"
color={sortId !== id && 'transparent'}
onClick={() => {
if (sortId === id) {
setSortOrder(!sortOrder);
} else {
setSortId(id);
setSortOrder(true);
}
}}>
{children}
{sortId === id && <Icon name={sortOrder ? 'sort-up' : 'sort-down'} ml="0.25rem;" />}
</Button>
</Table.Cell>
);
};

View File

@@ -0,0 +1,160 @@
import { Fragment } from 'inferno';
import { useBackend } from '../backend';
import { Box, Button, Flex, LabeledList, Slider, Section } from '../components';
import { BeakerContents } from '../interfaces/common/BeakerContents';
import { Window } from '../layouts';
const dispenseAmounts = [5, 10, 20, 30, 40, 60];
const removeAmounts = [1, 5, 10];
export const ChemDispenser = (props, context) => {
return (
<Window width={390} height={655} resizable>
<Window.Content className="Layout__content--flexColumn">
<ChemDispenserSettings />
<ChemDispenserChemicals />
<ChemDispenserBeaker />
</Window.Content>
</Window>
);
};
const ChemDispenserSettings = (properties, context) => {
const { act, data } = useBackend(context);
const { amount } = data;
return (
<Section title="Settings" flex="content">
<LabeledList>
<LabeledList.Item label="Dispense" verticalAlign="middle">
<Flex direction="row" wrap="wrap" spacing="1">
{dispenseAmounts.map((a, i) => (
<Flex.Item key={i} grow="1">
<Button
textAlign="center"
selected={amount === a}
content={a + 'u'}
m="0"
fluid
onClick={() =>
act('amount', {
amount: a,
})
}
/>
</Flex.Item>
))}
</Flex>
</LabeledList.Item>
<LabeledList.Item label="Custom Amount">
<Slider
step={1}
stepPixelSize={5}
value={amount}
minValue={1}
maxValue={120}
onDrag={(e, value) =>
act('amount', {
amount: value,
})
}
/>
</LabeledList.Item>
</LabeledList>
</Section>
);
};
const ChemDispenserChemicals = (properties, context) => {
const { act, data } = useBackend(context);
const { chemicals = [] } = data;
const flexFillers = [];
for (let i = 0; i < (chemicals.length + 1) % 3; i++) {
flexFillers.push(true);
}
return (
<Section title={data.glass ? 'Drink Dispenser' : 'Chemical Dispenser'} flexGrow="1">
<Flex direction="row" wrap="wrap" height="100%" align="flex-start">
{chemicals.map((c, i) => (
<Flex.Item key={i} grow="1" m={0.2} basis="40%" height="20px">
<Button
icon="arrow-circle-down"
width="100%"
height="100%"
align="flex-start"
content={c.title + ' (' + c.amount + ')'}
onClick={() =>
act('dispense', {
reagent: c.id,
})
}
/>
</Flex.Item>
))}
{flexFillers.map((_, i) => (
<Flex.Item key={i} grow="1" basis="25%" height="20px" />
))}
</Flex>
</Section>
);
};
const ChemDispenserBeaker = (properties, context) => {
const { act, data } = useBackend(context);
const { isBeakerLoaded, beakerCurrentVolume, beakerMaxVolume, beakerContents = [] } = data;
return (
<Section
title="Beaker"
flex="content"
minHeight="25%"
buttons={
<Box>
{!!isBeakerLoaded && (
<Box inline color="label" mr={2}>
{beakerCurrentVolume} / {beakerMaxVolume} units
</Box>
)}
<Button icon="eject" content="Eject" disabled={!isBeakerLoaded} onClick={() => act('ejectBeaker')} />
</Box>
}>
<BeakerContents
beakerLoaded={isBeakerLoaded}
beakerContents={beakerContents}
buttons={(chemical) => (
<Fragment>
<Button
content="Isolate"
icon="compress-arrows-alt"
onClick={() =>
act('remove', {
reagent: chemical.id,
amount: -1,
})
}
/>
{removeAmounts.map((a, i) => (
<Button
key={i}
content={a}
onClick={() =>
act('remove', {
reagent: chemical.id,
amount: a,
})
}
/>
))}
<Button
content="ALL"
onClick={() =>
act('remove', {
reagent: chemical.id,
amount: chemical.volume,
})
}
/>
</Fragment>
)}
/>
</Section>
);
};

Some files were not shown because too many files have changed in this diff Show More