Merge branch 'nui'

This commit is contained in:
Daan Vanden Bosch 2019-09-14 17:32:32 +02:00
commit aca71d1a17
243 changed files with 8519 additions and 6950 deletions

View File

@ -1,13 +1,12 @@
{
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"prettier/@typescript-eslint",
"plugin:prettier/recommended"
],
"plugins": ["react", "@typescript-eslint", "prettier"],
"plugins": ["@typescript-eslint", "prettier"],
"env": {
"browser": true,
"es6": true,
@ -34,14 +33,7 @@
"no-constant-condition": ["warn", { "checkLoops": false }],
"no-empty": "warn",
"no-useless-escape": "warn",
"prettier/prettier": "warn",
"react/no-unescaped-entities": "off"
},
"settings": {
"react": {
"pragma": "React",
"version": "detect"
}
"prettier/prettier": "warn"
},
"parser": "@typescript-eslint/parser"
}

View File

@ -1,73 +0,0 @@
module.exports = {
// Some colors are set to ridiculous values so they stand out once
// they're first used. They can then be changed to something more
// sensible.
"@background-color-base": "hsl(0, 0%, 20%)",
"@background-color-light": "lighten(@background-color-base, 20%)",
"@body-background": "@background-color-base",
"@component-background": "@background-color-base",
"@text-color": "hsl(0, 0%, 90%)",
"@text-color-secondary": "hsl(0, 0%, 35%)",
"@text-color-dark": "hsl(0, 0%, 15%)",
"@text-color-dark-secondary": "hsl(0, 0%, 35%)",
"@heading-color": "hsl(0, 0%, 85%)",
"@item-hover-bg": "hsl(200, 30%, 30%)",
"@item-active-bg": "hsl(200, 50%, 30%)",
"@primary-color": "hsl(200, 60%, 75%)",
// Color used to control the text color in many active and hover states.
"@primary-5": "hsl(200, 10%, 60%)",
// Color used to control the text color of active buttons.
"@primary-6": "hsl(200, 30%, 60%)",
"@disabled-color": "hsl(0, 0%, 50%)",
"@tag-default-bg": "yellow",
"@popover-bg": "yellow",
"@highlight-color": "blue",
"@info-color": "orange",
"@warning-color": "salmon",
"@alert-message-color": "red",
"@padding-lg": "12px",
"@padding-md": "8px",
"@padding-sm": "6px",
"@padding-xs": "4px",
"@layout-body-background": "cyan",
"@layout-sider-background": "lime",
"@layout-header-background": "lime",
"@layout-trigger-color": "magenta",
"@layout-trigger-background": "purple",
"@menu-dark-bg": "@component-background",
"@menu-dark-submenu-bg": "@component-background",
"@input-bg": "darken(@background-color-base, 5%)",
"@input-height-base": "28px",
"@input-height-lg": "34px",
"@input-height-sm": "24px",
"@btn-height-base": "28px",
"@btn-height-lg": "34px",
"@btn-height-sm": "24px",
"@btn-default-bg": "lighten(@background-color-base, 10%)",
"@border-color-base": "lighten(@background-color-base, 20%)",
"@border-color-split": "lighten(@background-color-base, 10%)",
"@border-radius-base": "0",
"@border-radius-sm": "0",
"@table-selected-row-bg": "@item-active-bg",
"@table-row-hover-bg": "@item-hover-bg",
"@collapse-header-bg": "yellow",
"@tabs-card-head-background": "darken(@background-color-base, 5%)",
"@tabs-card-height": "28px",
"@tabs-card-active-color": "white",
"@tabs-highlight-color": "white",
"@tabs-hover-color": "white",
"@tabs-active-color": "white",
"@tabs-card-active-color": "white",
"@tabs-ink-bar-color": "white",
};

View File

@ -3,12 +3,13 @@ import { writeFileSync } from "fs";
import "isomorphic-fetch";
import Logger from "js-logger";
import { ASSETS_DIR } from ".";
import { Difficulty, SectionId, SectionIds } from "../src/core/domain";
import { BoxDropDto, EnemyDropDto, ItemTypeDto } from "../src/core/dto";
import { Difficulty, SectionId, SectionIds } from "../src/core/model";
import {
name_and_episode_to_npc_type,
NpcType,
} from "../src/core/data_formats/parsing/quest/npc_types";
import { ItemTypeDto } from "../src/core/dto/ItemTypeDto";
import { BoxDropDto, EnemyDropDto } from "../src/hunt_optimizer/dto/drops";
const logger = Logger.get("assets_generation/update_drops_ephinea");

View File

@ -5,12 +5,14 @@ import { BufferCursor } from "../src/core/data_formats/cursor/BufferCursor";
import { ItemPmt, parse_item_pmt } from "../src/core/data_formats/parsing/itempmt";
import { parse_quest } from "../src/core/data_formats/parsing/quest";
import { parse_unitxt, Unitxt } from "../src/core/data_formats/parsing/unitxt";
import { Difficulties, Difficulty, SectionId, SectionIds } from "../src/core/domain";
import { BoxDropDto, EnemyDropDto, ItemTypeDto, QuestDto } from "../src/core/dto";
import { Difficulties, Difficulty, SectionId, SectionIds } from "../src/core/model";
import { update_drops_from_website } from "./update_drops_ephinea";
import { Episode, EPISODES } from "../src/core/data_formats/parsing/quest/Episode";
import { npc_data, NPC_TYPES, NpcType } from "../src/core/data_formats/parsing/quest/npc_types";
import { Endianness } from "../src/core/data_formats/Endianness";
import { ItemTypeDto } from "../src/core/dto/ItemTypeDto";
import { QuestDto } from "../src/hunt_optimizer/dto/QuestDto";
import { BoxDropDto, EnemyDropDto } from "../src/hunt_optimizer/dto/drops";
const logger = Logger.get("assets_generation/update_ephinea_data");

View File

@ -5,24 +5,14 @@
"license": "MIT",
"dependencies": {
"@types/lodash": "^4.14.132",
"@types/react": "16.8.20",
"@types/react-dom": "16.8.4",
"@types/react-virtualized": "^9.21.2",
"@types/react-virtualized-select": "^3.0.7",
"antd": "^3.20.1",
"@types/luxon": "^1.15.2",
"camera-controls": "^1.12.2",
"golden-layout": "^1.5.9",
"javascript-lp-solver": "^0.4.5",
"js-logger": "^1.6.0",
"lodash": "^4.17.14",
"mobx": "^5.11.0",
"mobx-react": "^6.1.1",
"moment": "^2.24.0",
"luxon": "^1.17.2",
"monaco-editor": "^0.17.1",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-virtualized": "^9.21.1",
"react-virtualized-select": "^3.1.3",
"three": "^0.106.2"
},
"scripts": {
@ -33,10 +23,8 @@
"update_ephinea_data": "ts-node --project=tsconfig-scripts.json assets_generation/update_ephinea_data.ts",
"lint": "prettier --check \"{src,assets_generation,test}/**/*.{ts,tsx}\" && echo Linting... && eslint \"{src,assets_generation,test}/**/*.{ts,tsx}\" && echo All code passes the prettier and eslint checks."
},
"eslintConfig": {
"extends": "react-app"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^5.10.2",
"@types/cheerio": "^0.22.11",
"@types/jest": "^24.0.15",
"@types/yaml": "^1.0.2",
@ -49,15 +37,11 @@
"dotenv-webpack": "^1.7.0",
"eslint": "^5.16.0",
"eslint-config-prettier": "^6.0.0",
"eslint-config-react": "^1.1.7",
"eslint-plugin-prettier": "^3.1.0",
"eslint-plugin-react": "^7.14.3",
"file-loader": "^4.1.0",
"fork-ts-checker-webpack-plugin": "^1.4.3",
"html-webpack-plugin": "^3.2.0",
"jest": "^24.8.0",
"less": "^3.9.0",
"less-loader": "^5.0.0",
"mini-css-extract-plugin": "^0.8.0",
"monaco-editor-webpack-plugin": "^1.7.0",
"optimize-css-assets-webpack-plugin": "^5.0.3",

View File

@ -0,0 +1,25 @@
import { NavigationView } from "./NavigationView";
import { MainContentView } from "./MainContentView";
import { el } from "../../core/gui/dom";
import { ResizableWidget } from "../../core/gui/ResizableWidget";
export class ApplicationView extends ResizableWidget {
private menu_view = this.disposable(new NavigationView());
private main_content_view = this.disposable(new MainContentView());
constructor() {
super(el.div({ class: "application_ApplicationView" }));
this.element.id = "root";
this.element.append(this.menu_view.element, this.main_content_view.element);
this.finalize_construction(ApplicationView.prototype);
}
resize(width: number, height: number): this {
super.resize(width, height);
this.main_content_view.resize(width, height - this.menu_view.height);
return this;
}
}

View File

@ -0,0 +1,58 @@
import { el } from "../../core/gui/dom";
import { gui_store, GuiTool } from "../../core/stores/GuiStore";
import { LazyWidget } from "../../core/gui/LazyWidget";
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { ChangeEvent } from "../../core/observable/Observable";
const TOOLS: [GuiTool, () => Promise<ResizableWidget>][] = [
[GuiTool.Viewer, async () => new (await import("../../viewer/gui/ViewerView")).ViewerView()],
[
GuiTool.QuestEditor,
async () => new (await import("../../quest_editor/gui/QuestEditorView")).QuestEditorView(),
],
[
GuiTool.HuntOptimizer,
async () =>
new (await import("../../hunt_optimizer/gui/HuntOptimizerView")).HuntOptimizerView(),
],
];
export class MainContentView extends ResizableWidget {
private tool_views = new Map(
TOOLS.map(([tool, create_view]) => [tool, this.disposable(new LazyWidget(create_view))]),
);
constructor() {
super(el.div({ class: "application_MainContentView" }));
for (const tool_view of this.tool_views.values()) {
this.element.append(tool_view.element);
}
const tool_view = this.tool_views.get(gui_store.tool.val);
if (tool_view) tool_view.visible.val = true;
this.disposable(gui_store.tool.observe(this.tool_changed));
this.finalize_construction(MainContentView.prototype);
}
resize(width: number, height: number): this {
super.resize(width, height);
for (const tool_view of this.tool_views.values()) {
tool_view.resize(width, height);
}
return this;
}
private tool_changed = ({ value: new_tool }: ChangeEvent<GuiTool>) => {
for (const tool of this.tool_views.values()) {
tool.visible.val = false;
}
const new_view = this.tool_views.get(new_tool);
if (new_view) new_view.visible.val = true;
};
}

View File

@ -0,0 +1,24 @@
.application_NavigationButton input {
display: none;
}
.application_NavigationButton label {
box-sizing: border-box;
display: inline-flex;
flex-direction: row;
align-items: center;
font-size: 13px;
height: 100%;
padding: 0 20px;
color: hsl(0, 0%, 65%);
}
.application_NavigationButton label:hover {
color: hsl(0, 0%, 85%);
background-color: hsl(0, 0%, 12%);
}
.application_NavigationButton input:checked + label {
color: hsl(0, 0%, 85%);
background-color: var(--bg-color);
}

View File

@ -0,0 +1,31 @@
import { Widget } from "../../core/gui/Widget";
import { create_element, el } from "../../core/gui/dom";
import { GuiTool } from "../../core/stores/GuiStore";
import "./NavigationButton.css";
export class NavigationButton extends Widget {
private input: HTMLInputElement = create_element("input");
private label: HTMLLabelElement = create_element("label");
constructor(tool: GuiTool, text: string) {
super(el.span({ class: "application_NavigationButton" }));
const tool_str = GuiTool[tool];
this.input.type = "radio";
this.input.name = "application_NavigationButton";
this.input.value = tool_str;
this.input.id = `application_NavigationButton_${tool_str}`;
this.label.append(text);
this.label.htmlFor = `application_NavigationButton_${tool_str}`;
this.element.append(this.input, this.label);
this.finalize_construction(NavigationButton.prototype);
}
set checked(checked: boolean) {
this.input.checked = checked;
}
}

View File

@ -0,0 +1,35 @@
.application_NavigationView {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: stretch;
background-color: hsl(0, 0%, 10%);
border-bottom: solid 2px var(--bg-color);
}
.application_NavigationView_spacer {
flex: 1;
}
.application_NavigationView_server {
display: flex;
align-items: center;
}
.application_NavigationView_server > * {
margin: 0 2px;
}
.application_NavigationView_github {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 30px;
font-size: 16px;
color: var(--control-text-color);
}
.application_NavigationView_github:hover {
color: var(--control-text-color-hover);
}

View File

@ -0,0 +1,75 @@
import { el, icon, Icon } from "../../core/gui/dom";
import "./NavigationView.css";
import { gui_store, GuiTool } from "../../core/stores/GuiStore";
import { Widget } from "../../core/gui/Widget";
import { NavigationButton } from "./NavigationButton";
import { Select } from "../../core/gui/Select";
import { property } from "../../core/observable";
const TOOLS: [GuiTool, string][] = [
[GuiTool.Viewer, "Viewer"],
[GuiTool.QuestEditor, "Quest Editor"],
[GuiTool.HuntOptimizer, "Hunt Optimizer"],
];
export class NavigationView extends Widget {
readonly height = 30;
private buttons = new Map<GuiTool, NavigationButton>(
TOOLS.map(([value, text]) => [value, this.disposable(new NavigationButton(value, text))]),
);
constructor() {
super(el.div({ class: "application_NavigationView" }));
this.element.style.height = `${this.height}px`;
this.element.onmousedown = this.mousedown;
for (const button of this.buttons.values()) {
this.element.append(button.element);
}
this.element.append(el.div({ class: "application_NavigationView_spacer" }));
const server_select = this.disposable(
new Select(property(["Ephinea"]), server => server, {
label: "Server:",
enabled: false,
selected: "Ephinea",
tooltip: "Only Ephinea is supported at the moment",
}),
);
this.element.append(
el.span(
{ class: "application_NavigationView_server" },
server_select.label!.element,
server_select.element,
),
el.a(
{
class: "application_NavigationView_github",
href: "https://github.com/DaanVandenBosch/phantasmal-world",
title: "GitHub",
},
icon(Icon.GitHub),
),
);
this.mark_tool_button(gui_store.tool.val);
this.disposable(gui_store.tool.observe(({ value }) => this.mark_tool_button(value)));
this.finalize_construction(NavigationView.prototype);
}
private mousedown(e: MouseEvent): void {
if (e.target instanceof HTMLLabelElement && e.target.control instanceof HTMLInputElement) {
gui_store.tool.val = (GuiTool as any)[e.target.control.value];
}
}
private mark_tool_button = (tool: GuiTool) => {
const button = this.buttons.get(tool);
if (button) button.checked = true;
};
}

View File

@ -1,39 +0,0 @@
import { autorun, observable } from "mobx";
import { Server } from "../../core/domain";
class ApplicationStore {
@observable current_server: Server = Server.Ephinea;
@observable current_tool: string = this.init_tool();
private global_keyup_handlers = new Map<string, () => void>();
constructor() {
autorun(() => {
window.location.hash = `#/${this.current_tool}`;
});
}
on_global_keyup(tool: string, binding: string, handler: () => void): void {
this.global_keyup_handlers.set(`${tool} -> ${binding}`, handler);
}
dispatch_global_keyup = (e: KeyboardEvent) => {
const binding_parts: string[] = [];
if (e.ctrlKey) binding_parts.push("Ctrl");
if (e.shiftKey) binding_parts.push("Shift");
if (e.altKey) binding_parts.push("Alt");
binding_parts.push(e.key.toUpperCase());
const binding = binding_parts.join("-");
const handler = this.global_keyup_handlers.get(`${this.current_tool} -> ${binding}`);
if (handler) handler();
};
private init_tool(): string {
const tool = window.location.hash.slice(2);
return tool.length ? tool : "viewer";
}
}
export const application_store = new ApplicationStore();

View File

@ -1,48 +0,0 @@
.main {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.navbar {
display: flex;
border-bottom: solid 1px var(--border-color-split);
}
.heading_menu {
flex: 1;
margin-bottom: -1px !important;
}
.server_select {
display: flex;
align-items: center;
margin: 0 6px;
}
.server_select > span {
display: inline-block;
margin-right: 10px;
}
.beta {
color: #f55656;
font-weight: bold;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
overflow: hidden;
}
.content > * {
flex: 1;
overflow: hidden;
}

View File

@ -1,86 +0,0 @@
import { Menu, Select } from "antd";
import { ClickParam } from "antd/lib/menu";
import { observer } from "mobx-react";
import React, { ReactNode, Component } from "react";
import { Server } from "../../core/domain";
import styles from "./ApplicationComponent.css";
import { DpsCalcComponent } from "../../dps_calc/ui/DpsCalcComponent";
import { with_error_boundary } from "../../core/ui/ErrorBoundary";
import { HuntOptimizerComponent } from "../../hunt_optimizer/ui/HuntOptimizerComponent";
import { QuestEditorComponent } from "../../quest_editor/ui/QuestEditorComponent";
import { ViewerComponent } from "../../viewer/ui/ViewerComponent";
import { application_store } from "../stores/ApplicationStore";
const Viewer = with_error_boundary(ViewerComponent);
const QuestEditor = with_error_boundary(QuestEditorComponent);
const HuntOptimizer = with_error_boundary(HuntOptimizerComponent);
const DpsCalc = with_error_boundary(DpsCalcComponent);
@observer
export class ApplicationComponent extends Component {
componentDidMount(): void {
window.addEventListener("keyup", this.keyup);
}
componentWillUnmount(): void {
window.removeEventListener("keyup", this.keyup);
}
render(): ReactNode {
let tool_component;
switch (application_store.current_tool) {
case "viewer":
tool_component = <Viewer />;
break;
case "quest_editor":
tool_component = <QuestEditor />;
break;
case "hunt_optimizer":
tool_component = <HuntOptimizer />;
break;
case "dps_calc":
tool_component = <DpsCalc />;
break;
}
return (
<div className={styles.main}>
<div className={styles.navbar}>
<Menu
className={styles.heading_menu}
onClick={this.menu_clicked}
selectedKeys={[application_store.current_tool]}
mode="horizontal"
>
<Menu.Item key="viewer">
Viewer<sup className={styles.beta}>(Beta)</sup>
</Menu.Item>
<Menu.Item key="quest_editor">
Quest Editor<sup className={styles.beta}>(Beta)</sup>
</Menu.Item>
<Menu.Item key="hunt_optimizer">Hunt Optimizer</Menu.Item>
{/* <Menu.Item key="dpsCalc">
DPS Calculator
</Menu.Item> */}
</Menu>
<div className={styles.server_select}>
<span>Server:</span>
<Select defaultValue={Server.Ephinea} style={{ width: 120 }}>
<Select.Option value={Server.Ephinea}>{Server.Ephinea}</Select.Option>
</Select>
</div>
</div>
<div className={styles.content}>{tool_component}</div>
</div>
);
}
private menu_clicked = (e: ClickParam) => {
application_store.current_tool = e.key;
};
private keyup = (e: KeyboardEvent) => {
application_store.dispatch_global_keyup(e);
};
}

View File

@ -1,137 +0,0 @@
import { observable, computed } from "mobx";
import { defer } from "lodash";
export enum LoadableState {
/**
* No attempt has been made to load data.
*/
Uninitialized,
/**
* The first data load is underway.
*/
Initializing,
/**
* Data was loaded at least once. The most recent load was successful.
*/
Nominal,
/**
* Data was loaded at least once. The most recent load failed.
*/
Error,
/**
* Data was loaded at least once. Another data load is underway.
*/
Reloading,
}
/**
* Represents a value that can be loaded asynchronously.
* [state]{@link Loadable#state} represents the current state of this Loadable's value.
*/
export class Loadable<T> {
@observable private _value: T;
@observable private _promise: Promise<T> = new Promise(resolve => resolve(this._value));
@observable private _state = LoadableState.Uninitialized;
private _load?: () => Promise<T>;
@observable private _error?: Error;
constructor(initial_value: T, load?: () => Promise<T>) {
this._value = initial_value;
this._load = load;
}
/**
* When this Loadable is uninitialized, a load will be triggered.
* Will return the initial value until a load has succeeded.
*/
@computed get value(): T {
// Load value on first use and return initial placeholder value.
if (this._state === LoadableState.Uninitialized) {
// Defer loading value to avoid side effects in computed value.
defer(() => this.load_value());
}
return this._value;
}
set value(value: T) {
this._value = value;
}
/**
* This property returns valid data as soon as possible.
* If the Loadable is uninitialized a data load will be triggered, otherwise the current value will be returned.
*/
get promise(): Promise<T> {
// Load value on first use.
if (this._state === LoadableState.Uninitialized) {
return this.load_value();
} else {
return this._promise;
}
}
@computed get state(): LoadableState {
return this._state;
}
/**
* @returns true if the initial data load has happened. It may or may not have succeeded.
* Check [error]{@link Loadable#error} to know whether an error occurred.
*/
@computed get is_initialized(): boolean {
return this._state !== LoadableState.Uninitialized;
}
/**
* @returns true if a data load is underway. This may be the initializing load or a later load.
*/
@computed get is_loading(): boolean {
switch (this._state) {
case LoadableState.Initializing:
case LoadableState.Reloading:
return true;
default:
return false;
}
}
/**
* @returns an {@link Error} if an error occurred during the most recent data load.
*/
@computed get error(): Error | undefined {
return this._error;
}
/**
* Load the data. Initializes the Loadable if it is uninitialized.
*/
load(): Promise<T> {
return this.load_value();
}
private async load_value(): Promise<T> {
if (this.is_loading) return this._promise;
this._state = LoadableState.Initializing;
try {
if (this._load) {
this._promise = this._load();
this._value = await this._promise;
}
this._state = LoadableState.Nominal;
this._error = undefined;
return this._value;
} catch (e) {
this._state = LoadableState.Error;
this._error = e;
throw e;
}
}
}

View File

@ -18,7 +18,7 @@ export function is_xj_model(model: NjModel): model is XjModel {
return model.type === "xj";
}
export class NjObject<M extends NjModel> {
export class NjObject<M extends NjModel = NjModel> {
evaluation_flags: NjEvaluationFlags;
model: M | undefined;
position: Vec3;

View File

@ -60,9 +60,10 @@ AREAS[Episode.I] = [
create_area(9, "Ruins 2", order++, 5),
create_area(10, "Ruins 3", order++, 5),
create_area(14, "Dark Falz", order++, 1),
create_area(15, "BA Ruins", order++, 3),
create_area(16, "BA Spaceship", order++, 3),
create_area(17, "Lobby", order++, 15),
// TODO:
// create_area(15, "BA Ruins", order++, 3),
// create_area(16, "BA Spaceship", order++, 3),
// create_area(17, "Lobby", order++, 15),
];
order = 0;
AREAS[Episode.II] = [

View File

@ -1,4 +1,4 @@
import { Episode, check_episode } from "./Episode";
import { check_episode, Episode } from "./Episode";
// Make sure ObjectType does not overlap NpcType.
export enum NpcType {
@ -314,7 +314,7 @@ define_npc_type_data(NpcType.Scientist, "Scientist", "Scientist", "Scientist", u
define_npc_type_data(NpcType.Nurse, "Nurse", "Nurse", "Nurse", undefined, false);
define_npc_type_data(NpcType.Irene, "Irene", "Irene", "Irene", undefined, false);
define_npc_type_data(NpcType.ItemShop, "Item Shop", "Item Shop", "Item Shop", undefined, false);
define_npc_type_data(NpcType.Nurse2, "Nurse (Ep. II);", "Nurse", "Nurse", 2, false);
define_npc_type_data(NpcType.Nurse2, "Nurse (Ep. II)", "Nurse", "Nurse", 2, false);
//
// Enemy NPCs
@ -450,17 +450,17 @@ define_npc_type_data(NpcType.DarkFalz, "Dark Falz", "Dark Falz", "Dark Falz", 1,
define_npc_type_data(
NpcType.Hildebear2,
"Hildebear (Ep. II);",
"Hildebear (Ep. II)",
"Hildebear",
"Hildelt",
2,
true,
NpcType.Hildeblue2,
);
define_npc_type_data(NpcType.Hildeblue2, "Hildeblue (Ep. II);", "Hildeblue", "Hildetorr", 2, true);
define_npc_type_data(NpcType.Hildeblue2, "Hildeblue (Ep. II)", "Hildeblue", "Hildetorr", 2, true);
define_npc_type_data(
NpcType.RagRappy2,
"Rag Rappy (Ep. II);",
"Rag Rappy (Ep. II)",
"Rag Rappy",
"El Rappy",
2,
@ -471,39 +471,32 @@ define_npc_type_data(NpcType.LoveRappy, "Love Rappy", "Love Rappy", "Love Rappy"
define_npc_type_data(NpcType.StRappy, "St. Rappy", "St. Rappy", "St. Rappy", 2, true);
define_npc_type_data(NpcType.HalloRappy, "Hallo Rappy", "Hallo Rappy", "Hallo Rappy", 2, true);
define_npc_type_data(NpcType.EggRappy, "Egg Rappy", "Egg Rappy", "Egg Rappy", 2, true);
define_npc_type_data(NpcType.Monest2, "Monest (Ep. II);", "Monest", "Mothvist", 2, true);
define_npc_type_data(NpcType.Monest2, "Monest (Ep. II)", "Monest", "Mothvist", 2, true);
define_npc_type_data(NpcType.Mothmant2, "Mothmant", "Mothmant", "Mothvert", 2, true);
define_npc_type_data(
NpcType.PoisonLily2,
"Poison Lily (Ep. II);",
"Poison Lily (Ep. II)",
"Poison Lily",
"Ob Lily",
2,
true,
NpcType.NarLily2,
);
define_npc_type_data(NpcType.NarLily2, "Nar Lily (Ep. II);", "Nar Lily", "Mil Lily", 2, true);
define_npc_type_data(NpcType.NarLily2, "Nar Lily (Ep. II)", "Nar Lily", "Mil Lily", 2, true);
define_npc_type_data(
NpcType.GrassAssassin2,
"Grass Assassin (Ep. II);",
"Grass Assassin (Ep. II)",
"Grass Assassin",
"Crimson Assassin",
2,
true,
);
define_npc_type_data(NpcType.Dimenian2, "Dimenian (Ep. II);", "Dimenian", "Arlan", 2, true);
define_npc_type_data(
NpcType.LaDimenian2,
"La Dimenian (Ep. II);",
"La Dimenian",
"Merlan",
2,
true,
);
define_npc_type_data(NpcType.SoDimenian2, "So Dimenian (Ep. II);", "So Dimenian", "Del-D", 2, true);
define_npc_type_data(NpcType.Dimenian2, "Dimenian (Ep. II)", "Dimenian", "Arlan", 2, true);
define_npc_type_data(NpcType.LaDimenian2, "La Dimenian (Ep. II)", "La Dimenian", "Merlan", 2, true);
define_npc_type_data(NpcType.SoDimenian2, "So Dimenian (Ep. II)", "So Dimenian", "Del-D", 2, true);
define_npc_type_data(
NpcType.DarkBelra2,
"Dark Belra (Ep. II);",
"Dark Belra (Ep. II)",
"Dark Belra",
"Indi Belra",
2,
@ -513,33 +506,26 @@ define_npc_type_data(NpcType.BarbaRay, "Barba Ray", "Barba Ray", "Barba Ray", 2,
// Episode II VR Spaceship
define_npc_type_data(
NpcType.SavageWolf2,
"Savage Wolf (Ep. II);",
"Savage Wolf",
"Gulgus",
2,
true,
);
define_npc_type_data(NpcType.SavageWolf2, "Savage Wolf (Ep. II)", "Savage Wolf", "Gulgus", 2, true);
define_npc_type_data(
NpcType.BarbarousWolf2,
"Barbarous Wolf (Ep. II);",
"Barbarous Wolf (Ep. II)",
"Barbarous Wolf",
"Gulgus-Gue",
2,
true,
);
define_npc_type_data(NpcType.PanArms2, "Pan Arms (Ep. II);", "Pan Arms", "Pan Arms", 2, true);
define_npc_type_data(NpcType.Migium2, "Migium (Ep. II);", "Migium", "Migium", 2, true);
define_npc_type_data(NpcType.Hidoom2, "Hidoom (Ep. II);", "Hidoom", "Hidoom", 2, true);
define_npc_type_data(NpcType.Dubchic2, "Dubchic (Ep. II);", "Dubchic", "Dubchich", 2, true);
define_npc_type_data(NpcType.Gilchic2, "Gilchic (Ep. II);", "Gilchic", "Gilchich", 2, true);
define_npc_type_data(NpcType.Garanz2, "Garanz (Ep. II);", "Garanz", "Baranz", 2, true);
define_npc_type_data(NpcType.Dubswitch2, "Dubswitch (Ep. II);", "Dubswitch", "Dubswitch", 2, true);
define_npc_type_data(NpcType.Delsaber2, "Delsaber (Ep. II);", "Delsaber", "Delsaber", 2, true);
define_npc_type_data(NpcType.PanArms2, "Pan Arms (Ep. II)", "Pan Arms", "Pan Arms", 2, true);
define_npc_type_data(NpcType.Migium2, "Migium (Ep. II)", "Migium", "Migium", 2, true);
define_npc_type_data(NpcType.Hidoom2, "Hidoom (Ep. II)", "Hidoom", "Hidoom", 2, true);
define_npc_type_data(NpcType.Dubchic2, "Dubchic (Ep. II)", "Dubchic", "Dubchich", 2, true);
define_npc_type_data(NpcType.Gilchic2, "Gilchic (Ep. II)", "Gilchic", "Gilchich", 2, true);
define_npc_type_data(NpcType.Garanz2, "Garanz (Ep. II)", "Garanz", "Baranz", 2, true);
define_npc_type_data(NpcType.Dubswitch2, "Dubswitch (Ep. II)", "Dubswitch", "Dubswitch", 2, true);
define_npc_type_data(NpcType.Delsaber2, "Delsaber (Ep. II)", "Delsaber", "Delsaber", 2, true);
define_npc_type_data(
NpcType.ChaosSorcerer2,
"Chaos Sorcerer (Ep. II);",
"Chaos Sorcerer (Ep. II)",
"Chaos Sorcerer",
"Gran Sorcerer",
2,

View File

@ -57,29 +57,3 @@ export type ToolItemTypeDto = {
id: number;
name: string;
};
export type EnemyDropDto = {
difficulty: string;
episode: number;
sectionId: string;
enemy: string;
itemTypeId: number;
dropRate: number;
rareRate: number;
};
export type BoxDropDto = {
difficulty: string;
episode: number;
sectionId: string;
areaId: number;
itemTypeId: number;
dropRate: number;
};
export type QuestDto = {
id: number;
name: string;
episode: 1 | 2 | 4;
enemyCounts: { [npcTypeCode: string]: number };
};

View File

@ -13,8 +13,8 @@ export function enum_values<E>(e: any): E[] {
* Map with a guaranteed value per enum key.
*/
export class EnumMap<K, V> {
private keys: K[];
private values = new Map<K, V>();
private readonly keys: K[];
private readonly values = new Map<K, V>();
constructor(enum_: any, initial_value: (key: K) => V) {
this.keys = enum_values(enum_);

66
src/core/gui/Button.css Normal file
View File

@ -0,0 +1,66 @@
.core_Button {
display: inline-flex;
flex-direction: row;
align-items: stretch;
align-content: stretch;
box-sizing: border-box;
height: 26px;
padding: 0;
border: var(--control-border);
color: var(--control-text-color);
outline: none;
font-size: 13px;
font-family: var(--font-family);
overflow: hidden;
}
.core_Button .core_Button_inner {
flex: 1;
display: inline-flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;
background-color: var(--control-bg-color);
height: 24px;
padding: 3px 5px;
border: var(--control-inner-border);
overflow: hidden;
}
.core_Button:hover .core_Button_inner {
background-color: var(--control-bg-color-hover);
border-color: hsl(0, 0%, 40%);
color: var(--control-text-color-hover);
}
.core_Button:active .core_Button_inner {
background-color: hsl(0, 0%, 20%);
border-color: hsl(0, 0%, 30%);
color: hsl(0, 0%, 75%);
}
.core_Button:disabled .core_Button_inner {
background-color: hsl(0, 0%, 15%);
border-color: hsl(0, 0%, 25%);
color: hsl(0, 0%, 55%);
}
.core_Button_inner > * {
display: inline-block;
margin: 0 3px;
}
.core_Button_center {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.core_Button_left,
.core_Button_right {
display: inline-flex;
align-content: center;
font-size: 11px;
}

79
src/core/gui/Button.ts Normal file
View File

@ -0,0 +1,79 @@
import { el, Icon, icon } from "./dom";
import "./Button.css";
import { Observable } from "../observable/Observable";
import { emitter } from "../observable";
import { Control } from "./Control";
import { Emitter } from "../observable/Emitter";
import { WidgetOptions } from "./Widget";
import { Property } from "../observable/property/Property";
import { WritableProperty } from "../observable/property/WritableProperty";
import { WidgetProperty } from "../observable/property/WidgetProperty";
export type ButtonOptions = WidgetOptions & {
icon_left?: Icon;
icon_right?: Icon;
};
export class Button extends Control<HTMLButtonElement> {
readonly mousedown: Observable<MouseEvent>;
readonly mouseup: Observable<MouseEvent>;
readonly click: Observable<MouseEvent>;
readonly text: WritableProperty<string>;
private readonly _mousedown: Emitter<MouseEvent>;
private readonly _mouseup: Emitter<MouseEvent>;
private readonly _click: Emitter<MouseEvent>;
private readonly _text: WidgetProperty<string>;
private readonly center_element: HTMLSpanElement;
constructor(text: string | Property<string>, options?: ButtonOptions) {
const inner_element = el.span({ class: "core_Button_inner" });
super(el.button({ class: "core_Button" }, inner_element), options);
this.center_element = el.span({ class: "core_Button_center" });
if (options && options.icon_left != undefined) {
inner_element.append(el.span({ class: "core_Button_left" }, icon(options.icon_left)));
}
inner_element.append(this.center_element);
if (options && options.icon_right != undefined) {
inner_element.append(el.span({ class: "core_Button_right" }, icon(options.icon_right)));
}
this._mousedown = emitter<MouseEvent>();
this.mousedown = this._mousedown;
this.element.onmousedown = (e: MouseEvent) => this._mousedown.emit({ value: e });
this._mouseup = emitter<MouseEvent>();
this.mouseup = this._mouseup;
this.element.onmouseup = (e: MouseEvent) => this._mouseup.emit({ value: e });
this._click = emitter<MouseEvent>();
this.click = this._click;
this.element.onclick = (e: MouseEvent) => this._click.emit({ value: e });
this._text = new WidgetProperty<string>(this, "", this.set_text);
this.text = this._text;
if (typeof text === "string") {
this.text.val = text;
} else if (text) {
this.text.bind_to(text);
}
this.finalize_construction(Button.prototype);
}
protected set_enabled(enabled: boolean): void {
super.set_enabled(enabled);
this.element.disabled = !enabled;
}
protected set_text(text: string): void {
this.center_element.textContent = text;
this.center_element.hidden = text === "";
}
}

37
src/core/gui/CheckBox.ts Normal file
View File

@ -0,0 +1,37 @@
import { create_element } from "./dom";
import { WritableProperty } from "../observable/property/WritableProperty";
import { LabelledControl, LabelledControlOptions } from "./LabelledControl";
import { WidgetProperty } from "../observable/property/WidgetProperty";
export type CheckBoxOptions = LabelledControlOptions;
export class CheckBox extends LabelledControl<HTMLInputElement> {
readonly preferred_label_position = "right";
readonly checked: WritableProperty<boolean>;
private readonly _checked: WidgetProperty<boolean>;
constructor(checked: boolean = false, options?: CheckBoxOptions) {
super(create_element("input", { class: "core_CheckBox" }), options);
this._checked = new WidgetProperty(this, checked, this.set_checked);
this.checked = this._checked;
this.set_checked(checked);
this.element.type = "checkbox";
this.element.onchange = () =>
this._checked.set_val(this.element.checked, { silent: false });
this.finalize_construction(CheckBox.prototype);
}
protected set_enabled(enabled: boolean): void {
super.set_enabled(enabled);
this.element.disabled = !enabled;
}
protected set_checked(checked: boolean): void {
this.element.checked = checked;
}
}

30
src/core/gui/ComboBox.css Normal file
View File

@ -0,0 +1,30 @@
.core_ComboBox {
box-sizing: border-box;
position: relative;
}
.core_ComboBox_inner {
box-sizing: border-box;
display: flex;
align-items: center;
}
.core_ComboBox_inner input {
flex: 1;
padding: 0;
border: none;
margin: 0;
color: var(--input-text-color);
background-color: transparent;
outline: none;
}
.core_ComboBox.disabled input {
color: var(--input-text-color-disabled);
}
.core_ComboBox .core_Menu {
top: 23px;
left: -2px;
min-width: calc(100% + 4px);
}

134
src/core/gui/ComboBox.ts Normal file
View File

@ -0,0 +1,134 @@
import { LabelledControl, LabelledControlOptions } from "./LabelledControl";
import { create_element, el, Icon, icon } from "./dom";
import "./ComboBox.css";
import "./Input.css";
import { Menu } from "./Menu";
import { Property } from "../observable/property/Property";
import { property } from "../observable";
import { WritableProperty } from "../observable/property/WritableProperty";
import { WidgetProperty } from "../observable/property/WidgetProperty";
export type ComboBoxOptions<T> = LabelledControlOptions & {
items: T[] | Property<T[]>;
to_label(item: T): string;
placeholder_text?: string;
filter?(text: string): void;
};
export class ComboBox<T> extends LabelledControl {
readonly preferred_label_position = "left";
readonly selected: WritableProperty<T | undefined>;
private readonly to_label: (element: T) => string;
private readonly menu: Menu<T>;
private readonly input_element: HTMLInputElement = create_element("input");
private readonly _selected: WidgetProperty<T | undefined>;
constructor(options: ComboBoxOptions<T>) {
super(el.span({ class: "core_ComboBox core_Input" }), options);
this.to_label = options.to_label;
this._selected = new WidgetProperty<T | undefined>(this, undefined, this.set_selected);
this.selected = this._selected;
const menu_visible = property(false);
this.menu = this.disposable(new Menu(options.items, options.to_label, this.element));
this.menu.element.onmousedown = e => e.preventDefault();
this.input_element.placeholder = options.placeholder_text || "";
this.input_element.onmousedown = () => {
menu_visible.val = true;
};
this.input_element.onkeydown = (e: Event) => {
const key = (e as KeyboardEvent).key;
switch (key) {
case "ArrowDown":
e.preventDefault();
this.menu.hover_next();
break;
case "ArrowUp":
e.preventDefault();
this.menu.hover_prev();
break;
case "Enter":
this.menu.select_hovered();
break;
}
};
const filter = options.filter;
if (filter) {
let input_value = "";
this.input_element.onkeyup = () => {
if (this.input_element.value !== input_value) {
input_value = this.input_element.value;
filter(input_value);
if (this.menu.visible.val || input_value) {
this.menu.hover_next();
}
}
};
}
this.input_element.onblur = () => {
menu_visible.val = false;
};
const down_arrow_element = el.span({}, icon(Icon.TriangleDown));
this.bind_hidden(down_arrow_element, menu_visible);
const up_arrow_element = el.span({}, icon(Icon.TriangleUp));
this.bind_hidden(up_arrow_element, menu_visible.map(v => !v));
const button_element = el.span(
{ class: "core_ComboBox_button" },
down_arrow_element,
up_arrow_element,
);
button_element.onmousedown = e => {
e.preventDefault();
menu_visible.val = !menu_visible.val;
};
this.element.append(
el.span(
{ class: "core_ComboBox_inner core_Input_inner" },
this.input_element,
button_element,
),
this.menu.element,
);
this.disposables(
this.menu.visible.bind_bi(menu_visible),
menu_visible.observe(({ value: visible }) => {
if (visible) {
this.menu.hover_next();
}
}),
this.menu.selected.observe(({ value }) => {
this.selected.set_val(value, { silent: false });
this.input_element.focus();
}),
);
this.finalize_construction(ComboBox.prototype);
}
protected set_selected(selected?: T): void {
this.input_element.value = selected ? this.to_label(selected) : "";
this.menu.selected.val = selected;
}
}

5
src/core/gui/Control.ts Normal file
View File

@ -0,0 +1,5 @@
import { Widget, WidgetOptions } from "./Widget";
export type ControlOptions = WidgetOptions;
export abstract class Control<E extends HTMLElement = HTMLElement> extends Widget<E> {}

View File

@ -0,0 +1,9 @@
.core_DropDown {
position: relative;
}
.core_DropDown .core_Menu {
top: 25px;
left: 0;
min-width: 100%;
}

82
src/core/gui/DropDown.ts Normal file
View File

@ -0,0 +1,82 @@
import { disposable_listener, el, Icon } from "./dom";
import "./DropDown.css";
import { Property } from "../observable/property/Property";
import { Button, ButtonOptions } from "./Button";
import { Menu } from "./Menu";
import { Control } from "./Control";
import { Observable } from "../observable/Observable";
import { Emitter } from "../observable/Emitter";
import { emitter } from "../observable";
export type DropDownOptions = ButtonOptions;
export class DropDown<T> extends Control {
readonly chosen: Observable<T>;
private readonly button: Button;
private readonly menu: Menu<T>;
private readonly _chosen: Emitter<T>;
private just_opened: boolean;
constructor(
text: string,
items: T[] | Property<T[]>,
to_label: (element: T) => string,
options?: DropDownOptions,
) {
const element = el.div({ class: "core_DropDown" });
const button = new Button(text, {
icon_left: options && options.icon_left,
icon_right: Icon.TriangleDown,
});
const menu = new Menu<T>(items, to_label, element);
super(element, options);
this.button = this.disposable(button);
this.menu = this.disposable(menu);
this.element.append(this.button.element, this.menu.element);
this._chosen = emitter();
this.chosen = this._chosen;
this.just_opened = false;
this.disposables(
disposable_listener(button.element, "mousedown", () => this.button_mousedown(), {
capture: true,
}),
button.mouseup.observe(() => this.button_mouseup()),
this.menu.selected.observe(({ value }) => {
if (value) {
this._chosen.emit({ value });
this.menu.selected.val = undefined;
}
}),
);
this.finalize_construction(DropDown.prototype);
}
protected set_enabled(enabled: boolean): void {
super.set_enabled(enabled);
this.button.enabled.val = enabled;
}
private button_mousedown(): void {
this.just_opened = !this.menu.visible.val;
this.menu.visible.val = true;
}
private button_mouseup(): void {
if (this.just_opened) {
this.menu.focus();
} else {
this.menu.visible.val = false;
}
this.just_opened = false;
}
}

View File

@ -0,0 +1,3 @@
.core_DurationInput input {
text-align: center;
}

View File

@ -0,0 +1,45 @@
import { Input, InputOptions } from "./Input";
import { Duration } from "luxon";
import "./DurationInput.css";
export type DurationInputOptions = InputOptions;
export class DurationInput extends Input<Duration> {
readonly preferred_label_position = "left";
constructor(value = Duration.fromMillis(0), options?: DurationInputOptions) {
super(value, "core_DurationInput", "text", "core_DurationInput_inner", options);
this.input_element.pattern = "(60|[0-5][0-9]):(60|[0-5][0-9])";
this.set_value(value);
this.finalize_construction(DurationInput.prototype);
}
protected get_value(): Duration {
const str = this.input_element.value;
if (this.input_element.validity.valid) {
return Duration.fromObject({
hours: parseInt(str.slice(0, 2), 10),
minutes: parseInt(str.slice(3), 10),
});
} else {
const colon_pos = str.indexOf(":");
if (colon_pos === -1) {
return Duration.fromObject({ minutes: parseInt(str, 10) });
} else {
return Duration.fromObject({
hours: parseInt(str.slice(0, colon_pos), 10),
minutes: parseInt(str.slice(colon_pos + 1), 10),
});
}
}
}
protected set_value(value: Duration): void {
this.input_element.value = value.toFormat("hh:mm");
}
}

View File

@ -0,0 +1,9 @@
.core_FileButton_input {
overflow: hidden;
clip: rect(0, 0, 0, 0);
position: absolute;
width: 1px;
height: 1px;
border: none;
padding: 0;
}

View File

@ -0,0 +1,79 @@
import { create_element, el, icon, Icon } from "./dom";
import "./FileButton.css";
import "./Button.css";
import { property } from "../observable";
import { Property } from "../observable/property/Property";
import { Control, ControlOptions } from "./Control";
import { WritableProperty } from "../observable/property/WritableProperty";
export type FileButtonOptions = ControlOptions & {
accept?: string;
icon_left?: Icon;
};
export class FileButton extends Control<HTMLElement> {
readonly files: Property<File[]>;
private input: HTMLInputElement = create_element("input", {
class: "core_FileButton_input core_Button_inner",
});
private readonly _files: WritableProperty<File[]> = property<File[]>([]);
constructor(text: string, options?: FileButtonOptions) {
super(
create_element("label", {
class: "core_FileButton core_Button",
}),
options,
);
this.files = this._files;
this.input.type = "file";
this.input.onchange = () => {
if (this.input.files && this.input.files.length) {
this._files.val = [...this.input.files!];
} else {
this._files.val = [];
}
};
if (options && options.accept) this.input.accept = options.accept;
const inner_element = el.span({
class: "core_FileButton_inner core_Button_inner",
});
if (options && options.icon_left != undefined) {
inner_element.append(
el.span(
{ class: "core_FileButton_left core_Button_left" },
icon(options.icon_left),
),
);
}
inner_element.append(el.span({ class: "core_Button_center", text }));
this.element.append(inner_element, this.input);
this.disposables(
this.enabled.observe(({ value }) => {
this.input.disabled = !value;
if (value) {
this.element.classList.remove("disabled");
} else {
this.element.classList.add("disabled");
}
}),
);
this.finalize_construction(FileButton.prototype);
}
click(): void {
this.input.click();
}
}

35
src/core/gui/Input.css Normal file
View File

@ -0,0 +1,35 @@
.core_Input {
display: inline-block;
box-sizing: border-box;
height: 24px;
border: var(--input-border);
}
.core_Input .core_Input_inner {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0 3px;
border: var(--input-inner-border);
background-color: var(--input-bg-color);
color: var(--input-text-color);
outline: none;
font-size: 13px;
}
.core_Input:hover {
border: var(--input-border-hover);
}
.core_Input:focus-within {
border: var(--input-border-focus);
}
.core_Input.disabled {
border: var(--input-border-disabled);
}
.core_Input.disabled .core_Input_inner {
color: var(--input-text-color-disabled);
background-color: var(--input-bg-color-disabled);
}

77
src/core/gui/Input.ts Normal file
View File

@ -0,0 +1,77 @@
/* eslint-disable no-dupe-class-members */
import { LabelledControl, LabelledControlOptions } from "./LabelledControl";
import { create_element, el } from "./dom";
import { WritableProperty } from "../observable/property/WritableProperty";
import { is_any_property, Property } from "../observable/property/Property";
import "./Input.css";
import { WidgetProperty } from "../observable/property/WidgetProperty";
export type InputOptions = LabelledControlOptions;
export abstract class Input<T> extends LabelledControl<HTMLElement> {
readonly value: WritableProperty<T>;
protected readonly input_element: HTMLInputElement;
private readonly _value: WidgetProperty<T>;
protected constructor(
value: T,
class_name: string,
input_type: string,
input_class_name: string,
options?: InputOptions,
) {
super(el.span({ class: `${class_name} core_Input` }), options);
this._value = new WidgetProperty<T>(this, value, this.set_value);
this.value = this._value;
this.input_element = create_element("input", {
class: `${input_class_name} core_Input_inner`,
});
this.input_element.type = input_type;
this.input_element.onchange = () => {
this._value.set_val(this.get_value(), { silent: false });
};
this.element.append(this.input_element);
}
protected set_enabled(enabled: boolean): void {
super.set_enabled(enabled);
this.input_element.disabled = !enabled;
}
protected abstract get_value(): T;
protected abstract set_value(value: T): void;
protected set_attr<T>(attr: InputAttrsOfType<T>, value?: T | Property<T>): void;
protected set_attr<T, U>(
attr: InputAttrsOfType<U>,
value: T | Property<T> | undefined,
convert: (value: T) => U,
): void;
protected set_attr<T, U>(
attr: InputAttrsOfType<U>,
value?: T | Property<T>,
convert?: (value: T) => U,
): void {
if (value == undefined) return;
const input = this.input_element as any;
const cvt = convert ? convert : (v: T) => (v as any) as U;
if (is_any_property(value)) {
input[attr] = cvt(value.val);
this.disposable(value.observe(({ value }) => (input[attr] = cvt(value))));
} else {
input[attr] = cvt(value);
}
}
}
type InputAttrsOfType<T> = {
[K in keyof HTMLInputElement]: T extends HTMLInputElement[K] ? K : never;
}[keyof HTMLInputElement];

View File

@ -1,4 +1,3 @@
.main {
.core_Label.disabled {
color: var(--text-color-disabled);
padding: 5px 0;
}

34
src/core/gui/Label.ts Normal file
View File

@ -0,0 +1,34 @@
import { WidgetOptions, Widget } from "./Widget";
import { create_element } from "./dom";
import { WritableProperty } from "../observable/property/WritableProperty";
import "./Label.css";
import { Property } from "../observable/property/Property";
import { WidgetProperty } from "../observable/property/WidgetProperty";
export class Label extends Widget<HTMLLabelElement> {
set for(id: string) {
this.element.htmlFor = id;
}
readonly text: WritableProperty<string>;
private readonly _text = new WidgetProperty<string>(this, "", this.set_text);
constructor(text: string | Property<string>, options?: WidgetOptions) {
super(create_element("label", { class: "core_Label" }), options);
this.text = this._text;
if (typeof text === "string") {
this.set_text(text);
} else {
this.disposable(this._text.bind_to(text));
}
this.finalize_construction(Label.prototype);
}
protected set_text(text: string): void {
this.element.textContent = text;
}
}

View File

@ -0,0 +1,42 @@
import { Label } from "./Label";
import { Control } from "./Control";
import { WidgetOptions } from "./Widget";
export type LabelledControlOptions = WidgetOptions & {
label?: string;
};
export type LabelPosition = "left" | "right" | "top" | "bottom";
export abstract class LabelledControl<E extends HTMLElement = HTMLElement> extends Control<E> {
abstract readonly preferred_label_position: LabelPosition;
get label(): Label | undefined {
if (!this._label && this._label_text != undefined) {
this._label = this.disposable(new Label(this._label_text));
if (!this.id) {
this._label.for = this.id = unique_id();
}
this._label.enabled.bind_bi(this.enabled);
}
return this._label;
}
private readonly _label_text?: string;
private _label?: Label;
protected constructor(element: E, options?: LabelledControlOptions) {
super(element, options);
this._label_text = options && options.label;
}
}
let id = 0;
function unique_id(): string {
return String(id++);
}

View File

@ -0,0 +1,43 @@
import { Widget } from "./Widget";
import { el } from "./dom";
import { Resizable } from "./Resizable";
import { ResizableWidget } from "./ResizableWidget";
export class LazyWidget extends ResizableWidget {
private initialized = false;
private view: Widget & Resizable | undefined;
constructor(private create_view: () => Promise<Widget & Resizable>) {
super(el.div({ class: "core_LazyView" }));
this.visible.val = false;
}
protected set_visible(visible: boolean): void {
super.set_visible(visible);
if (visible && !this.initialized) {
this.initialized = true;
this.create_view().then(view => {
if (!this.disposed) {
this.view = this.disposable(view);
this.view.resize(this.width, this.height);
this.element.append(view.element);
}
});
}
this.finalize_construction(LazyWidget.prototype);
}
resize(width: number, height: number): this {
super.resize(width, height);
if (this.view) {
this.view.resize(width, height);
}
return this;
}
}

26
src/core/gui/Menu.css Normal file
View File

@ -0,0 +1,26 @@
.core_Menu {
z-index: 1000;
position: absolute;
box-sizing: border-box;
outline: none;
border: var(--control-border);
--scrollbar-color: hsl(0, 0%, 18%);
--scrollbar-thumb-color: hsl(0, 0%, 22%);
}
.core_Menu > .core_Menu_inner {
overflow: auto;
background-color: var(--control-bg-color);
max-height: 500px;
border: var(--control-inner-border);
}
.core_Menu > .core_Menu_inner > * {
padding: 4px 8px;
white-space: nowrap;
}
.core_Menu > .core_Menu_inner > .core_Menu_hovered {
background-color: var(--control-bg-color-hover);
color: var(--control-text-color-hover);
}

176
src/core/gui/Menu.ts Normal file
View File

@ -0,0 +1,176 @@
import { disposable_listener, el } from "./dom";
import { Widget } from "./Widget";
import { Property } from "../observable/property/Property";
import { property } from "../observable";
import { WritableProperty } from "../observable/property/WritableProperty";
import { WidgetProperty } from "../observable/property/WidgetProperty";
import "./Menu.css";
export class Menu<T> extends Widget {
readonly selected: WritableProperty<T | undefined>;
private readonly to_label: (element: T) => string;
private readonly items: Property<T[]>;
private readonly inner_element = el.div({ class: "core_Menu_inner" });
private readonly related_element: HTMLElement;
private readonly _selected: WidgetProperty<T | undefined>;
private hovered_index?: number;
private hovered_element?: HTMLElement;
constructor(
items: T[] | Property<T[]>,
to_label: (element: T) => string,
related_element: HTMLElement,
) {
super(el.div({ class: "core_Menu", tab_index: -1 }));
this.visible.val = false;
this.element.onmouseup = this.mouseup;
this.element.onkeydown = this.keydown;
this.inner_element.onmouseover = this.inner_mouseover;
this.element.append(this.inner_element);
this.to_label = to_label;
this.items = Array.isArray(items) ? property(items) : items;
this.related_element = related_element;
this._selected = new WidgetProperty<T | undefined>(this, undefined, this.set_selected);
this.selected = this._selected;
this.disposables(
this.items.observe(
({ value: items }) => {
this.inner_element.innerHTML = "";
this.inner_element.append(
...items.map((item, index) =>
el.div({ text: to_label(item), data: { index: index.toString() } }),
),
);
this.hover_item();
},
{ call_now: true },
),
disposable_listener(document, "mousedown", this.document_mousedown, {
capture: true,
}),
disposable_listener(document, "keydown", this.document_keydown),
);
this.finalize_construction(Menu.prototype);
}
hover_next(): void {
this.visible.val = true;
this.hover_item(
this.hovered_index != undefined ? (this.hovered_index + 1) % this.items.val.length : 0,
);
}
hover_prev(): void {
this.visible.val = true;
this.hover_item(this.hovered_index ? this.hovered_index - 1 : this.items.val.length - 1);
}
select_hovered(): void {
if (this.hovered_index != undefined) {
this.select_item(this.hovered_index);
}
}
protected set_visible(visible: boolean): void {
super.set_visible(visible);
if (this.visible.val != visible) {
this.hover_item();
this.inner_element.scrollTop = 0;
}
}
protected set_selected(): void {
// Noop
}
private mouseup = (e: Event): void => {
if (!(e.target instanceof HTMLElement)) return;
const index_str = e.target.dataset.index;
if (index_str == undefined) return;
this.select_item(parseInt(index_str, 10));
};
private keydown = (e: Event): void => {
const key = (e as KeyboardEvent).key;
switch (key) {
case "ArrowDown":
this.hover_next();
break;
case "ArrowUp":
this.hover_prev();
break;
case "Enter":
this.select_hovered();
break;
}
};
private inner_mouseover = (e: Event): void => {
if (e.target && e.target instanceof HTMLElement) {
const index = e.target.dataset.index;
if (index != undefined) {
this.hover_item(parseInt(index, 10));
}
}
};
private document_mousedown = (e: Event): void => {
if (
this.visible.val &&
!this.element.contains(e.target as Node) &&
!this.related_element.contains(e.target as Node)
) {
this.visible.set_val(false, { silent: false });
}
};
private document_keydown = (e: Event): void => {
if ((e as KeyboardEvent).key === "Escape") {
this.visible.set_val(false, { silent: false });
}
};
private hover_item(index?: number): void {
if (this.hovered_element) {
this.hovered_element.classList.remove("core_Menu_hovered");
}
if (index == undefined) {
this.hovered_index = undefined;
this.hovered_element = undefined;
} else {
this.hovered_element = this.inner_element.children.item(index) as HTMLElement;
if (this.hovered_element) {
this.hovered_index = index;
this.hovered_element.classList.add("core_Menu_hovered");
this.hovered_element.scrollIntoView({ block: "nearest" });
}
}
}
private select_item(index: number): void {
const item = this.items.val[index];
if (!item) return;
this.selected.set_val(item, { silent: false });
this.visible.set_val(false, { silent: false });
}
}

View File

@ -0,0 +1,3 @@
.core_NumberInput .core_NumberInput_inner {
padding-right: 1px;
}

View File

@ -0,0 +1,50 @@
import { Property } from "../observable/property/Property";
import { Input, InputOptions } from "./Input";
import "./NumberInput.css";
export class NumberInput extends Input<number> {
readonly preferred_label_position = "left";
private readonly rounding_factor: number;
constructor(
value: number = 0,
options: InputOptions & {
label?: string;
min?: number | Property<number>;
max?: number | Property<number>;
step?: number | Property<number>;
width?: number;
round_to?: number;
} = {},
) {
super(value, "core_NumberInput", "number", "core_NumberInput_inner", options);
const { min, max, step } = options;
this.set_attr("min", min, String);
this.set_attr("max", max, String);
this.input_element.step = "any";
this.set_attr("step", step, String);
if (options.round_to != undefined && options.round_to >= 0) {
this.rounding_factor = Math.pow(10, options.round_to);
} else {
this.rounding_factor = 1;
}
this.element.style.width = `${options.width == undefined ? 54 : options.width}px`;
this.set_value(value);
this.finalize_construction(NumberInput.prototype);
}
protected get_value(): number {
return this.input_element.valueAsNumber;
}
protected set_value(value: number): void {
this.input_element.valueAsNumber =
Math.round(this.rounding_factor * value) / this.rounding_factor;
}
}

View File

@ -0,0 +1,31 @@
import { ResizableWidget } from "./ResizableWidget";
import { create_element } from "./dom";
import { Renderer } from "../rendering/Renderer";
export class RendererWidget extends ResizableWidget {
constructor(private renderer: Renderer) {
super(create_element("div"));
this.element.append(renderer.dom_element);
this.disposable(renderer);
this.finalize_construction(RendererWidget.prototype);
}
start_rendering(): void {
this.renderer.start_rendering();
}
stop_rendering(): void {
this.renderer.stop_rendering();
}
resize(width: number, height: number): this {
super.resize(width, height);
this.renderer.set_size(width, height);
return this;
}
}

View File

@ -0,0 +1,3 @@
export interface Resizable {
resize(width: number, height: number): this;
}

View File

@ -0,0 +1,16 @@
import { Widget } from "./Widget";
import { Resizable } from "./Resizable";
export abstract class ResizableWidget<E extends HTMLElement = HTMLElement> extends Widget<E>
implements Resizable {
protected width: number = 0;
protected height: number = 0;
resize(width: number, height: number): this {
this.width = width;
this.height = height;
this.element.style.width = `${width}px`;
this.element.style.height = `${height}px`;
return this;
}
}

15
src/core/gui/Select.css Normal file
View File

@ -0,0 +1,15 @@
.core_Select {
position: relative;
display: inline-flex;
width: 160px;
}
.core_Select .core_Button {
flex: 1;
}
.core_Select .core_Menu {
top: 25px;
left: 0;
min-width: 100%;
}

96
src/core/gui/Select.ts Normal file
View File

@ -0,0 +1,96 @@
import { LabelledControl, LabelledControlOptions, LabelPosition } from "./LabelledControl";
import { disposable_listener, el, Icon } from "./dom";
import "./Select.css";
import { is_any_property, Property } from "../observable/property/Property";
import { Button } from "./Button";
import { WritableProperty } from "../observable/property/WritableProperty";
import { WidgetProperty } from "../observable/property/WidgetProperty";
import { Menu } from "./Menu";
export type SelectOptions<T> = LabelledControlOptions & {
selected?: T | Property<T>;
};
export class Select<T> extends LabelledControl {
readonly preferred_label_position: LabelPosition;
readonly selected: WritableProperty<T | undefined>;
private readonly to_label: (element: T) => string;
private readonly button: Button;
private readonly menu: Menu<T>;
private readonly _selected: WidgetProperty<T | undefined>;
private just_opened: boolean;
constructor(
items: T[] | Property<T[]>,
to_label: (element: T) => string,
options?: SelectOptions<T>,
) {
const element = el.div({ class: "core_Select" });
const button = new Button(" ", {
icon_right: Icon.TriangleDown,
});
const menu = new Menu<T>(items, to_label, element);
super(element, options);
this.preferred_label_position = "left";
this.to_label = to_label;
this.button = this.disposable(button);
this.menu = this.disposable(menu);
this.element.append(this.button.element, this.menu.element);
this._selected = new WidgetProperty<T | undefined>(this, undefined, this.set_selected);
this.selected = this._selected;
this.just_opened = false;
this.disposables(
disposable_listener(button.element, "mousedown", e => this.button_mousedown(e)),
button.mouseup.observe(() => this.button_mouseup()),
this.menu.selected.observe(({ value }) =>
this._selected.set_val(value, { silent: false }),
),
);
if (options) {
if (is_any_property(options.selected)) {
this.selected.bind_to(options.selected);
} else if (options.selected) {
this.selected.val = options.selected;
}
}
this.finalize_construction(Select.prototype);
}
protected set_enabled(enabled: boolean): void {
super.set_enabled(enabled);
this.button.enabled.val = enabled;
}
protected set_selected(selected?: T): void {
this.button.text.val = selected ? this.to_label(selected) : " ";
this.menu.selected.val = selected;
}
private button_mousedown(e: Event): void {
e.stopPropagation();
this.just_opened = !this.menu.visible.val;
this.menu.visible.val = true;
}
private button_mouseup(): void {
if (this.just_opened) {
this.menu.focus();
} else {
this.menu.visible.val = false;
}
this.just_opened = false;
}
}

View File

@ -0,0 +1,29 @@
.core_TabContainer_Bar {
box-sizing: border-box;
padding: 3px 3px 0 3px;
border-bottom: var(--border);
}
.core_TabContainer_Tab {
box-sizing: border-box;
display: inline-flex;
align-items: center;
height: calc(100% + 1px);
padding: 0 10px;
border: var(--border);
margin: 0 1px -1px 1px;
background-color: hsl(0, 0%, 12%);
color: hsl(0, 0%, 75%);
font-size: 13px;
}
.core_TabContainer_Tab:hover {
background-color: hsl(0, 0%, 18%);
color: hsl(0, 0%, 85%);
}
.core_TabContainer_Tab.active {
background-color: var(--bg-color);
color: hsl(0, 0%, 90%);
border-bottom-color: var(--bg-color);
}

View File

@ -0,0 +1,99 @@
import { Widget, WidgetOptions } from "./Widget";
import { create_element, el } from "./dom";
import { LazyWidget } from "./LazyWidget";
import { Resizable } from "./Resizable";
import { ResizableWidget } from "./ResizableWidget";
import "./TabContainer.css";
export type Tab = {
title: string;
key: string;
create_view: () => Promise<Widget & Resizable>;
};
export type TabContainerOptions = WidgetOptions & {
tabs: Tab[];
};
type TabInfo = Tab & { tab_element: HTMLSpanElement; lazy_view: LazyWidget };
const BAR_HEIGHT = 28;
export class TabContainer extends ResizableWidget {
private tabs: TabInfo[] = [];
private bar_element = el.div({ class: "core_TabContainer_Bar" });
private panes_element = el.div({ class: "core_TabContainer_Panes" });
constructor(options: TabContainerOptions) {
super(el.div({ class: "core_TabContainer" }), options);
this.bar_element.onmousedown = this.bar_mousedown;
for (const tab of options.tabs) {
const tab_element = create_element("span", {
class: "core_TabContainer_Tab",
text: tab.title,
data: { key: tab.key },
});
this.bar_element.append(tab_element);
const lazy_view = new LazyWidget(tab.create_view);
this.tabs.push({
...tab,
tab_element,
lazy_view,
});
this.panes_element.append(lazy_view.element);
this.disposable(lazy_view);
}
if (this.tabs.length) {
this.activate(this.tabs[0].key);
}
this.element.append(this.bar_element, this.panes_element);
this.finalize_construction(TabContainer.prototype);
}
resize(width: number, height: number): this {
super.resize(width, height);
this.bar_element.style.width = `${width}px`;
this.bar_element.style.height = `${BAR_HEIGHT}px`;
const tab_pane_height = height - BAR_HEIGHT;
this.panes_element.style.width = `${width}px`;
this.panes_element.style.height = `${tab_pane_height}px`;
for (const tabs of this.tabs) {
tabs.lazy_view.resize(width, tab_pane_height);
}
return this;
}
private bar_mousedown = (e: MouseEvent) => {
if (e.target instanceof HTMLElement) {
const key = e.target.dataset["key"];
if (key) this.activate(key);
}
};
private activate(key: string): void {
for (const tab of this.tabs) {
const active = tab.key === key;
if (active) {
tab.tab_element.classList.add("active");
} else {
tab.tab_element.classList.remove("active");
}
tab.lazy_view.visible.val = active;
}
}
}

92
src/core/gui/Table.css Normal file
View File

@ -0,0 +1,92 @@
.core_Table {
position: relative;
display: block;
box-sizing: border-box;
overflow: auto;
background-color: var(--bg-color);
border-collapse: collapse;
}
.core_Table tr {
display: flex;
align-items: stretch;
}
.core_Table thead {
position: sticky;
display: inline-block;
top: 0;
z-index: 2;
}
.core_Table thead tr {
position: sticky;
top: 0;
}
.core_Table thead th {
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.core_Table th,
.core_Table td {
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
padding: 3px 6px;
border-right: var(--border);
border-bottom: var(--border);
background-color: var(--bg-color);
}
.core_Table tbody {
user-select: text;
cursor: text;
}
.core_Table tbody th,
.core_Table tbody td {
white-space: nowrap;
}
.core_Table tbody th,
.core_Table tfoot th {
text-align: left;
}
.core_Table th.fixed {
position: sticky;
text-align: left;
}
.core_Table th.input {
padding: 0;
overflow: visible;
}
.core_Table th.input .core_DurationInput {
z-index: 0;
height: 100%;
width: 100%;
border: none;
}
.core_Table th.input .core_DurationInput:hover,
.core_Table th.input .core_DurationInput:focus-within {
margin: -1px;
height: calc(100% + 2px);
width: calc(100% + 2px);
}
.core_Table th.input .core_DurationInput:hover {
z-index: 4;
border: var(--input-border-hover);
}
.core_Table th.input .core_DurationInput:focus-within {
z-index: 6;
border: var(--input-border-focus);
}

256
src/core/gui/Table.ts Normal file
View File

@ -0,0 +1,256 @@
import { Widget, WidgetOptions } from "./Widget";
import { el } from "./dom";
import {
ListChangeType,
ListProperty,
ListPropertyChangeEvent,
} from "../observable/property/list/ListProperty";
import { Disposer } from "../observable/Disposer";
import "./Table.css";
import Logger = require("js-logger");
const logger = Logger.get("core/gui/Table");
export type Column<T> = {
key?: string;
title: string;
fixed?: boolean;
width: number;
input?: boolean;
text_align?: string;
tooltip?: (value: T) => string;
sortable?: boolean;
render_cell(value: T, disposer: Disposer): string | HTMLElement;
footer?: {
render_cell(): string;
tooltip?(): string;
};
};
export enum SortDirection {
Asc,
Desc,
}
export type TableOptions<T> = WidgetOptions & {
values: ListProperty<T>;
columns: Column<T>[];
sort?(sort_columns: { column: Column<T>; direction: SortDirection }[]): void;
};
export class Table<T> extends Widget<HTMLTableElement> {
private readonly table_disposer = this.disposable(new Disposer());
private readonly tbody_element = el.tbody();
private readonly footer_row_element?: HTMLTableRowElement;
private readonly values: ListProperty<T>;
private readonly columns: Column<T>[];
constructor(options: TableOptions<T>) {
super(el.table({ class: "core_Table" }), options);
this.values = options.values;
this.columns = options.columns;
let sort_columns: { column: Column<T>; direction: SortDirection }[] = [];
const thead_element = el.thead();
const header_tr_element = el.tr();
let left = 0;
let has_footer = false;
header_tr_element.append(
...this.columns.map((column, index) => {
const th = el.th(
{ data: { index: index.toString() } },
el.span({ text: column.title }),
);
if (column.fixed) {
th.style.position = "sticky";
th.style.left = `${left}px`;
left += column.width;
}
th.style.width = `${column.width}px`;
if (column.footer) {
has_footer = true;
}
return th;
}),
);
const sort = options.sort;
if (sort) {
header_tr_element.onmousedown = e => {
if (e.target instanceof HTMLElement) {
let element: HTMLElement = e.target;
for (let i = 0; i < 5; i++) {
if (element.dataset.index) {
break;
} else if (element.parentElement) {
element = element.parentElement;
} else {
return;
}
}
if (!element.dataset.index) return;
const index = parseInt(element.dataset.index, 10);
const column = this.columns[index];
if (!column.sortable) return;
const existing_index = sort_columns.findIndex(sc => sc.column === column);
if (existing_index === 0) {
const sc = sort_columns[0];
sc.direction =
sc.direction === SortDirection.Asc
? SortDirection.Desc
: SortDirection.Asc;
} else {
if (existing_index !== -1) {
sort_columns.splice(existing_index, 1);
}
sort_columns.unshift({ column, direction: SortDirection.Asc });
}
sort(sort_columns);
}
};
}
thead_element.append(header_tr_element);
this.tbody_element = el.tbody();
this.element.append(thead_element, this.tbody_element);
if (has_footer) {
this.footer_row_element = el.tr();
this.element.append(el.tfoot({}, this.footer_row_element));
this.create_footer();
}
this.disposables(this.values.observe_list(this.update_table));
this.splice_rows(0, this.values.length.val, this.values.val);
this.finalize_construction(Table.prototype);
}
private update_table = (change: ListPropertyChangeEvent<T>): void => {
if (change.type === ListChangeType.ListChange) {
this.splice_rows(change.index, change.removed.length, change.inserted);
this.update_footer();
} else if (change.type === ListChangeType.ValueChange) {
// TODO: update rows
}
};
private splice_rows = (index: number, amount: number, inserted: T[]) => {
for (let i = 0; i < amount; i++) {
this.tbody_element.children[index].remove();
}
this.table_disposer.dispose_at(index, amount);
const rows = inserted.map((value, i) => this.create_row(index + i, value));
if (index >= this.tbody_element.childElementCount) {
this.tbody_element.append(...rows);
} else {
for (let i = 0; i < amount; i++) {
this.tbody_element.children[index + i].insertAdjacentElement(
"beforebegin",
rows[i],
);
}
}
};
private create_row = (index: number, value: T): HTMLTableRowElement => {
const disposer = this.table_disposer.add(new Disposer());
let left = 0;
return el.tr(
{},
...this.columns.map((column, i) => {
const cell = column.fixed ? el.th() : el.td();
try {
const content = column.render_cell(value, disposer);
cell.append(content);
if (column.input) cell.classList.add("input");
if (column.fixed) {
cell.classList.add("fixed");
cell.style.left = `${left}px`;
left += column.width || 0;
}
cell.style.width = `${column.width}px`;
if (column.text_align) cell.style.textAlign = column.text_align;
if (column.tooltip) cell.title = column.tooltip(value);
} catch (e) {
logger.warn(`Error while rendering cell for index ${index}, column ${i}.`, e);
}
return cell;
}),
);
};
private create_footer(): void {
const footer_cells: HTMLTableHeaderCellElement[] = [];
let left = 0;
for (let i = 0; i < this.columns.length; i++) {
const column = this.columns[i];
const cell = el.th();
cell.style.width = `${column.width}px`;
if (column.fixed) {
cell.classList.add("fixed");
cell.style.left = `${left}px`;
left += column.width || 0;
}
if (column.footer) {
cell.textContent = column.footer.render_cell();
cell.title = column.footer.tooltip ? column.footer.tooltip() : "";
}
if (column.text_align) cell.style.textAlign = column.text_align;
footer_cells.push(cell);
}
this.footer_row_element!.append(...footer_cells);
}
private update_footer(): void {
if (!this.footer_row_element) return;
const col_count = this.columns.length;
for (let i = 0; i < col_count; i++) {
const column = this.columns[i];
if (column.footer) {
const cell = this.footer_row_element.children[i] as HTMLTableHeaderCellElement;
cell.textContent = column.footer.render_cell();
cell.title = column.footer.tooltip ? column.footer.tooltip() : "";
}
}
}
}

33
src/core/gui/TextArea.css Normal file
View File

@ -0,0 +1,33 @@
.core_TextArea {
box-sizing: border-box;
display: inline-block;
border: var(--input-border);
}
.core_TextArea .core_TextArea_inner {
box-sizing: border-box;
vertical-align: top;
padding: 3px;
border: var(--input-inner-border);
background-color: var(--input-bg-color);
color: var(--input-text-color);
outline: none;
font-size: 13px;
}
.core_TextArea:hover {
border: var(--input-border-hover);
}
.core_TextArea:focus-within {
border: var(--input-border-focus);
}
.core_TextArea.disabled {
border: var(--input-border-disabled);
}
.core_TextArea.disabled .core_TextArea_inner {
color: var(--input-text-color-disabled);
background-color: var(--input-bg-color-disabled);
}

50
src/core/gui/TextArea.ts Normal file
View File

@ -0,0 +1,50 @@
import { LabelledControl, LabelledControlOptions } from "./LabelledControl";
import { el } from "./dom";
import { WritableProperty } from "../observable/property/WritableProperty";
import "./TextArea.css";
import { WidgetProperty } from "../observable/property/WidgetProperty";
export type TextAreaOptions = LabelledControlOptions & {
max_length?: number;
font_family?: string;
rows?: number;
cols?: number;
};
export class TextArea extends LabelledControl {
readonly preferred_label_position = "left";
readonly value: WritableProperty<string>;
private readonly text_element: HTMLTextAreaElement = el.textarea({
class: "core_TextArea_inner",
});
private readonly _value = new WidgetProperty<string>(this, "", this.set_value);
constructor(value = "", options?: TextAreaOptions) {
super(el.div({ class: "core_TextArea" }), options);
if (options) {
if (options.max_length != undefined) this.text_element.maxLength = options.max_length;
if (options.font_family != undefined)
this.text_element.style.fontFamily = options.font_family;
if (options.rows != undefined) this.text_element.rows = options.rows;
if (options.cols != undefined) this.text_element.cols = options.cols;
}
this.value = this._value;
this.set_value(value);
this.text_element.onchange = () =>
this._value.set_val(this.text_element.value, { silent: false });
this.element.append(this.text_element);
this.finalize_construction(TextArea.prototype);
}
protected set_value(value: string): void {
this.text_element.value = value;
}
}

31
src/core/gui/TextInput.ts Normal file
View File

@ -0,0 +1,31 @@
import { Input, InputOptions } from "./Input";
import { Property } from "../observable/property/Property";
export type TextInputOptions = InputOptions & {
max_length?: number | Property<number>;
};
export class TextInput extends Input<string> {
readonly preferred_label_position = "left";
constructor(value = "", options?: TextInputOptions) {
super(value, "core_TextInput", "text", "core_TextInput_inner", options);
if (options) {
const { max_length } = options;
this.set_attr("maxLength", max_length);
}
this.set_value(value);
this.finalize_construction(TextInput.prototype);
}
protected get_value(): string {
return this.input_element.value;
}
protected set_value(value: string): void {
this.input_element.value = value;
}
}

26
src/core/gui/ToolBar.css Normal file
View File

@ -0,0 +1,26 @@
.core_ToolBar {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
border-bottom: var(--border);
}
.core_ToolBar > * {
margin: 2px 1px;
}
.core_ToolBar > .core_ToolBar_group {
margin: 2px 3px;
display: flex;
flex-direction: row;
align-items: center;
}
.core_ToolBar > .core_ToolBar_group > * {
margin: 0 2px;
}
.core_ToolBar .core_Input {
height: 26px;
}

42
src/core/gui/ToolBar.ts Normal file
View File

@ -0,0 +1,42 @@
import { Widget, WidgetOptions } from "./Widget";
import { create_element } from "./dom";
import "./ToolBar.css";
import { LabelledControl } from "./LabelledControl";
export type ToolBarOptions = WidgetOptions & {
children?: Widget[];
};
export class ToolBar extends Widget {
readonly height = 33;
constructor(options?: ToolBarOptions) {
super(create_element("div", { class: "core_ToolBar" }), options);
this.element.style.height = `${this.height}px`;
if (options && options.children) {
for (const child of options.children) {
if (child instanceof LabelledControl && child.label) {
const group = create_element("div", { class: "core_ToolBar_group" });
if (
child.preferred_label_position === "left" ||
child.preferred_label_position === "top"
) {
group.append(child.label.element, child.element);
} else {
group.append(child.element, child.label.element);
}
this.element.append(group);
} else {
this.element.append(child.element);
this.disposable(child);
}
}
}
this.finalize_construction(ToolBar.prototype);
}
}

132
src/core/gui/Widget.ts Normal file
View File

@ -0,0 +1,132 @@
import { Disposable } from "../observable/Disposable";
import { Disposer } from "../observable/Disposer";
import { Observable } from "../observable/Observable";
import { bind_hidden } from "./dom";
import { WritableProperty } from "../observable/property/WritableProperty";
import { WidgetProperty } from "../observable/property/WidgetProperty";
import { Property } from "../observable/property/Property";
import Logger from "js-logger";
const logger = Logger.get("core/gui/Widget");
export type WidgetOptions = {
class?: string;
enabled?: boolean | Property<boolean>;
tooltip?: string | Property<string>;
};
export abstract class Widget<E extends HTMLElement = HTMLElement> implements Disposable {
readonly element: E;
get id(): string {
return this.element.id;
}
set id(id: string) {
this.element.id = id;
}
readonly visible: WritableProperty<boolean>;
readonly enabled: WritableProperty<boolean>;
readonly tooltip: WritableProperty<string>;
protected disposed = false;
private readonly disposer = new Disposer();
private readonly _visible: WidgetProperty<boolean> = new WidgetProperty<boolean>(
this,
true,
this.set_visible,
);
private readonly _enabled: WidgetProperty<boolean> = new WidgetProperty<boolean>(
this,
true,
this.set_enabled,
);
private readonly _tooltip: WidgetProperty<string> = new WidgetProperty<string>(
this,
"",
this.set_tooltip,
);
private readonly options: WidgetOptions;
private construction_finalized = false;
protected constructor(element: E, options?: WidgetOptions) {
this.element = element;
this.visible = this._visible;
this.enabled = this._enabled;
this.tooltip = this._tooltip;
this.options = options || {};
if (this.options.class) {
this.element.classList.add(this.options.class);
}
setTimeout(() => {
if (!this.construction_finalized) {
logger.warn(
`finalize_construction is never called for ${
Object.getPrototypeOf(this).constructor.name
}.`,
);
}
}, 0);
}
focus(): void {
this.element.focus();
}
dispose(): void {
this.element.remove();
this.disposer.dispose();
this.disposed = true;
}
protected finalize_construction(proto: any): void {
if (Object.getPrototypeOf(this) !== proto) return;
this.construction_finalized = true;
if (typeof this.options.enabled === "boolean") {
this.enabled.val = this.options.enabled;
} else if (this.options.enabled) {
this.enabled.bind_to(this.options.enabled);
}
if (typeof this.options.tooltip === "string") {
this.tooltip.val = this.options.tooltip;
} else if (this.options.tooltip) {
this.tooltip.bind_to(this.options.tooltip);
}
}
protected set_visible(visible: boolean): void {
this.element.hidden = !visible;
}
protected set_enabled(enabled: boolean): void {
if (enabled) {
this.element.classList.remove("disabled");
} else {
this.element.classList.add("disabled");
}
}
protected set_tooltip(tooltip: string): void {
this.element.title = tooltip;
}
protected bind_hidden(element: HTMLElement, observable: Observable<boolean>): void {
this.disposable(bind_hidden(element, observable));
}
protected disposable<T extends Disposable>(disposable: T): T {
return this.disposer.add(disposable);
}
protected disposables(...disposables: Disposable[]): void {
this.disposer.add_all(...disposables);
}
}

190
src/core/gui/dom.ts Normal file
View File

@ -0,0 +1,190 @@
import { Disposable } from "../observable/Disposable";
import { Observable } from "../observable/Observable";
import { is_property } from "../observable/property/Property";
import { SectionId } from "../model";
type ElementAttributes = {
class?: string;
tab_index?: number;
text?: string;
title?: string;
data?: { [key: string]: string };
};
export const el = {
div: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLDivElement =>
create_element("div", attributes, ...children),
span: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLSpanElement =>
create_element("span", attributes, ...children),
h2: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLHeadingElement =>
create_element("h2", attributes, ...children),
p: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLParagraphElement =>
create_element("p", attributes, ...children),
a: (
attributes?: ElementAttributes & {
href?: string;
},
...children: HTMLElement[]
): HTMLAnchorElement => {
const element = create_element<HTMLAnchorElement>("a", attributes, ...children);
if (attributes && attributes.href && attributes.href.trimLeft().startsWith("http")) {
element.target = "_blank";
element.rel = "noopener noreferrer";
}
return element;
},
table: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLTableElement =>
create_element("table", attributes, ...children),
thead: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLTableSectionElement =>
create_element("thead", attributes, ...children),
tbody: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLTableSectionElement =>
create_element("tbody", attributes, ...children),
tfoot: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLTableSectionElement =>
create_element("tfoot", attributes, ...children),
tr: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLTableRowElement =>
create_element("tr", attributes, ...children),
th: (
attributes?: ElementAttributes & { col_span?: number },
...children: HTMLElement[]
): HTMLTableHeaderCellElement => create_element("th", attributes, ...children),
td: (
attributes?: ElementAttributes & { col_span?: number },
...children: HTMLElement[]
): HTMLTableCellElement => create_element("td", attributes, ...children),
button: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLButtonElement =>
create_element("button", attributes, ...children),
textarea: (attributes?: ElementAttributes, ...children: HTMLElement[]): HTMLTextAreaElement =>
create_element("textarea", attributes, ...children),
};
export function create_element<T extends HTMLElement>(
tag_name: string,
attributes?: ElementAttributes & {
href?: string;
col_span?: number;
},
...children: HTMLElement[]
): T {
const element = document.createElement(tag_name) as (HTMLTableCellElement & HTMLAnchorElement);
if (attributes) {
if (attributes.class) element.className = attributes.class;
if (attributes.text) element.textContent = attributes.text;
if (attributes.title) element.title = attributes.title;
if (attributes.href) element.href = attributes.href;
if (attributes.data) {
for (const [key, val] of Object.entries(attributes.data)) {
element.dataset[key] = val;
}
}
if (attributes.col_span) element.colSpan = attributes.col_span;
if (attributes.tab_index) element.tabIndex = attributes.tab_index;
}
element.append(...children);
return (element as HTMLElement) as T;
}
export function bind_hidden(element: HTMLElement, observable: Observable<boolean>): Disposable {
if (is_property(observable)) {
element.hidden = observable.val;
}
return observable.observe(({ value }) => (element.hidden = value));
}
export enum Icon {
File,
NewFile,
Save,
TriangleUp,
TriangleDown,
Undo,
Redo,
Remove,
GitHub,
}
export function icon(icon: Icon): HTMLElement {
let icon_str!: string;
switch (icon) {
case Icon.File:
icon_str = "fas fa-file";
break;
case Icon.NewFile:
icon_str = "fas fa-file-medical";
break;
case Icon.Save:
icon_str = "fas fa-save";
break;
case Icon.TriangleUp:
icon_str = "fas fa-caret-up";
break;
case Icon.TriangleDown:
icon_str = "fas fa-caret-down";
break;
case Icon.Undo:
icon_str = "fas fa-undo";
break;
case Icon.Redo:
icon_str = "fas fa-redo";
break;
case Icon.Remove:
icon_str = "fas fa-trash-alt";
break;
case Icon.GitHub:
icon_str = "fab fa-github";
break;
}
return el.span({ class: icon_str });
}
export function section_id_icon(section_id: SectionId, options?: { size?: number }): HTMLElement {
const element = el.span();
const size = options && options.size;
element.style.display = "inline-block";
element.style.width = `${size}px`;
element.style.height = `${size}px`;
element.style.backgroundImage = `url(${process.env.PUBLIC_URL}/images/sectionids/${SectionId[section_id]}.png)`;
element.style.backgroundSize = `${size}px`;
element.title = SectionId[section_id];
return element;
}
export function disposable_listener(
element: DocumentAndElementEventHandlers,
event: string,
listener: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions,
): Disposable {
element.addEventListener(event, listener, options);
return {
dispose(): void {
element.removeEventListener(event, listener);
},
};
}

View File

@ -0,0 +1,53 @@
#root .lm_header {
box-sizing: border-box;
padding: 3px 0 0 0;
border-bottom: var(--border);
}
#root .lm_tabs {
padding: 0 3px;
}
#root .lm_tab {
cursor: default;
display: inline-flex;
align-items: center;
height: 23px;
padding: 0 10px;
border: var(--border);
margin: 0 1px -1px 1px;
background-color: hsl(0, 0%, 12%);
color: hsl(0, 0%, 75%);
font-size: 13px;
}
#root .lm_tab:hover {
background-color: hsl(0, 0%, 18%);
color: hsl(0, 0%, 85%);
}
#root .lm_tab.lm_active {
background-color: var(--bg-color);
color: hsl(0, 0%, 90%);
border-bottom-color: var(--bg-color);
}
#root .lm_splitter {
box-sizing: border-box;
background-color: hsl(0, 0%, 20%);
}
#root .lm_splitter.lm_vertical {
border-top: var(--border);
border-bottom: var(--border);
}
#root .lm_splitter.lm_horizontal {
border-left: var(--border);
border-right: var(--border);
}
body .lm_dropTargetIndicator {
box-sizing: border-box;
background-color: hsla(0, 0%, 100%, 0.2);
}

77
src/core/gui/index.css Normal file
View File

@ -0,0 +1,77 @@
:root {
/* Basic view variables */
--bg-color: hsl(0, 0%, 15%);
--text-color: hsl(0, 0%, 80%);
--text-color-disabled: hsl(0, 0%, 55%);
--font-family: Verdana, Geneva, sans-serif;
--border: solid 1px hsl(0, 0%, 25%);
/* Scrollbars */
--scrollbar-color: hsl(0, 0%, 13%);
--scrollbar-thumb-color: hsl(0, 0%, 17%);
/* Controls */
--control-bg-color: hsl(0, 0%, 20%);
--control-bg-color-hover: hsl(0, 0%, 25%);
--control-text-color: hsl(0, 0%, 80%);
--control-text-color-hover: hsl(0, 0%, 90%);
--control-border: solid 1px hsl(0, 0%, 10%);
--control-inner-border: solid 1px hsl(0, 0%, 35%);
/* Inputs */
--input-bg-color: hsl(0, 0%, 12%);
--input-bg-color-disabled: hsl(0, 0%, 15%);
--input-text-color: hsl(0, 0%, 75%);
--input-text-color-disabled: var(--text-color-disabled);
--input-border: solid 1px hsl(0, 0%, 25%);
--input-border-hover: solid 1px hsl(0, 0%, 30%);
--input-border-focus: solid 1px hsl(0, 0%, 40%);
--input-border-disabled: solid 1px hsl(0, 0%, 20%);
--input-inner-border: solid 1px hsl(0, 0%, 5%);
}
* {
scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-color);
}
::-webkit-scrollbar {
background-color: var(--scrollbar-color);
}
::-webkit-scrollbar-track {
background-color: var(--scrollbar-color);
}
::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-color);
}
::-webkit-scrollbar-corner {
background-color: var(--scrollbar-color);
}
body {
cursor: default;
user-select: none;
overflow: hidden;
margin: 0;
font-size: 13px;
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-family);
}
h2 {
font-size: 1.1em;
margin: 0.5em 0;
}
#root *[hidden] {
display: none;
}

View File

@ -1,4 +1,6 @@
import { observable, computed } from "mobx";
import { Property } from "../observable/property/Property";
import { WritableProperty } from "../observable/property/WritableProperty";
import { property } from "../observable";
//
// Item types.
@ -74,21 +76,40 @@ export interface Item {
}
export class WeaponItem implements Item {
/**
* Integer from 0 to 100.
*/
@observable attribute: number = 0;
/**
* Integer from 0 to 100.
*/
@observable hit: number = 0;
@observable grind: number = 0;
readonly type: WeaponItemType;
@computed get grind_atp(): number {
return 2 * this.grind;
/**
* Integer from 0 to 100.
*/
readonly attribute: Property<number>;
/**
* Integer from 0 to 100.
*/
readonly hit: Property<number>;
readonly grind: Property<number>;
readonly grind_atp: Property<number>;
private readonly _attribute: WritableProperty<number>;
private readonly _hit: WritableProperty<number>;
private readonly _grind: WritableProperty<number>;
constructor(type: WeaponItemType) {
this.type = type;
this._attribute = property(0);
this.attribute = this._attribute;
this._hit = property(0);
this.hit = this._hit;
this._grind = property(0);
this.grind = this._grind;
this.grind_atp = this.grind.map(grind => 2 * grind);
}
constructor(readonly type: WeaponItemType) {}
}
export class ArmorItem implements Item {

View File

@ -0,0 +1,10 @@
/**
* Objects implementing this interface should be disposed when they're not used anymore.
* This is to avoid e.g. memory leaks.
*/
export interface Disposable {
/**
* Releases any held resources.
*/
dispose(): void;
}

View File

@ -0,0 +1,51 @@
import { Disposer } from "./Disposer";
import { Disposable } from "./Disposable";
test("calling add or add_all should increase length correctly", () => {
const disposer = new Disposer();
expect(disposer.length).toBe(0);
disposer.add(dummy());
expect(disposer.length).toBe(1);
disposer.add_all(dummy(), dummy());
expect(disposer.length).toBe(3);
disposer.add(dummy());
expect(disposer.length).toBe(4);
disposer.add_all(dummy(), dummy());
expect(disposer.length).toBe(6);
});
test("length should be 0 after calling dispose", () => {
const disposer = new Disposer();
disposer.add_all(dummy(), dummy(), dummy());
expect(disposer.length).toBe(3);
disposer.dispose();
expect(disposer.length).toBe(0);
});
test("contained disposables should be disposed when calling dispose", () => {
let dispose_calls = 0;
function disposable(): Disposable {
return {
dispose(): void {
dispose_calls++;
},
};
}
const disposer = new Disposer();
disposer.add_all(disposable(), disposable(), disposable());
expect(dispose_calls).toBe(0);
disposer.dispose();
expect(dispose_calls).toBe(3);
});
function dummy(): Disposable {
return { dispose(): void {} };
}

View File

@ -0,0 +1,85 @@
import { Disposable } from "./Disposable";
import Logger = require("js-logger");
const logger = Logger.get("core/observable/Disposer");
/**
* Container for disposables.
*/
export class Disposer implements Disposable {
/**
* The amount of disposables contained in this disposer.
*/
get length(): number {
return this.disposables.length;
}
get disposed(): boolean {
return this._disposed;
}
private _disposed = false;
private readonly disposables: Disposable[];
constructor(...disposables: Disposable[]) {
this.disposables = disposables;
}
/**
* Add a single disposable and return the given disposable.
*/
add<T extends Disposable>(disposable: T): T {
if (!this._disposed) {
this.disposables.push(disposable);
}
return disposable;
}
/**
* Insert a single disposable at the given index and return the given disposable.
*/
insert<T extends Disposable>(index: number, disposable: T): T {
if (!this._disposed) {
this.disposables.splice(index, 0, disposable);
}
return disposable;
}
/**
* Add 0 or more disposables.
*/
add_all(...disposable: Disposable[]): this {
if (!this._disposed) {
this.disposables.push(...disposable);
}
return this;
}
/**
* Disposes all held disposables.
*/
dispose_all(): void {
this.dispose_at(0, this.disposables.length);
}
/**
* Disposes all held disposables.
*/
dispose(): void {
this.dispose_all();
this._disposed = true;
}
dispose_at(index: number, amount: number = 1): void {
for (const disposable of this.disposables.splice(index, amount)) {
try {
disposable.dispose();
} catch (e) {
logger.warn("Error while disposing.", e);
}
}
}
}

View File

@ -0,0 +1,5 @@
import { ChangeEvent, Observable } from "./Observable";
export interface Emitter<T> extends Observable<T> {
emit(event: ChangeEvent<T>): void;
}

View File

@ -0,0 +1,9 @@
import { Disposable } from "./Disposable";
export interface ChangeEvent<T> {
value: T;
}
export interface Observable<T> {
observe(observer: (event: ChangeEvent<T>) => void): Disposable;
}

View File

@ -0,0 +1,36 @@
import { Disposable } from "./Disposable";
import Logger from "js-logger";
import { Emitter } from "./Emitter";
import { ChangeEvent } from "./Observable";
const logger = Logger.get("core/observable/SimpleEmitter");
export class SimpleEmitter<T> implements Emitter<T> {
protected readonly observers: ((event: ChangeEvent<T>) => void)[] = [];
emit(event: ChangeEvent<T>): void {
for (const observer of this.observers) {
try {
observer(event);
} catch (e) {
logger.error("Observer threw error.", e);
}
}
}
observe(observer: (event: ChangeEvent<T>) => void): Disposable {
if (!this.observers.includes(observer)) {
this.observers.push(observer);
}
return {
dispose: () => {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
},
};
}
}

View File

@ -0,0 +1,65 @@
import { SimpleEmitter } from "./SimpleEmitter";
import { WritableProperty } from "./property/WritableProperty";
import { SimpleProperty } from "./property/SimpleProperty";
import { Emitter } from "./Emitter";
import { Property } from "./property/Property";
import { DependentProperty } from "./property/DependentProperty";
import { WritableListProperty } from "./property/list/WritableListProperty";
import { SimpleListProperty } from "./property/list/SimpleListProperty";
import { Observable } from "./Observable";
export function emitter<E>(): Emitter<E> {
return new SimpleEmitter();
}
export function property<T>(value: T): WritableProperty<T> {
return new SimpleProperty(value);
}
export function list_property<T>(
extract_observables?: (element: T) => Observable<any>[],
...elements: T[]
): WritableListProperty<T> {
return new SimpleListProperty(extract_observables, ...elements);
}
export function add(left: Property<number>, right: number): Property<number> {
return left.map(l => l + right);
}
export function sub(left: Property<number>, right: number): Property<number> {
return left.map(l => l - right);
}
export function map<R, P1, P2>(
f: (prop_1: P1, prop_2: P2) => R,
prop_1: Property<P1>,
prop_2: Property<P2>,
): Property<R>;
export function map<R, P1, P2, P3>(
f: (prop_1: P1, prop_2: P2, prop_3: P3) => R,
prop_1: Property<P1>,
prop_2: Property<P2>,
prop_3: Property<P3>,
): Property<R>;
export function map<R, P1, P2, P3, P4>(
f: (prop_1: P1, prop_2: P2, prop_3: P3, prop_4: P4) => R,
prop_1: Property<P1>,
prop_2: Property<P2>,
prop_3: Property<P3>,
prop_4: Property<P4>,
): Property<R>;
export function map<R, P1, P2, P3, P4, P5>(
f: (prop_1: P1, prop_2: P2, prop_3: P3, prop_4: P4, prop_5: P5) => R,
prop_1: Property<P1>,
prop_2: Property<P2>,
prop_3: Property<P3>,
prop_4: Property<P4>,
prop_5: Property<P5>,
): Property<R>;
export function map<R>(
f: (...props: Property<any>[]) => R,
...props: Property<any>[]
): Property<R> {
return new DependentProperty(props, () => f(...props.map(p => p.val)));
}

View File

@ -0,0 +1,58 @@
import { Disposable } from "../Disposable";
import Logger from "js-logger";
import { Property, PropertyChangeEvent } from "./Property";
const logger = Logger.get("core/observable/property/AbstractMinimalProperty");
// This class exists purely because otherwise the resulting cyclic dependency graph would trip up commonjs.
// The dependency graph is still cyclic but for some reason it's not a problem this way.
export abstract class AbstractMinimalProperty<T> implements Property<T> {
readonly is_property = true;
abstract readonly val: T;
abstract get_val(): T;
protected readonly observers: ((change: PropertyChangeEvent<T>) => void)[] = [];
observe(
observer: (change: PropertyChangeEvent<T>) => void,
options?: { call_now?: boolean },
): Disposable {
if (!this.observers.includes(observer)) {
this.observers.push(observer);
}
if (options && options.call_now) {
this.call_observer(observer, this.val);
}
return {
dispose: () => {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
},
};
}
abstract map<U>(f: (element: T) => U): Property<U>;
abstract flat_map<U>(f: (element: T) => Property<U>): Property<U>;
protected emit(old_value: T): void {
for (const observer of this.observers) {
this.call_observer(observer, old_value);
}
}
private call_observer(observer: (event: PropertyChangeEvent<T>) => void, old_value: T): void {
try {
observer({ value: this.val, old_value });
} catch (e) {
logger.error("Observer threw error.", e);
}
}
}

View File

@ -0,0 +1,14 @@
import { DependentProperty } from "./DependentProperty";
import { FlatMappedProperty } from "./FlatMappedProperty";
import { AbstractMinimalProperty } from "./AbstractMinimalProperty";
import { Property } from "./Property";
export abstract class AbstractProperty<T> extends AbstractMinimalProperty<T> {
map<U>(f: (element: T) => U): Property<U> {
return new DependentProperty([this], () => f(this.val));
}
flat_map<U>(f: (element: T) => Property<U>): Property<U> {
return new FlatMappedProperty(this, value => f(value));
}
}

View File

@ -0,0 +1,73 @@
import { Disposable } from "../Disposable";
import { Disposer } from "../Disposer";
import { AbstractMinimalProperty } from "./AbstractMinimalProperty";
import { FlatMappedProperty } from "./FlatMappedProperty";
import { Property, PropertyChangeEvent } from "./Property";
/**
* Starts observing its dependencies when the first observer on this property is registered.
* Stops observing its dependencies when the last observer on this property is disposed.
* This way no extra disposables need to be managed when e.g. {@link Property.map} is used.
*/
export class DependentProperty<T> extends AbstractMinimalProperty<T> implements Property<T> {
private _val?: T;
get val(): T {
return this.get_val();
}
get_val(): T {
if (this.dependency_disposables.length) {
return this._val as T;
} else {
return this.f();
}
}
private dependency_disposables = new Disposer();
constructor(private dependencies: Property<any>[], private f: () => T) {
super();
}
observe(
observer: (event: PropertyChangeEvent<T>) => void,
options: { call_now?: boolean } = {},
): Disposable {
const super_disposable = super.observe(observer, options);
if (this.dependency_disposables.length === 0) {
this._val = this.f();
this.dependency_disposables.add_all(
...this.dependencies.map(dependency =>
dependency.observe(() => {
const old_value = this._val!;
this._val = this.f();
this.emit(old_value);
}),
),
);
}
this.emit(this._val!);
return {
dispose: () => {
super_disposable.dispose();
if (this.observers.length === 0) {
this.dependency_disposables.dispose_all();
}
},
};
}
map<U>(f: (element: T) => U): Property<U> {
return new DependentProperty([this], () => f(this.val));
}
flat_map<U>(f: (element: T) => Property<U>): Property<U> {
return new FlatMappedProperty(this, value => f(value));
}
}

View File

@ -0,0 +1,81 @@
import { Disposable } from "../Disposable";
import { AbstractMinimalProperty } from "./AbstractMinimalProperty";
import { DependentProperty } from "./DependentProperty";
import { Property, PropertyChangeEvent } from "./Property";
/**
* Starts observing its dependency when the first observer on this property is registered.
* Stops observing its dependency when the last observer on this property is disposed.
* This way no extra disposables need to be managed when {@link Property.flat_map} is used.
*/
export class FlatMappedProperty<T, U> extends AbstractMinimalProperty<U> implements Property<U> {
get val(): U {
return this.get_val();
}
get_val(): U {
return this.computed_property
? this.computed_property.val
: this.f(this.dependency.val).val;
}
private dependency_disposable?: Disposable;
private computed_property?: Property<U>;
private computed_disposable?: Disposable;
constructor(private dependency: Property<T>, private f: (value: T) => Property<U>) {
super();
}
observe(observer: (event: PropertyChangeEvent<U>) => void): Disposable {
const super_disposable = super.observe(observer);
if (this.dependency_disposable == undefined) {
this.dependency_disposable = this.dependency.observe(() => {
const old_value = this.val;
this.compute_and_observe();
this.emit(old_value);
});
this.compute_and_observe();
}
this.emit(this.get_val());
return {
dispose: () => {
super_disposable.dispose();
if (this.observers.length === 0) {
this.dependency_disposable!.dispose();
this.dependency_disposable = undefined;
this.computed_disposable!.dispose();
this.computed_disposable = undefined;
this.computed_property = undefined;
}
},
};
}
map<V>(f: (element: U) => V): Property<V> {
return new DependentProperty([this], () => f(this.val));
}
flat_map<V>(f: (element: U) => Property<V>): Property<V> {
return new FlatMappedProperty(this, value => f(value));
}
private compute_and_observe(): void {
if (this.computed_disposable) this.computed_disposable.dispose();
this.computed_property = this.f(this.dependency.val);
let old_value = this.computed_property.val;
this.computed_disposable = this.computed_property.observe(() => {
const ov = old_value;
old_value = this.val;
this.emit(ov);
});
}
}

View File

@ -0,0 +1,31 @@
import { ChangeEvent, Observable } from "../Observable";
import { Disposable } from "../Disposable";
export interface PropertyChangeEvent<T> extends ChangeEvent<T> {
old_value: T;
}
export interface Property<T> extends Observable<T> {
readonly is_property: true;
readonly val: T;
get_val(): T;
observe(
observer: (event: PropertyChangeEvent<T>) => void,
options?: { call_now?: boolean },
): Disposable;
map<U>(f: (element: T) => U): Property<U>;
flat_map<U>(f: (element: T) => Property<U>): Property<U>;
}
export function is_property<T>(observable: Observable<T>): observable is Property<T> {
return (observable as any).is_property;
}
export function is_any_property(observable: any): observable is Property<any> {
return observable && observable.is_property;
}

View File

@ -0,0 +1,57 @@
import { Disposable } from "../Disposable";
import { Observable } from "../Observable";
import { WritableProperty } from "./WritableProperty";
import { AbstractProperty } from "./AbstractProperty";
import { is_property } from "./Property";
export class SimpleProperty<T> extends AbstractProperty<T> implements WritableProperty<T> {
constructor(private _val: T) {
super();
}
get val(): T {
return this.get_val();
}
set val(value: T) {
this.set_val(value);
}
get_val(): T {
return this._val;
}
set_val(val: T, options: { silent?: boolean } = {}): void {
if (val !== this._val) {
const old_value = this._val;
this._val = val;
if (!options.silent) {
this.emit(old_value);
}
}
}
update(f: (value: T) => T): void {
this.val = f(this.val);
}
bind_to(observable: Observable<T>): Disposable {
if (is_property(observable)) {
this.val = observable.val;
}
return observable.observe(event => (this.val = event.value));
}
bind_bi(property: WritableProperty<T>): Disposable {
const bind_1 = this.bind_to(property);
const bind_2 = property.bind_to(this);
return {
dispose(): void {
bind_1.dispose();
bind_2.dispose();
},
};
}
}

View File

@ -0,0 +1,13 @@
import { SimpleProperty } from "./SimpleProperty";
import { Widget } from "../../gui/Widget";
export class WidgetProperty<T> extends SimpleProperty<T> {
constructor(private widget: Widget, val: T, private set_value: (this: Widget, val: T) => void) {
super(val);
}
set_val(val: T, options?: { silent?: boolean }): void {
this.set_value.call(this.widget, val);
super.set_val(val, { silent: true, ...options });
}
}

View File

@ -0,0 +1,20 @@
import { Observable } from "../Observable";
import { Disposable } from "../Disposable";
import { Property } from "./Property";
export interface WritableProperty<T> extends Property<T> {
val: T;
set_val(value: T, options?: { silent?: boolean }): void;
update(f: (value: T) => T): void;
/**
* Bind the value of this property to the given observable.
*
* @param observable the observable who's events will be propagated to this property.
*/
bind_to(observable: Observable<T>): Disposable;
bind_bi(property: WritableProperty<T>): Disposable;
}

View File

@ -0,0 +1,44 @@
import { Property } from "../Property";
import { Disposable } from "../../Disposable";
import { Observable } from "../../Observable";
export enum ListChangeType {
ListChange,
ValueChange,
}
export type ListPropertyChangeEvent<T> = ListChange<T> | ListValueChange<T>;
export type ListChange<T> = {
readonly type: ListChangeType.ListChange;
readonly index: number;
readonly removed: T[];
readonly inserted: T[];
};
export type ListValueChange<T> = {
readonly type: ListChangeType.ValueChange;
readonly index: number;
readonly updated: T[];
};
export interface ListProperty<T> extends Property<T[]> {
readonly is_list_property: true;
readonly length: Property<number>;
get(index: number): T;
observe_list(
observer: (change: ListPropertyChangeEvent<T>) => void,
options?: { call_now?: boolean },
): Disposable;
}
export function is_list_property<T>(observable: Observable<T[]>): observable is ListProperty<T> {
return (observable as any).is_list_property;
}
export function is_any_list_property(observable: any): observable is ListProperty<any> {
return observable && observable.is_list_property;
}

View File

@ -0,0 +1,286 @@
import { WritableListProperty } from "./WritableListProperty";
import { Disposable } from "../../Disposable";
import { WritableProperty } from "../WritableProperty";
import { Observable } from "../../Observable";
import { property } from "../../index";
import { AbstractProperty } from "../AbstractProperty";
import { Property } from "../Property";
import { is_list_property, ListChangeType, ListPropertyChangeEvent } from "./ListProperty";
import Logger from "js-logger";
const logger = Logger.get("core/observable/property/list/SimpleListProperty");
export class SimpleListProperty<T> extends AbstractProperty<T[]>
implements WritableListProperty<T> {
readonly is_list_property = true;
readonly length: Property<number>;
get val(): T[] {
return this.get_val();
}
set val(values: T[]) {
this.set_val(values);
}
get_val(): T[] {
return this.values;
}
set_val(values: T[]): T[] {
const removed = this.values.splice(0, this.values.length, ...values);
this.finalize_update({
type: ListChangeType.ListChange,
index: 0,
removed,
inserted: values,
});
return removed;
}
private readonly _length = property(0);
private readonly values: T[];
private readonly extract_observables?: (element: T) => Observable<any>[];
/**
* Internal observers which observe observables related to this list's values so that their
* changes can be propagated via update events.
*/
private readonly value_observers: { index: number; disposables: Disposable[] }[] = [];
/**
* External observers which are observing this list.
*/
private readonly list_observers: ((change: ListPropertyChangeEvent<T>) => void)[] = [];
/**
* @param extract_observables - Extractor function called on each value in this list. Changes
* to the returned observables will be propagated via update events.
* @param values - Initial values of this list.
*/
constructor(extract_observables?: (element: T) => Observable<any>[], ...values: T[]) {
super();
this.length = this._length;
this.values = values;
this.extract_observables = extract_observables;
}
observe_list(observer: (change: ListPropertyChangeEvent<T>) => void): Disposable {
if (this.value_observers.length === 0 && this.extract_observables) {
this.replace_element_observers(0, Infinity, this.values);
}
if (!this.list_observers.includes(observer)) {
this.list_observers.push(observer);
}
return {
dispose: () => {
const index = this.list_observers.indexOf(observer);
if (index !== -1) {
this.list_observers.splice(index, 1);
}
if (this.list_observers.length === 0) {
for (const { disposables } of this.value_observers) {
for (const disposable of disposables) {
disposable.dispose();
}
}
this.value_observers.splice(0, Infinity);
}
},
};
}
bind_to(observable: Observable<T[]>): Disposable {
if (is_list_property(observable)) {
return observable.observe_list(change => {
if (change.type === ListChangeType.ListChange) {
this.splice(change.index, change.removed.length, ...change.inserted);
}
});
} else {
return observable.observe(({ value }) => this.set_val(value));
}
}
bind_bi(property: WritableProperty<T[]>): Disposable {
const bind_1 = this.bind_to(property);
const bind_2 = property.bind_to(this);
return {
dispose(): void {
bind_1.dispose();
bind_2.dispose();
},
};
}
update(f: (element: T[]) => T[]): void {
this.splice(0, this.values.length, ...f(this.values));
}
get(index: number): T {
return this.values[index];
}
set(index: number, element: T): void {
const removed = [this.values[index]];
this.values[index] = element;
this.finalize_update({
type: ListChangeType.ListChange,
index,
removed,
inserted: [element],
});
}
push(...values: T[]): number {
const index = this.values.length;
this.values.push(...values);
this.finalize_update({
type: ListChangeType.ListChange,
index,
removed: [],
inserted: values,
});
return this.length.val;
}
remove(...values: T[]): void {
for (const value of values) {
const index = this.values.indexOf(value);
this.values.splice(index, 1);
this.finalize_update({
type: ListChangeType.ListChange,
index,
removed: [value],
inserted: [],
});
}
}
clear(): void {
const removed = this.values.splice(0, this.values.length);
this.finalize_update({
type: ListChangeType.ListChange,
index: 0,
removed,
inserted: [],
});
}
splice(index: number, delete_count?: number, ...values: T[]): T[] {
let removed: T[];
if (delete_count == undefined) {
removed = this.values.splice(index);
} else {
removed = this.values.splice(index, delete_count, ...values);
}
this.finalize_update({
type: ListChangeType.ListChange,
index,
removed,
inserted: values,
});
return removed;
}
sort(compare: (a: T, b: T) => number): void {
this.values.sort(compare);
this.finalize_update({
type: ListChangeType.ListChange,
index: 0,
removed: this.values,
inserted: this.values,
});
}
/**
* Does the following in the given order:
* - Updates value observers
* - Sets length silently
* - Emits ListPropertyChangeEvent
* - Emits PropertyChangeEvent
* - Emits length PropertyChangeEvent if necessary
*/
protected finalize_update(change: ListPropertyChangeEvent<T>): void {
if (
this.list_observers.length &&
this.extract_observables &&
change.type === ListChangeType.ListChange
) {
this.replace_element_observers(change.index, change.removed.length, change.inserted);
}
const old_length = this._length.val;
this._length.set_val(this.values.length, { silent: true });
for (const observer of this.list_observers) {
this.call_list_observer(observer, change);
}
this.emit(this.values);
// Set length to old length first to ensure an event is emitted.
this._length.set_val(old_length, { silent: true });
this._length.set_val(this.values.length, { silent: false });
}
private call_list_observer(
observer: (change: ListPropertyChangeEvent<T>) => void,
change: ListPropertyChangeEvent<T>,
): void {
try {
observer(change);
} catch (e) {
logger.error("Observer threw error.", e);
}
}
private replace_element_observers(from: number, amount: number, new_elements: T[]): void {
let index = from;
const removed = this.value_observers.splice(
from,
amount,
...new_elements.map(element => {
const obj = {
index,
disposables: this.extract_observables!(element).map(observable =>
observable.observe(() => {
this.finalize_update({
type: ListChangeType.ValueChange,
updated: [element],
index: obj.index,
});
}),
),
};
index++;
return obj;
}),
);
for (const { disposables } of removed) {
for (const disposable of disposables) {
disposable.dispose();
}
}
const shift = new_elements.length - amount;
while (index < this.value_observers.length) {
this.value_observers[index++].index += shift;
}
}
}

View File

@ -0,0 +1,19 @@
import { ListProperty } from "./ListProperty";
import { WritableProperty } from "../WritableProperty";
export interface WritableListProperty<T> extends ListProperty<T>, WritableProperty<T[]> {
val: T[];
set(index: number, value: T): void;
push(...values: T[]): number;
splice(index: number, delete_count?: number): T[];
splice(index: number, delete_count: number, ...values: T[]): T[];
remove(...values: T[]): void;
clear(): void;
sort(compare: (a: T, b: T) => number): void;
}

View File

@ -1,11 +1,11 @@
import Logger from "js-logger";
import { Server } from "./domain";
import { Server } from "./model";
const logger = Logger.get("persistence/Persister");
const logger = Logger.get("core/persistence/Persister");
export abstract class Persister {
protected persist_for_server(server: Server, key: string, data: any): void {
this.persist(key + "." + Server[server], data);
this.persist(this.server_key(server, key), data);
}
protected persist(key: string, data: any): void {
@ -17,7 +17,7 @@ export abstract class Persister {
}
protected async load_for_server<T>(server: Server, key: string): Promise<T | undefined> {
return this.load(key + "." + Server[server]);
return this.load(this.server_key(server, key));
}
protected async load<T>(key: string): Promise<T | undefined> {
@ -29,4 +29,18 @@ export abstract class Persister {
return undefined;
}
}
private server_key(server: Server, key: string): string {
let k = key + ".";
switch (server) {
case Server.Ephinea:
k += "Ephinea";
break;
default:
throw new Error(`Server ${Server[server]} not supported.`);
}
return k;
}
}

View File

@ -1,6 +1,7 @@
import CameraControls from "camera-controls";
import * as THREE from "three";
import {
Camera,
Clock,
Color,
Group,
@ -12,6 +13,7 @@ import {
Vector3,
WebGLRenderer,
} from "three";
import { Disposable } from "../observable/Disposable";
CameraControls.install({
// Hack to make panning and orbiting work the way we want.
@ -21,8 +23,8 @@ CameraControls.install({
},
});
export abstract class Renderer<C extends PerspectiveCamera | OrthographicCamera> {
protected _debug = false;
export abstract class Renderer implements Disposable {
private _debug = false;
get debug(): boolean {
return this._debug;
@ -32,18 +34,18 @@ export abstract class Renderer<C extends PerspectiveCamera | OrthographicCamera>
this._debug = debug;
}
readonly camera: C;
readonly camera: Camera;
readonly controls: CameraControls;
readonly scene = new Scene();
readonly light_holder = new Group();
private renderer = new WebGLRenderer({ antialias: true });
private render_scheduled = false;
private render_stop_scheduled = false;
private animation_frame_handle?: number = undefined;
private light = new HemisphereLight(0xffffff, 0x505050, 1.2);
private controls_clock = new Clock();
protected constructor(camera: C) {
protected constructor(camera: PerspectiveCamera | OrthographicCamera) {
this.camera = camera;
this.dom_element.tabIndex = 0;
@ -78,11 +80,15 @@ export abstract class Renderer<C extends PerspectiveCamera | OrthographicCamera>
}
start_rendering(): void {
requestAnimationFrame(this.call_render);
this.schedule_render();
this.animation_frame_handle = requestAnimationFrame(this.call_render);
}
stop_rendering(): void {
this.render_stop_scheduled = true;
if (this.animation_frame_handle != undefined) {
cancelAnimationFrame(this.animation_frame_handle);
this.animation_frame_handle = undefined;
}
}
schedule_render = () => {
@ -100,6 +106,10 @@ export abstract class Renderer<C extends PerspectiveCamera | OrthographicCamera>
);
}
dispose(): void {
this.renderer.dispose();
}
protected render(): void {
this.renderer.render(this.scene, this.camera);
}
@ -114,15 +124,10 @@ export abstract class Renderer<C extends PerspectiveCamera | OrthographicCamera>
this.render_scheduled = false;
if (this.render_stop_scheduled) {
this.render_stop_scheduled = false;
return;
}
if (should_render) {
this.render();
}
requestAnimationFrame(this.call_render);
this.animation_frame_handle = requestAnimationFrame(this.call_render);
};
}

View File

@ -8,7 +8,7 @@ import {
QuaternionKeyframeTrack,
VectorKeyframeTrack,
} from "three";
import { NjModel, NjObject } from "../../data_formats/parsing/ninja";
import { NjObject } from "../../data_formats/parsing/ninja";
import {
NjInterpolation,
NjKeyframeTrackType,
@ -17,10 +17,7 @@ import {
export const PSO_FRAME_RATE = 30;
export function create_animation_clip(
nj_object: NjObject<NjModel>,
nj_motion: NjMotion,
): AnimationClip {
export function create_animation_clip(nj_object: NjObject, nj_motion: NjMotion): AnimationClip {
const interpolation =
nj_motion.interpolation === NjInterpolation.Spline ? InterpolateSmooth : InterpolateLinear;

View File

@ -11,14 +11,11 @@ const NO_TRANSLATION = new Vector3(0, 0, 0);
const NO_ROTATION = new Quaternion(0, 0, 0, 1);
const NO_SCALE = new Vector3(1, 1, 1);
export function ninja_object_to_geometry_builder(
object: NjObject<NjModel>,
builder: GeometryBuilder,
): void {
export function ninja_object_to_geometry_builder(object: NjObject, builder: GeometryBuilder): void {
new GeometryCreator(builder).to_geometry_builder(object);
}
export function ninja_object_to_buffer_geometry(object: NjObject<NjModel>): BufferGeometry {
export function ninja_object_to_buffer_geometry(object: NjObject): BufferGeometry {
return new GeometryCreator(new GeometryBuilder()).create_buffer_geometry(object);
}
@ -62,17 +59,17 @@ class GeometryCreator {
this.builder = builder;
}
to_geometry_builder(object: NjObject<NjModel>): void {
to_geometry_builder(object: NjObject): void {
this.object_to_geometry(object, undefined, new Matrix4());
}
create_buffer_geometry(object: NjObject<NjModel>): BufferGeometry {
create_buffer_geometry(object: NjObject): BufferGeometry {
this.to_geometry_builder(object);
return this.builder.build();
}
private object_to_geometry(
object: NjObject<NjModel>,
object: NjObject,
parent_bone: Bone | undefined,
parent_matrix: Matrix4,
): void {

View File

@ -0,0 +1,17 @@
import { sequential } from "./sequential";
test("sequential functions should run sequentially", () => {
let time = 10;
const f = sequential(() => new Promise(resolve => setTimeout(resolve, time--)));
const resolved_values: number[] = [];
let last_promise!: Promise<any>;
for (let i = 0; i < 10; i++) {
last_promise = f().then(() => resolved_values.push(i));
}
expect(resolved_values).toEqual([]);
return last_promise.then(() => expect(resolved_values).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]));
});

37
src/core/sequential.ts Normal file
View File

@ -0,0 +1,37 @@
/**
* Takes a function f that returns a promise and returns a function that forwards calls to f
* sequentially. So f will never be called while a call to f is underway.
*/
export function sequential<F extends (...args: any[]) => Promise<any>>(f: F): F {
const queue: {
args: any[];
resolve: (value: any) => void;
reject: (reason: any) => void;
}[] = [];
async function process_queue(): Promise<void> {
while (queue.length) {
const { args, resolve, reject } = queue[0];
try {
resolve(await f(...args));
} catch (e) {
reject(e);
} finally {
queue.shift();
}
}
}
function g(...args: any[]): Promise<any> {
const promise = new Promise((resolve, reject) => queue.push({ args, resolve, reject }));
if (queue.length === 1) {
process_queue();
}
return promise;
}
return g as F;
}

View File

@ -0,0 +1,93 @@
import { WritableProperty } from "../observable/property/WritableProperty";
import { Disposable } from "../observable/Disposable";
import { property } from "../observable";
import { Property } from "../observable/property/Property";
import { Server } from "../model";
export enum GuiTool {
Viewer,
QuestEditor,
HuntOptimizer,
}
const GUI_TOOL_TO_STRING = new Map([
[GuiTool.Viewer, "viewer"],
[GuiTool.QuestEditor, "quest_editor"],
[GuiTool.HuntOptimizer, "hunt_optimizer"],
]);
const STRING_TO_GUI_TOOL = new Map([...GUI_TOOL_TO_STRING.entries()].map(([k, v]) => [v, k]));
class GuiStore implements Disposable {
readonly tool: WritableProperty<GuiTool> = property(GuiTool.Viewer);
readonly server: Property<Server>;
private readonly _server: WritableProperty<Server> = property(Server.Ephinea);
private readonly hash_disposer = this.tool.observe(({ value: tool }) => {
window.location.hash = `#/${gui_tool_to_string(tool)}`;
});
private readonly global_keydown_handlers = new Map<string, () => void>();
constructor() {
const tool = window.location.hash.slice(2);
this.tool.val = string_to_gui_tool(tool) || GuiTool.Viewer;
this.server = this._server;
window.addEventListener("keydown", this.dispatch_global_keydown);
}
dispose(): void {
this.hash_disposer.dispose();
this.global_keydown_handlers.clear();
window.removeEventListener("keydown", this.dispatch_global_keydown);
}
on_global_keydown(tool: GuiTool, binding: string, handler: () => void): Disposable {
const key = this.handler_key(tool, binding);
this.global_keydown_handlers.set(key, handler);
return {
dispose: () => {
this.global_keydown_handlers.delete(key);
},
};
}
private dispatch_global_keydown = (e: KeyboardEvent) => {
const binding_parts: string[] = [];
if (e.ctrlKey) binding_parts.push("Ctrl");
if (e.shiftKey) binding_parts.push("Shift");
if (e.altKey) binding_parts.push("Alt");
binding_parts.push(e.key.toUpperCase());
const binding = binding_parts.join("-");
const handler = this.global_keydown_handlers.get(this.handler_key(this.tool.val, binding));
if (handler) {
e.preventDefault();
handler();
}
};
private handler_key(tool: GuiTool, binding: string): string {
return `${(GuiTool as any)[tool]} -> ${binding}`;
}
}
export const gui_store = new GuiStore();
function string_to_gui_tool(tool: string): GuiTool | undefined {
return STRING_TO_GUI_TOOL.get(tool);
}
function gui_tool_to_string(tool: GuiTool): string {
const str = GUI_TOOL_TO_STRING.get(tool);
if (str) {
return str;
} else {
throw new Error(`To string not implemented for ${(GuiTool as any)[tool]}.`);
}
}

View File

@ -1,4 +1,3 @@
import { observable } from "mobx";
import {
ArmorItemType,
ItemType,
@ -6,95 +5,91 @@ import {
ToolItemType,
UnitItemType,
WeaponItemType,
} from "../domain/items";
import { Loadable } from "../Loadable";
} from "../model/items";
import { ServerMap } from "./ServerMap";
import { ItemTypeDto } from "../dto";
import { Server } from "../domain";
import { Server } from "../model";
import { ItemTypeDto } from "../dto/ItemTypeDto";
export class ItemTypeStore {
private id_to_item_type: ItemType[] = [];
readonly item_types: ItemType[];
@observable item_types: ItemType[] = [];
get_by_id(id: number): ItemType | undefined {
return this.id_to_item_type[id];
constructor(item_types: ItemType[], private readonly id_to_item_type: ItemType[]) {
this.item_types = item_types;
}
load = async (server: Server): Promise<ItemTypeStore> => {
const response = await fetch(
`${process.env.PUBLIC_URL}/itemTypes.${Server[server].toLowerCase()}.json`,
);
const data: ItemTypeDto[] = await response.json();
const item_types = new Array<ItemType>();
for (const item_type_dto of data) {
let item_type: ItemType;
switch (item_type_dto.class) {
case "weapon":
item_type = new WeaponItemType(
item_type_dto.id,
item_type_dto.name,
item_type_dto.minAtp,
item_type_dto.maxAtp,
item_type_dto.ata,
item_type_dto.maxGrind,
item_type_dto.requiredAtp,
);
break;
case "armor":
item_type = new ArmorItemType(
item_type_dto.id,
item_type_dto.name,
item_type_dto.atp,
item_type_dto.ata,
item_type_dto.minEvp,
item_type_dto.maxEvp,
item_type_dto.minDfp,
item_type_dto.maxDfp,
item_type_dto.mst,
item_type_dto.hp,
item_type_dto.lck,
);
break;
case "shield":
item_type = new ShieldItemType(
item_type_dto.id,
item_type_dto.name,
item_type_dto.atp,
item_type_dto.ata,
item_type_dto.minEvp,
item_type_dto.maxEvp,
item_type_dto.minDfp,
item_type_dto.maxDfp,
item_type_dto.mst,
item_type_dto.hp,
item_type_dto.lck,
);
break;
case "unit":
item_type = new UnitItemType(item_type_dto.id, item_type_dto.name);
break;
case "tool":
item_type = new ToolItemType(item_type_dto.id, item_type_dto.name);
break;
default:
continue;
}
this.id_to_item_type[item_type.id] = item_type;
item_types.push(item_type);
}
this.item_types = item_types;
return this;
get_by_id = (id: number): ItemType | undefined => {
return this.id_to_item_type[id];
};
}
export const item_type_stores: ServerMap<Loadable<ItemTypeStore>> = new ServerMap(server => {
const store = new ItemTypeStore();
return new Loadable(store, () => store.load(server));
});
async function load(server: Server): Promise<ItemTypeStore> {
const response = await fetch(
`${process.env.PUBLIC_URL}/itemTypes.${Server[server].toLowerCase()}.json`,
);
const data: ItemTypeDto[] = await response.json();
const item_types: ItemType[] = [];
const id_to_item_type: ItemType[] = [];
for (const item_type_dto of data) {
let item_type: ItemType;
switch (item_type_dto.class) {
case "weapon":
item_type = new WeaponItemType(
item_type_dto.id,
item_type_dto.name,
item_type_dto.minAtp,
item_type_dto.maxAtp,
item_type_dto.ata,
item_type_dto.maxGrind,
item_type_dto.requiredAtp,
);
break;
case "armor":
item_type = new ArmorItemType(
item_type_dto.id,
item_type_dto.name,
item_type_dto.atp,
item_type_dto.ata,
item_type_dto.minEvp,
item_type_dto.maxEvp,
item_type_dto.minDfp,
item_type_dto.maxDfp,
item_type_dto.mst,
item_type_dto.hp,
item_type_dto.lck,
);
break;
case "shield":
item_type = new ShieldItemType(
item_type_dto.id,
item_type_dto.name,
item_type_dto.atp,
item_type_dto.ata,
item_type_dto.minEvp,
item_type_dto.maxEvp,
item_type_dto.minDfp,
item_type_dto.maxDfp,
item_type_dto.mst,
item_type_dto.hp,
item_type_dto.lck,
);
break;
case "unit":
item_type = new UnitItemType(item_type_dto.id, item_type_dto.name);
break;
case "tool":
item_type = new ToolItemType(item_type_dto.id, item_type_dto.name);
break;
default:
continue;
}
id_to_item_type[item_type.id] = item_type;
item_types.push(item_type);
}
return new ItemTypeStore(item_types, id_to_item_type);
}
export const item_type_stores: ServerMap<ItemTypeStore> = new ServerMap(load);

View File

@ -1,20 +1,38 @@
import { computed } from "mobx";
import { Server } from "../domain";
import { application_store } from "../../application/stores/ApplicationStore";
import { EnumMap } from "../enums";
import { Server } from "../model";
import { Property } from "../observable/property/Property";
import { gui_store } from "./GuiStore";
import { memoize } from "lodash";
import { sequential } from "../sequential";
import { Disposable } from "../observable/Disposable";
/**
* Map with a guaranteed value per server.
* Map with a lazily-loaded, guaranteed value per server.
*/
export class ServerMap<V> extends EnumMap<Server, V> {
constructor(initial_value: (server: Server) => V) {
super(Server, initial_value);
export class ServerMap<T> {
/**
* The value for the current server as set in {@link gui_store}.
*/
get current(): Property<Promise<T>> {
if (!this._current) {
this._current = gui_store.server.map(server => this.get(server));
}
return this._current;
}
/**
* @returns the value for the current server as set in {@link application_store}.
*/
@computed get current(): V {
return this.get(application_store.current_server);
private readonly get_value: (server: Server) => Promise<T>;
private _current?: Property<Promise<T>>;
constructor(get_value: (server: Server) => Promise<T>) {
this.get_value = memoize(get_value);
}
get(server: Server): Promise<T> {
return this.get_value(server);
}
observe_current(f: (current: T) => void, options?: { call_now?: boolean }): Disposable {
const seq_f = sequential(async ({ value }: { value: Promise<T> }) => f(await value));
return this.current.observe(seq_f, options);
}
}

View File

@ -1,41 +0,0 @@
.main:global(.Select > .Select-control) {
cursor: pointer;
background-color: var(--background-color);
color: var(--text-color);
height: 28px;
border-color: var(--input-border-color);
border-radius: 0;
}
.main:global(.Select .Select-control .Select-value .Select-value-label) {
color: white !important;
}
.main:global(.Select .Select-placeholder),
.main:global(.Select .Select--single > .Select-control .Select-value) {
line-height: 28px;
}
.main:global(.Select .Select-input) {
height: 26px;
}
.main:global(.Select:hover > .Select-control) {
border-color: var(--hover-color);
}
.main:global(.Select.is-focused > .Select-control) {
background-color: var(--background-color);
border-color: var(--hover-color);
}
.main:global(.Select.is-focused:not(.is-open) > .Select-control) {
background-color: var(--background-color);
border-color: var(--hover-color);
}
.main:global(.Select > .Select-menu-outer) {
margin-top: 0;
background-color: var(--background-color);
border-color: var(--border-color);
}

View File

@ -1,29 +0,0 @@
import React, { PureComponent, ReactNode } from "react";
import {
OptionValues,
ReactAsyncSelectProps,
ReactCreatableSelectProps,
ReactSelectProps,
} from "react-select";
import VirtualizedSelect, { AdditionalVirtualizedSelectProps } from "react-virtualized-select";
import styles from "./BigSelect.css";
/**
* Simply wraps {@link VirtualizedSelect} to provide consistent styling.
*/
export class BigSelect<TValue = OptionValues> extends PureComponent<
VirtualizedSelectProps<TValue>
> {
render(): ReactNode {
return <VirtualizedSelect className={styles.main} {...this.props} />;
}
}
// Copied from react-virtualized-select.
type VirtualizedSelectProps<TValue = OptionValues> =
| (ReactCreatableSelectProps<TValue> &
ReactAsyncSelectProps<TValue> &
AdditionalVirtualizedSelectProps<TValue> & { async: true })
| ReactCreatableSelectProps<TValue> &
ReactSelectProps<TValue> &
AdditionalVirtualizedSelectProps<TValue>;

View File

@ -1,98 +0,0 @@
.main {
/*
position: relative; necessary to avoid background and border disappearing while antd animates
dropdowns in Chrome. No idea why this prevents it...
*/
position: relative;
border: solid 1px var(--table-border-color);
background-color: var(--foreground-color);
}
.main * {
scrollbar-color: var(--table-scrollbar-thumb-color) var(--table-scrollbar-color);
}
.main ::-webkit-scrollbar {
background-color: var(--table-scrollbar-color);
}
.main ::-webkit-scrollbar-track {
background-color: var(--table-scrollbar-color);
}
.main ::-webkit-scrollbar-thumb {
background-color: var(--table-scrollbar-thumb-color);
}
.main ::-webkit-scrollbar-corner {
background-color: var(--table-scrollbar-color);
}
.header {
user-select: none;
background-color: hsl(0, 0%, 32%);
font-weight: bold;
}
.header .cell {
border-right: solid 1px var(--table-border-color);
}
.header .cell.sortable {
cursor: pointer;
}
.header .cell .sort_indictator {
fill: currentColor;
}
.cell {
display: flex;
align-items: center;
box-sizing: border-box;
padding: 0 5px;
border-bottom: solid 1px var(--table-border-color);
border-right: solid 1px hsl(0, 0%, 29%);
}
.cell.last_in_row {
border-right: solid 1px var(--table-border-color);
}
.cell:global(.number) {
justify-content: flex-end;
}
.cell.footer_cell {
font-weight: bold;
}
.cell.custom {
padding: 0;
}
.cell > .cell_text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell > :global(.ant-time-picker) {
/* Cover the default borders. */
margin: -1px;
height: calc(100% + 2px);
}
/* Make sure the glowing border is entirely visible. */
.cell > :global(.ant-time-picker):hover {
z-index: 10;
}
.cell > :global(.ant-time-picker) input {
height: 100%;
}
.no_result {
margin: 20px;
color: var(--text-color-disabled);
}

View File

@ -1,189 +0,0 @@
import React, { ReactNode, Component } from "react";
import {
GridCellRenderer,
Index,
MultiGrid,
SortDirectionType,
SortDirection,
} from "react-virtualized";
import styles from "./BigTable.css";
export interface Column<T> {
key?: string;
name: string;
width: number;
cell_renderer: (record: T) => ReactNode;
tooltip?: (record: T) => string;
footer_value?: string;
footer_tooltip?: string;
/**
* "number" has special meaning.
*/
class_name?: string;
sortable?: boolean;
}
export type ColumnSort<T> = { column: Column<T>; direction: SortDirectionType };
/**
* A table with a fixed header. Optionally has fixed columns and a footer.
* Uses windowing to support large amounts of rows and columns.
* TODO: no-content message.
*/
export class BigTable<T> extends Component<{
width: number;
height: number;
row_count: number;
overscan_row_count?: number;
columns: Column<T>[];
fixed_column_count?: number;
overscan_column_count?: number;
record: (index: Index) => T;
footer?: boolean;
/**
* When this changes, the DataTable will re-render.
*/
update_trigger?: any;
sort?: (sort_columns: ColumnSort<T>[]) => void;
}> {
private sort_columns = new Array<ColumnSort<T>>();
render(): ReactNode {
return (
<div
className={styles.main}
style={{ width: this.props.width, height: this.props.height }}
>
<MultiGrid
width={this.props.width}
height={this.props.height}
rowHeight={26}
rowCount={this.props.row_count + 1 + (this.props.footer ? 1 : 0)}
fixedRowCount={1}
overscanRowCount={this.props.overscan_row_count}
columnWidth={this.column_width}
columnCount={this.props.columns.length}
fixedColumnCount={this.props.fixed_column_count}
overscanColumnCount={this.props.overscan_column_count}
cellRenderer={this.cell_renderer}
classNameTopLeftGrid={styles.header}
classNameTopRightGrid={styles.header}
updateTigger={this.props.update_trigger}
/>
</div>
);
}
private column_width = ({ index }: Index): number => {
return this.props.columns[index].width;
};
private cell_renderer: GridCellRenderer = ({ columnIndex, rowIndex, style }): ReactNode => {
const column = this.props.columns[columnIndex];
let cell: ReactNode;
let sort_indicator: ReactNode;
let title: string | undefined;
const classes = [styles.cell];
if (columnIndex === this.props.columns.length - 1) {
classes.push(styles.last_in_row);
}
if (rowIndex === 0) {
// Header row
cell = title = column.name;
if (column.sortable) {
classes.push(styles.sortable);
const sort = this.sort_columns[0];
if (sort && sort.column === column) {
if (sort.direction === SortDirection.ASC) {
sort_indicator = (
<svg
className={styles.sort_indictator}
width="18"
height="18"
viewBox="0 0 24 24"
>
<path d="M7 14l5-5 5 5z" />
<path d="M0 0h24v24H0z" fill="none" />
</svg>
);
} else {
sort_indicator = (
<svg
className={styles.sort_indictator}
width="18"
height="18"
viewBox="0 0 24 24"
>
<path d="M7 10l5 5 5-5z" />
<path d="M0 0h24v24H0z" fill="none" />
</svg>
);
}
}
}
} else {
// Record or footer row
if (column.class_name) {
classes.push(column.class_name);
}
if (this.props.footer && rowIndex === 1 + this.props.row_count) {
// Footer row
classes.push(styles.footer_cell);
cell = column.footer_value == null ? "" : column.footer_value;
title = column.footer_tooltip == null ? "" : column.footer_tooltip;
} else {
// Record row
const result = this.props.record({ index: rowIndex - 1 });
cell = column.cell_renderer(result);
if (column.tooltip) {
title = column.tooltip(result);
}
}
}
if (typeof cell !== "string") {
classes.push(styles.custom);
}
const on_click =
rowIndex === 0 && column.sortable ? () => this.header_clicked(column) : undefined;
return (
<div
className={classes.join(" ")}
key={`${columnIndex}, ${rowIndex}`}
style={style}
title={title}
onClick={on_click}
>
{typeof cell === "string" ? <span className={styles.cell_text}>{cell}</span> : cell}
{sort_indicator}
</div>
);
};
private header_clicked = (column: Column<T>): void => {
const old_index = this.sort_columns.findIndex(sc => sc.column === column);
let old = old_index === -1 ? undefined : this.sort_columns.splice(old_index, 1)[0];
const direction =
old_index === 0 && old && old.direction === SortDirection.ASC
? SortDirection.DESC
: SortDirection.ASC;
this.sort_columns.unshift({ column, direction });
this.sort_columns.splice(10);
if (this.props.sort) {
this.props.sort(this.sort_columns);
}
};
}

View File

@ -1,8 +0,0 @@
import React, { Component, ReactNode } from "react";
import styles from "./DisabledTextComponent.css";
export class DisabledTextComponent extends Component<{ children: string }> {
render(): ReactNode {
return <div className={styles.main}>{this.props.children}</div>;
}
}

View File

@ -1,10 +0,0 @@
.main {
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
}
.main > * {
margin-top: 10%;
}

View File

@ -1,39 +0,0 @@
import { Alert } from "antd";
import React, { ReactNode, Component, ComponentType } from "react";
import styles from "./ErrorBoundary.css";
type State = { has_error: boolean };
export class ErrorBoundary extends Component<{}, State> {
state = {
has_error: false,
};
render(): ReactNode {
if (this.state.has_error) {
return (
<div className={styles.main}>
<div>
<Alert type="error" message="Something went wrong." />
</div>
</div>
);
} else {
return this.props.children;
}
}
static getDerivedStateFromError(): State {
return { has_error: true };
}
}
export function with_error_boundary<P>(Component: ComponentType<P>): ComponentType<P> {
const ComponentErrorBoundary = (props: P): JSX.Element => (
<ErrorBoundary>
<Component {...props} />
</ErrorBoundary>
);
ComponentErrorBoundary.displayName = `${Component.displayName}ErrorBoundary`;
return ComponentErrorBoundary;
}

View File

@ -1,24 +0,0 @@
import * as React from "react";
import { Component, ReactNode, FocusEvent } from "react";
import { InputNumber } from "antd";
export class NumberInput extends Component<{
value: number;
min?: number;
max?: number;
on_change?: (new_value?: number) => void;
on_blur?: (e: FocusEvent<HTMLInputElement>) => void;
}> {
render(): ReactNode {
return (
<InputNumber
value={this.props.value}
min={this.props.min}
max={this.props.max}
onChange={this.props.on_change}
onBlur={this.props.on_blur}
size="small"
/>
);
}
}

View File

@ -1,47 +0,0 @@
import React, { Component, ReactNode } from "react";
import { OrthographicCamera, PerspectiveCamera } from "three";
import { Renderer } from "../rendering/Renderer";
type Props = {
renderer: Renderer<PerspectiveCamera | OrthographicCamera>;
width: number;
height: number;
debug?: boolean;
on_will_unmount?: () => void;
};
export class RendererComponent extends Component<Props> {
render(): ReactNode {
return <div ref={this.modify_dom} />;
}
UNSAFE_componentWillReceiveProps(props: Props): void {
if (this.props.debug !== props.debug) {
this.props.renderer.debug = !!props.debug;
}
if (this.props.width !== props.width || this.props.height !== props.height) {
this.props.renderer.set_size(props.width, props.height);
}
}
componentDidMount(): void {
this.props.renderer.start_rendering();
}
componentWillUnmount(): void {
this.props.renderer.stop_rendering();
this.props.on_will_unmount && this.props.on_will_unmount();
}
shouldComponentUpdate(): boolean {
return false;
}
private modify_dom = (div: HTMLDivElement | null) => {
if (div) {
this.props.renderer.set_size(this.props.width, this.props.height);
div.appendChild(this.props.renderer.dom_element);
}
};
}

View File

@ -1,25 +0,0 @@
import React from "react";
import { SectionId } from "../domain";
export function SectionIdIcon({
section_id,
size = 28,
title,
}: {
section_id: SectionId;
size?: number;
title?: string;
}): JSX.Element {
return (
<div
title={title}
style={{
display: "inline-block",
width: size,
height: size,
backgroundImage: `url(${process.env.PUBLIC_URL}/images/sectionids/${SectionId[section_id]}.png)`,
backgroundSize: size,
}}
/>
);
}

View File

@ -1,23 +0,0 @@
import * as React from "react";
import { ChangeEvent, Component, FocusEvent, ReactNode } from "react";
import { Input } from "antd";
export class TextArea extends Component<{
value: string;
max_length: number;
rows: number;
on_change?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
on_blur?: (e: FocusEvent<HTMLTextAreaElement>) => void;
}> {
render(): ReactNode {
return (
<Input.TextArea
value={this.props.value}
maxLength={this.props.max_length}
rows={this.props.rows}
onChange={this.props.on_change}
onBlur={this.props.on_blur}
/>
);
}
}

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