- All views now have a View super type

- Widget now has a children array
- Widgets can be activated and deactivated (this recurses over child widgets)
- Renderers are now turned on and off in activate/deactivate methods
- It is now possible to set a tool-local path (this path is appended to the tool's base path)
- TabContainer can now automatically set a path based on paths given in its tab configuration
- It's now possible to directly link to subviews of the viewer and the hunt optimizer
This commit is contained in:
Daan Vanden Bosch 2020-01-05 01:07:35 +01:00
parent acaa51c28c
commit f87c2ecf84
62 changed files with 693 additions and 373 deletions

View File

@ -74,14 +74,19 @@ Features that are in ***bold italics*** are planned and not yet implemented.
## Events
- ***Event graph***
- ***Delete event***
- Event graph
- Add events
- Delete event
- ***Delete coupled NPCs if requested***
- ***Edit event section***
- ***Add parent-child relationship***
- ***Remove parent-child relationship***
- Edit event delay
### Event Actions
- ***Add/Delete***
- Add/Delete
- Lock/unlock doors
- ***Spawn NPCs***
## Script Object Code
@ -132,7 +137,7 @@ Features that are in ***bold italics*** are planned and not yet implemented.
- Start debugging by clicking "Debug" or pressing F5
- Stop debugging by clicking "Stop" or pressing Shift-F5
- Step with "Step over", "Step into" and "Step out"
- Step with "Step over" (F8), "Step into" (F7) and "Step out" (Shift-F8)
- Continue to next breakpoint with "Continue" (F6)
- Set breakpoints in the script editor
- Register viewer
@ -146,8 +151,9 @@ Features that are in ***bold italics*** are planned and not yet implemented.
## Bugs
- [3D View](#3d-view): Random Type Box 1 and Fixed Type Box objects aren't rendered correctly
- [3D View](#3d-view): Some objects are only partially loaded (they consist of several separate models)
- [3D View](#3d-view): Some objects are only partially loaded (they consist of several separate models), e.g.:
- Random Type Box 1
- Fixed Type Box
- Forest Switch
- Laser Fence
- Forest Laser
@ -155,3 +161,4 @@ Features that are in ***bold italics*** are planned and not yet implemented.
- Energy Barrier
- Teleporter
- [Load Quest](#load-quest): Can't parse quest 125 White Day
- [Script Assembly Editor](#script-assembly-editor): Go to definition doesn't work in RT (#231)

View File

@ -1,24 +1,26 @@
import { NavigationView } from "./NavigationView";
import { MainContentView } from "./MainContentView";
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { GuiStore, GuiTool } from "../../core/stores/GuiStore";
import { div } from "../../core/gui/dom";
import "./ApplicationView.css";
import { ResizableView } from "../../core/gui/ResizableView";
import { Widget } from "../../core/gui/Widget";
import { Resizable } from "../../core/gui/Resizable";
/**
* The top-level view which contains all other views.
*/
export class ApplicationView extends ResizableWidget {
export class ApplicationView extends ResizableView {
private menu_view: NavigationView;
private main_content_view: MainContentView;
readonly element: HTMLElement;
constructor(gui_store: GuiStore, tool_views: [GuiTool, () => Promise<ResizableWidget>][]) {
constructor(gui_store: GuiStore, tool_views: [GuiTool, () => Promise<Widget & Resizable>][]) {
super();
this.menu_view = this.disposable(new NavigationView(gui_store));
this.main_content_view = this.disposable(new MainContentView(gui_store, tool_views));
this.menu_view = this.add(new NavigationView(gui_store));
this.main_content_view = this.add(new MainContentView(gui_store, tool_views));
this.element = div(
{ className: "application_ApplicationView" },

View File

@ -1,32 +1,30 @@
import { GuiStore, GuiTool } from "../../core/stores/GuiStore";
import { LazyWidget } from "../../core/gui/LazyWidget";
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { ChangeEvent } from "../../core/observable/Observable";
import { div } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
import { Widget } from "../../core/gui/Widget";
import { Resizable } from "../../core/gui/Resizable";
export class MainContentView extends ResizableView {
private tool_views: Map<GuiTool, LazyWidget>;
private current_tool_view?: LazyWidget;
export class MainContentView extends ResizableWidget {
readonly element = div({ className: "application_MainContentView" });
private tool_views: Map<GuiTool, LazyWidget>;
constructor(gui_store: GuiStore, tool_views: [GuiTool, () => Promise<ResizableWidget>][]) {
constructor(gui_store: GuiStore, tool_views: [GuiTool, () => Promise<Widget & Resizable>][]) {
super();
this.tool_views = new Map(
tool_views.map(([tool, create_view]) => [
tool,
this.disposable(new LazyWidget(create_view)),
]),
tool_views.map(([tool, create_view]) => [tool, this.add(new LazyWidget(create_view))]),
);
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.disposables(
gui_store.tool.observe(({ value }) => this.set_current_tool(value), { call_now: true }),
);
this.finalize_construction();
}
@ -41,12 +39,17 @@ export class MainContentView extends ResizableWidget {
return this;
}
private tool_changed = ({ value: new_tool }: ChangeEvent<GuiTool>): void => {
for (const tool of this.tool_views.values()) {
tool.visible.val = false;
private set_current_tool(tool: GuiTool): void {
if (this.current_tool_view) {
this.current_tool_view.visible.val = false;
this.current_tool_view.deactivate();
}
const new_view = this.tool_views.get(new_tool);
if (new_view) new_view.visible.val = true;
};
this.current_tool_view = this.tool_views.get(tool);
if (this.current_tool_view) {
this.current_tool_view.visible.val = true;
this.current_tool_view.activate();
}
}
}

View File

@ -1,9 +1,9 @@
import { Widget } from "../../core/gui/Widget";
import { GuiTool } from "../../core/stores/GuiStore";
import "./NavigationButton.css";
import { input, label, span } from "../../core/gui/dom";
import { Control } from "../../core/gui/Control";
export class NavigationButton extends Widget {
export class NavigationButton extends Control {
readonly element = span({ className: "application_NavigationButton" });
private input: HTMLInputElement = input();

View File

@ -1,9 +1,9 @@
import { a, div, icon, Icon, span } from "../../core/gui/dom";
import "./NavigationView.css";
import { GuiStore, GuiTool } from "../../core/stores/GuiStore";
import { Widget } from "../../core/gui/Widget";
import { NavigationButton } from "./NavigationButton";
import { Select } from "../../core/gui/Select";
import { View } from "../../core/gui/View";
const TOOLS: [GuiTool, string][] = [
[GuiTool.Viewer, "Viewer"],
@ -11,11 +11,11 @@ const TOOLS: [GuiTool, string][] = [
[GuiTool.HuntOptimizer, "Hunt Optimizer"],
];
export class NavigationView extends Widget {
export class NavigationView extends View {
private readonly buttons = new Map<GuiTool, NavigationButton>(
TOOLS.map(([value, text]) => [value, this.disposable(new NavigationButton(value, text))]),
TOOLS.map(([value, text]) => [value, this.add(new NavigationButton(value, text))]),
);
private readonly server_select = this.disposable(
private readonly server_select = this.add(
new Select({
label: "Server:",
items: ["Ephinea"],
@ -57,15 +57,16 @@ export class NavigationView extends Widget {
this.element.style.height = `${this.height}px`;
this.element.onmousedown = this.mousedown;
this.mark_tool_button(gui_store.tool.val);
this.disposable(gui_store.tool.observe(({ value }) => this.mark_tool_button(value)));
this.disposables(
gui_store.tool.observe(({ value }) => this.mark_tool_button(value), { call_now: true }),
);
this.finalize_construction();
}
private mousedown = (e: MouseEvent): void => {
if (e.target instanceof HTMLLabelElement && e.target.control instanceof HTMLInputElement) {
this.gui_store.tool.val = (GuiTool as any)[e.target.control.value];
this.gui_store.set_tool((GuiTool as any)[e.target.control.value]);
}
};

View File

@ -90,6 +90,8 @@ export function initialize_application(
resize();
document.body.append(application_view.element);
application_view.activate();
disposer.add(disposable_listener(window, "resize", resize));
return {

View File

@ -1,5 +1,5 @@
import { LabelledControl, LabelledControlOptions } from "./LabelledControl";
import { Icon, icon, input, span } from "./dom";
import { bind_attr, Icon, icon, input, span } from "./dom";
import "./ComboBox.css";
import "./Input.css";
import { Menu } from "./Menu";
@ -90,14 +90,7 @@ export class ComboBox<T> extends LabelledControl {
};
const down_arrow_element = icon(Icon.TriangleDown);
this.bind_hidden(down_arrow_element, this.menu.visible);
const up_arrow_element = icon(Icon.TriangleUp);
this.bind_hidden(
up_arrow_element,
this.menu.visible.map(v => !v),
);
const button_element = span(
{ className: "core_ComboBox_button" },
down_arrow_element,
@ -128,6 +121,14 @@ export class ComboBox<T> extends LabelledControl {
this.selected.set_val(value, { silent: false });
this.input_element.focus();
}),
bind_attr(
up_arrow_element,
"hidden",
this.menu.visible.map(v => !v),
),
bind_attr(down_arrow_element, "hidden", this.menu.visible),
);
this.finalize_construction();

View File

@ -6,4 +6,6 @@ export type ControlOptions = WidgetOptions;
* Represents all widgets that allow for user interaction such as buttons, text inputs, combo boxes,
* etc.
*/
export abstract class Control extends Widget {}
export abstract class Control extends Widget {
readonly children: readonly Widget[] = [];
}

View File

@ -1,4 +0,0 @@
.core_ErrorView {
box-sizing: border-box;
padding: 10%;
}

View File

@ -1,19 +0,0 @@
import { ResizableWidget } from "./ResizableWidget";
import { UnavailableView } from "../../quest_editor/gui/UnavailableView";
import "./ErrorView.css";
import { div } from "./dom";
export class ErrorView extends ResizableWidget {
readonly element: HTMLElement;
constructor(message: string) {
super();
this.element = div(
{ className: "core_ErrorView" },
this.disposable(new UnavailableView(message)).element,
);
this.finalize_construction();
}
}

View File

@ -0,0 +1,10 @@
.core_ErrorWidget {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 10%;
text-align: center;
}

View File

@ -0,0 +1,22 @@
import { ResizableWidget } from "./ResizableWidget";
import "./ErrorWidget.css";
import { div } from "./dom";
import { Widget } from "./Widget";
import { Label } from "./Label";
export class ErrorWidget extends ResizableWidget {
private readonly label: Label;
readonly element = div({ className: "core_ErrorWidget" });
readonly children: readonly Widget[] = [];
constructor(message: string) {
super();
this.label = this.disposable(new Label(message, { enabled: false }));
this.element.append(this.label.element);
this.finalize_construction();
}
}

View File

@ -6,21 +6,20 @@ import { WidgetProperty } from "../observable/property/WidgetProperty";
import { label } from "./dom";
export class Label extends Widget {
private readonly _text = new WidgetProperty<string>(this, "", this.set_text);
readonly element = label({ className: "core_Label" });
readonly children: readonly Widget[] = [];
set for(id: string) {
this.element.htmlFor = id;
}
readonly text: WritableProperty<string>;
private readonly _text = new WidgetProperty<string>(this, "", this.set_text);
readonly text: WritableProperty<string> = this._text;
constructor(text: string | Property<string>, options?: WidgetOptions) {
super(options);
this.text = this._text;
if (typeof text === "string") {
this.set_text(text);
} else {

View File

@ -1,20 +1,34 @@
import { Widget } from "./Widget";
import { Resizable } from "./Resizable";
import { ResizableWidget } from "./ResizableWidget";
import { div } from "./dom";
import { Widget } from "./Widget";
import { Resizable } from "./Resizable";
export class LazyWidget extends ResizableWidget {
readonly element = div({ className: "core_LazyView" });
private initialized = false;
private view: (Widget & Resizable) | undefined;
readonly element = div({ className: "core_LazyView" });
get children(): readonly (Widget & Resizable)[] {
return this.view ? [this.view] : [];
}
constructor(private create_view: () => Promise<Widget & Resizable>) {
super();
this.visible.val = false;
}
resize(width: number, height: number): this {
super.resize(width, height);
if (this.view) {
this.view.resize(width, height);
}
return this;
}
protected set_visible(visible: boolean): void {
super.set_visible(visible);
@ -28,20 +42,11 @@ export class LazyWidget extends ResizableWidget {
this.view = this.disposable(view);
this.view.resize(this.width, this.height);
this.element.append(view.element);
this.view.activate();
}
});
}
this.finalize_construction();
}
resize(width: number, height: number): this {
super.resize(width, height);
if (this.view) {
this.view.resize(width, height);
}
return this;
}
}

View File

@ -14,6 +14,7 @@ export type MenuOptions<T> = {
export class Menu<T> extends Widget {
readonly element = div({ className: "core_Menu", tabIndex: -1 });
readonly children: readonly Widget[] = [];
readonly selected: WritableProperty<T | undefined>;
private readonly to_label: (item: T) => string;

View File

@ -1,9 +1,11 @@
import { ResizableWidget } from "./ResizableWidget";
import { Renderer } from "../rendering/Renderer";
import { div } from "./dom";
import { Widget } from "./Widget";
export class RendererWidget extends ResizableWidget {
readonly element = div({ className: "core_RendererWidget" });
readonly children: readonly Widget[] = [];
constructor(private renderer: Renderer) {
super();

View File

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

View File

@ -0,0 +1,14 @@
import { View } from "./View";
import { Resizable } from "./Resizable";
export abstract class ResizableView extends View implements Resizable {
protected width: number = 0;
protected height: number = 0;
resize(width: number, height: number): void {
this.width = width;
this.height = height;
this.element.style.width = `${width}px`;
this.element.style.height = `${height}px`;
}
}

View File

@ -5,11 +5,10 @@ export abstract class ResizableWidget extends Widget implements Resizable {
protected width: number = 0;
protected height: number = 0;
resize(width: number, height: number): this {
resize(width: number, height: number): void {
this.width = width;
this.height = height;
this.element.style.width = `${width}px`;
this.element.style.height = `${height}px`;
return this;
}
}

View File

@ -1,13 +1,15 @@
import { Widget, WidgetOptions } from "./Widget";
import { LazyWidget } from "./LazyWidget";
import { Resizable } from "./Resizable";
import { ResizableWidget } from "./ResizableWidget";
import "./TabContainer.css";
import { div, span } from "./dom";
import { GuiStore } from "../stores/GuiStore";
import { Resizable } from "./Resizable";
export type Tab = {
title: string;
key: string;
path?: string;
create_view: () => Promise<Widget & Resizable>;
};
@ -20,13 +22,18 @@ type TabInfo = Tab & { tab_element: HTMLSpanElement; lazy_view: LazyWidget };
const BAR_HEIGHT = 28;
export class TabContainer extends ResizableWidget {
readonly element = div({ className: "core_TabContainer" });
private tabs: TabInfo[] = [];
private bar_element = div({ className: "core_TabContainer_Bar" });
private panes_element = div({ className: "core_TabContainer_Panes" });
private active_tab?: TabInfo;
constructor(options: TabContainerOptions) {
readonly element = div({ className: "core_TabContainer" });
get children(): readonly Widget[] {
return this.tabs.flatMap(tab => tab.lazy_view.children);
}
constructor(private readonly gui_store: GuiStore, options: TabContainerOptions) {
super(options);
this.bar_element.onmousedown = this.bar_mousedown;
@ -43,19 +50,16 @@ export class TabContainer extends ResizableWidget {
const lazy_view = this.disposable(new LazyWidget(tab.create_view));
this.tabs.push({
const tab_info: TabInfo = {
...tab,
tab_element,
lazy_view,
});
};
this.tabs.push(tab_info);
this.panes_element.append(lazy_view.element);
}
if (this.tabs.length) {
this.activate(this.tabs[0].key);
}
this.element.append(this.bar_element, this.panes_element);
this.finalize_construction();
@ -79,24 +83,61 @@ export class TabContainer extends ResizableWidget {
return this;
}
activate(): void {
if (this.active_tab) {
this.activate_tab(this.active_tab);
} else {
let active_tab: TabInfo | undefined;
for (const tab_info of this.tabs) {
if (
tab_info.path != undefined &&
this.gui_store.path.val.startsWith(tab_info.path)
) {
active_tab = tab_info;
}
}
if (active_tab) {
this.activate_tab(active_tab);
} else if (this.tabs.length) {
this.activate_tab(this.tabs[0]);
}
}
}
private bar_mousedown = (e: MouseEvent): void => {
if (e.target instanceof HTMLElement) {
const key = e.target.dataset["key"];
if (key) this.activate(key);
if (key) this.activate_key(key);
}
};
private activate(key: string): void {
private activate_key(key: string): void {
for (const tab of this.tabs) {
const active = tab.key === key;
if (tab.key === key) {
this.activate_tab(tab);
break;
}
}
}
if (active) {
tab.tab_element.classList.add("active");
} else {
tab.tab_element.classList.remove("active");
private activate_tab(tab: TabInfo): void {
if (this.active_tab !== tab) {
if (this.active_tab) {
this.active_tab.tab_element.classList.remove("active");
this.active_tab.lazy_view.visible.val = false;
this.active_tab.lazy_view.deactivate();
}
tab.lazy_view.visible.val = active;
this.active_tab = tab;
tab.tab_element.classList.add("active");
tab.lazy_view.visible.val = true;
}
if (tab.path != undefined) {
this.gui_store.set_path_prefix(tab.path);
tab.lazy_view.activate();
}
}
}

View File

@ -36,13 +36,14 @@ export type TableOptions<T> = WidgetOptions & {
};
export class Table<T> extends Widget {
readonly element = table({ className: "core_Table" });
private readonly tbody_element = tbody();
private readonly footer_row_element?: HTMLTableRowElement;
private readonly values: ListProperty<T>;
private readonly columns: Column<T>[];
readonly element = table({ className: "core_Table" });
readonly children: readonly Widget[] = [];
constructor(options: TableOptions<T>) {
super(options);

View File

@ -4,10 +4,9 @@ import { LabelledControl } from "./LabelledControl";
import { div } from "./dom";
export class ToolBar extends Widget {
private readonly children: readonly Widget[];
readonly element = div({ className: "core_ToolBar" });
readonly height = 33;
readonly children: readonly Widget[];
constructor(options?: WidgetOptions, ...children: Widget[]) {
// noinspection SuspiciousTypeOfGuard

32
src/core/gui/View.ts Normal file
View File

@ -0,0 +1,32 @@
import { Widget } from "./Widget";
import { array_remove } from "../util";
export abstract class View extends Widget {
private readonly _children: Widget[] = [];
get children(): readonly Widget[] {
return this._children;
}
dispose(): void {
this._children.splice(0);
super.dispose();
}
/**
* Adds a child widget to the {@link _children} array and makes sure it is disposed when this
* widget is disposed.
*/
protected add<T extends Widget>(child: T): T {
this._children.push(child);
return this.disposable(child);
}
/**
* Removes a child widget from the {@link _children} array and disposes it.
*/
protected remove(child: Widget): void {
array_remove(this._children, child);
this.remove_disposable(child);
}
}

View File

@ -1,7 +1,5 @@
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";
@ -21,6 +19,9 @@ export type WidgetOptions = {
*/
export abstract class Widget implements Disposable {
private readonly disposer = new Disposer();
private _active = false;
private readonly _visible: WidgetProperty<boolean> = new WidgetProperty<boolean>(
this,
true,
@ -49,12 +50,32 @@ export abstract class Widget implements Disposable {
this.element.id = id;
}
/**
* An active widget might, for example, run an animation loop.
*/
get active(): boolean {
return this._active;
}
abstract readonly children: readonly Widget[];
get disposed(): boolean {
return this.disposer.disposed;
}
/**
* An invisible widget typically sets the hidden attribute on its {@link element}.
*/
readonly visible: WritableProperty<boolean> = this._visible;
/**
* A disabled widget typically sets the disabled attribute on its {@link element} and adds the
* `disabled` class to it.
*/
readonly enabled: WritableProperty<boolean> = this._enabled;
/**
* The {@link tooltip} property typically corresponds to the `tooltip` attribute of its
* {@link element}.
*/
readonly tooltip: WritableProperty<string> = this._tooltip;
protected constructor(options: WidgetOptions = {}) {
@ -71,15 +92,48 @@ export abstract class Widget implements Disposable {
}, 0);
}
/**
* Activate this widget. This call will also be propagated to the relevant children.
*/
activate(): void {
this._active = true;
for (const child of this.children) {
child.activate();
}
}
/**
* Deactivate this widget. This call will also be propagated to the relevant children.
*/
deactivate(): void {
this._active = false;
for (const child of this.children) {
child.deactivate();
}
}
/**
* Move focus to this widget.
*/
focus(): void {
this.element.focus();
}
/**
* Removes the widget's {@link element} from the DOM and disposes all its held disposables.
*/
dispose(): void {
this.element.remove();
this.disposer.dispose();
}
/**
* Every concrete subclass of {@link Widget} should call this method at the end of its
* constructor. When this method is called, we can refer to abstract properties that are
* provided by subclasses.
*/
protected finalize_construction(): void {
if (Object.getPrototypeOf(this) !== this.constructor.prototype) return;
@ -123,10 +177,6 @@ export abstract class Widget implements Disposable {
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);
}
@ -134,4 +184,8 @@ export abstract class Widget implements Disposable {
protected disposables(...disposables: Disposable[]): void {
this.disposer.add_all(...disposables);
}
protected remove_disposable(disposable: Disposable): void {
this.disposer.remove(disposable);
}
}

View File

@ -192,10 +192,6 @@ export function bind_attr<E extends Element, A extends keyof E>(
return observable.observe(({ value }) => (element[attribute] = value));
}
export function bind_hidden(element: HTMLElement, observable: Observable<boolean>): Disposable {
return bind_attr(element, "hidden", observable);
}
export enum Icon {
ArrowDown,
Eye,

View File

@ -1,5 +1,6 @@
import { Disposable } from "./Disposable";
import { LogManager } from "../Logger";
import { array_remove } from "../util";
const logger = LogManager.get("core/observable/Disposer");
@ -60,6 +61,14 @@ export class Disposer implements Disposable {
return this;
}
/**
* Removes and disposes the given disposable.
*/
remove(disposable: Disposable): void {
array_remove(this.disposables, disposable);
disposable.dispose();
}
/**
* Disposes all held disposables.
*/

View File

@ -10,6 +10,8 @@ import { Observable } from "./Observable";
import { FlatMappedProperty } from "./property/FlatMappedProperty";
import { ListProperty } from "./property/list/ListProperty";
import { FlatMappedListProperty } from "./property/list/FlatMappedListProperty";
import { Disposable } from "./Disposable";
import { Disposer } from "./Disposer";
export function emitter<E>(): Emitter<E> {
return new SimpleEmitter();
@ -34,6 +36,43 @@ export function sub(left: Property<number>, right: number): Property<number> {
return left.map(l => l - right);
}
export function observe<P1>(observer: (prop_1: P1) => void, prop_1: Property<P1>): Disposable;
export function observe<P1, P2>(
observer: (prop_1: P1, prop_2: P2) => void,
prop_1: Property<P1>,
prop_2: Property<P2>,
): Disposable;
export function observe<P1, P2, P3>(
observer: (prop_1: P1, prop_2: P2, prop_3: P3) => void,
prop_1: Property<P1>,
prop_2: Property<P2>,
prop_3: Property<P3>,
): Disposable;
export function observe<P1, P2, P3, P4>(
observer: (prop_1: P1, prop_2: P2, prop_3: P3, prop_4: P4) => void,
prop_1: Property<P1>,
prop_2: Property<P2>,
prop_3: Property<P3>,
prop_4: Property<P4>,
): Disposable;
export function observe<P1, P2, P3, P4, P5>(
observer: (prop_1: P1, prop_2: P2, prop_3: P3, prop_4: P4, prop_5: P5) => void,
prop_1: Property<P1>,
prop_2: Property<P2>,
prop_3: Property<P3>,
prop_4: Property<P4>,
prop_5: Property<P5>,
): Disposable;
export function observe(
observer: (...props: any[]) => void,
...props: Property<any>[]
): Disposable {
const observer_function = (prop: Property<any>): Disposable =>
prop.observe(() => observer(...props.map(p => p.val)));
return new Disposer(...props.map(observer_function));
}
export function map<R, P1>(transform: (prop_1: P1) => R, prop_1: Property<P1>): Property<R>;
export function map<R, P1, P2>(
transform: (prop_1: P1, prop_2: P2) => R,

View File

@ -72,8 +72,10 @@ export abstract class Renderer implements Disposable {
}
start_rendering(): void {
this.schedule_render();
this.animation_frame_handle = requestAnimationFrame(this.call_render);
if (this.animation_frame_handle == undefined) {
this.schedule_render();
this.animation_frame_handle = requestAnimationFrame(this.call_render);
}
}
stop_rendering(): void {

View File

@ -20,18 +20,25 @@ const GUI_TOOL_TO_STRING = new Map([
const STRING_TO_GUI_TOOL = new Map([...GUI_TOOL_TO_STRING.entries()].map(([k, v]) => [v, k]));
export class GuiStore extends Store {
private readonly _tool: WritableProperty<GuiTool> = property(GuiTool.Viewer);
private readonly _path: WritableProperty<string> = property("");
private readonly _server: WritableProperty<Server> = property(Server.Ephinea);
private readonly global_keydown_handlers = new Map<string, (e: KeyboardEvent) => void>();
private readonly features: Set<string> = new Set();
readonly tool: WritableProperty<GuiTool> = property(GuiTool.Viewer);
readonly tool: Property<GuiTool> = this._tool;
readonly path: Property<string> = this._path;
readonly server: Property<Server> = this._server;
constructor() {
super();
const url = window.location.hash.slice(2);
const [tool_str, params_str] = url.split("?");
const [full_path, params_str] = url.split("?");
const first_slash_idx = full_path.indexOf("/");
const tool_str = first_slash_idx === -1 ? full_path : full_path.slice(0, first_slash_idx);
const path = first_slash_idx === -1 ? "" : full_path.slice(first_slash_idx);
if (params_str) {
const features = params_str
@ -46,21 +53,35 @@ export class GuiStore extends Store {
}
}
this.disposables(
this.tool.observe(({ value: tool }) => {
let hash = `#/${gui_tool_to_string(tool)}`;
this.disposables(disposable_listener(window, "keydown", this.dispatch_global_keydown));
if (this.features.size) {
hash += "?features=" + [...this.features].join(",");
}
this.set_tool(string_to_gui_tool(tool_str) ?? GuiTool.Viewer, path);
}
window.location.hash = hash;
}),
set_tool(tool: GuiTool, path: string = ""): void {
this._path.val = path;
this._tool.val = tool;
this.update_location();
}
disposable_listener(window, "keydown", this.dispatch_global_keydown),
);
/**
* Updates the path to `path_prefix` if the current path doesn't start with `path_prefix`.
*/
set_path_prefix(path_prefix: string): void {
if (!this.path.val.startsWith(path_prefix)) {
this._path.val = path_prefix;
this.update_location();
}
}
this.tool.val = string_to_gui_tool(tool_str) || GuiTool.Viewer;
private update_location(): void {
let hash = `#/${gui_tool_to_string(this.tool.val)}${this.path.val}`;
if (this.features.size) {
hash += "?features=" + [...this.features].join(",");
}
window.location.hash = hash;
}
on_global_keydown(

View File

@ -14,6 +14,26 @@ export function arrays_equal<T>(
return true;
}
/**
* Removes 0 or more elements from `array`.
*
* @returns The number of removed elements.
*/
export function array_remove<T>(array: T[], ...elements: T[]): number {
let count = 0;
for (const element of elements) {
const index = array.indexOf(element);
if (index !== -1) {
array.splice(index, 1);
count++;
}
}
return count;
}
/**
* @param min - The minimum value, inclusive.
* @param max - The maximum value, exclusive.

View File

@ -1,8 +1,8 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import "./HelpView.css";
import { div, p } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
export class HelpView extends ResizableWidget {
export class HelpView extends ResizableView {
readonly element = div(
{ className: "hunt_optimizer_HelpView" },
p(

View File

@ -2,41 +2,65 @@ import { TabContainer } from "../../core/gui/TabContainer";
import { ServerMap } from "../../core/stores/ServerMap";
import { HuntOptimizerStore } from "../stores/HuntOptimizerStore";
import { HuntMethodStore } from "../stores/HuntMethodStore";
import { GuiStore } from "../../core/stores/GuiStore";
import { ResizableView } from "../../core/gui/ResizableView";
export class HuntOptimizerView extends ResizableView {
private readonly tab_container: TabContainer;
get element(): HTMLElement {
return this.tab_container.element;
}
export class HuntOptimizerView extends TabContainer {
constructor(
gui_store: GuiStore,
hunt_optimizer_stores: ServerMap<HuntOptimizerStore>,
hunt_method_stores: ServerMap<HuntMethodStore>,
) {
super({
class: "hunt_optimizer_HuntOptimizerView",
tabs: [
{
title: "Optimize",
key: "optimize",
create_view: async function() {
return new (await import("./OptimizerView")).OptimizerView(
hunt_optimizer_stores,
);
super();
this.tab_container = this.add(
new TabContainer(gui_store, {
class: "hunt_optimizer_HuntOptimizerView",
tabs: [
{
title: "Optimize",
key: "optimize",
path: "/optimize",
create_view: async function() {
return new (await import("./OptimizerView")).OptimizerView(
hunt_optimizer_stores,
);
},
},
},
{
title: "Methods",
key: "methods",
create_view: async function() {
return new (await import("./MethodsView")).MethodsView(hunt_method_stores);
{
title: "Methods",
key: "methods",
path: "/methods",
create_view: async function() {
return new (await import("./MethodsView")).MethodsView(
gui_store,
hunt_method_stores,
);
},
},
},
{
title: "Help",
key: "help",
create_view: async function() {
return new (await import("./HelpView")).HelpView();
{
title: "Help",
key: "help",
path: "/help",
create_view: async function() {
return new (await import("./HelpView")).HelpView();
},
},
},
],
});
],
}),
);
this.finalize_construction();
}
resize(width: number, height: number): void {
super.resize(width, height);
this.tab_container.resize(width, height);
}
}

View File

@ -1,4 +1,3 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
import { HuntMethodModel } from "../model/HuntMethodModel";
import {
@ -16,10 +15,11 @@ import { ServerMap } from "../../core/stores/ServerMap";
import { HuntMethodStore } from "../stores/HuntMethodStore";
import { LogManager } from "../../core/Logger";
import { div } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
const logger = LogManager.get("hunt_optimizer/gui/MethodsForEpisodeView");
export class MethodsForEpisodeView extends ResizableWidget {
export class MethodsForEpisodeView extends ResizableView {
readonly element = div({ className: "hunt_optimizer_MethodsForEpisodeView" });
private readonly episode: Episode;
@ -35,7 +35,7 @@ export class MethodsForEpisodeView extends ResizableWidget {
const hunt_methods = list_property<HuntMethodModel>();
const table = this.disposable(
const table = this.add(
new Table({
class: "hunt_optimizer_MethodsForEpisodeView_table",
values: hunt_methods,
@ -123,7 +123,7 @@ export class MethodsForEpisodeView extends ResizableWidget {
this.element.append(table.element);
this.disposable(
this.disposables(
hunt_method_stores.current.observe(
async ({ value }) => {
try {

View File

@ -3,15 +3,17 @@ import { Episode } from "../../core/data_formats/parsing/quest/Episode";
import { MethodsForEpisodeView } from "./MethodsForEpisodeView";
import { ServerMap } from "../../core/stores/ServerMap";
import { HuntMethodStore } from "../stores/HuntMethodStore";
import { GuiStore } from "../../core/stores/GuiStore";
export class MethodsView extends TabContainer {
constructor(hunt_method_stores: ServerMap<HuntMethodStore>) {
super({
constructor(gui_store: GuiStore, hunt_method_stores: ServerMap<HuntMethodStore>) {
super(gui_store, {
class: "hunt_optimizer_MethodsView",
tabs: [
{
title: "Episode I",
key: "episode_1",
path: "/methods/episode_1",
create_view: async function() {
return new MethodsForEpisodeView(hunt_method_stores, Episode.I);
},
@ -19,6 +21,7 @@ export class MethodsView extends TabContainer {
{
title: "Episode II",
key: "episode_2",
path: "/methods/episode_2",
create_view: async function() {
return new MethodsForEpisodeView(hunt_method_stores, Episode.II);
},
@ -26,6 +29,7 @@ export class MethodsView extends TabContainer {
{
title: "Episode IV",
key: "episode_4",
path: "/methods/episode_4",
create_view: async function() {
return new MethodsForEpisodeView(hunt_method_stores, Episode.IV);
},

View File

@ -1,4 +1,3 @@
import { Widget } from "../../core/gui/Widget";
import { div, h2, section_id_icon, span } from "../../core/gui/dom";
import { Column, Table } from "../../core/gui/Table";
import { Disposable } from "../../core/observable/Disposable";
@ -11,10 +10,11 @@ import { Duration } from "luxon";
import { ServerMap } from "../../core/stores/ServerMap";
import { HuntOptimizerStore } from "../stores/HuntOptimizerStore";
import { LogManager } from "../../core/Logger";
import { View } from "../../core/gui/View";
const logger = LogManager.get("hunt_optimizer/gui/OptimizationResultView");
export class OptimizationResultView extends Widget {
export class OptimizationResultView extends View {
readonly element = div(
{ className: "hunt_optimizer_OptimizationResultView" },
h2("Ideal Combination of Methods"),
@ -34,14 +34,16 @@ export class OptimizationResultView extends Widget {
if (this.disposed) return;
if (this.results_observer) {
this.results_observer.dispose();
this.remove_disposable(this.results_observer);
}
this.results_observer = hunt_optimizer_store.result.observe(
({ value }) => this.update_table(value),
{
call_now: true,
},
this.results_observer = this.disposable(
hunt_optimizer_store.result.observe(
({ value }) => this.update_table(value),
{
call_now: true,
},
),
);
} catch (e) {
logger.error("Couldn't load hunt optimizer store.", e);
@ -54,21 +56,9 @@ export class OptimizationResultView extends Widget {
this.finalize_construction();
}
dispose(): void {
super.dispose();
if (this.results_observer) {
this.results_observer.dispose();
}
if (this.table) {
this.table.dispose();
}
}
private update_table(result?: OptimalResultModel): void {
if (this.table) {
this.table.dispose();
this.remove(this.table);
}
let total_runs = 0;
@ -203,11 +193,15 @@ export class OptimizationResultView extends Widget {
}
}
this.table = new Table({
class: "hunt_optimizer_OptimizationResultView_table",
values: result ? list_property(undefined, ...result.optimal_methods) : list_property(),
columns,
});
this.table = this.add(
new Table<OptimalMethodModel>({
class: "hunt_optimizer_OptimizationResultView_table",
values: result
? list_property(undefined, ...result.optimal_methods)
: list_property(),
columns,
}),
);
this.element.append(this.table.element);
}

View File

@ -1,20 +1,20 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { WantedItemsView } from "./WantedItemsView";
import "./OptimizerView.css";
import { OptimizationResultView } from "./OptimizationResultView";
import { ServerMap } from "../../core/stores/ServerMap";
import { HuntOptimizerStore } from "../stores/HuntOptimizerStore";
import { div } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
export class OptimizerView extends ResizableWidget {
export class OptimizerView extends ResizableView {
readonly element = div({ className: "hunt_optimizer_OptimizerView" });
constructor(hunt_optimizer_stores: ServerMap<HuntOptimizerStore>) {
super();
this.element.append(
this.disposable(new WantedItemsView(hunt_optimizer_stores)).element,
this.disposable(new OptimizationResultView(hunt_optimizer_stores)).element,
this.add(new WantedItemsView(hunt_optimizer_stores)).element,
this.add(new OptimizationResultView(hunt_optimizer_stores)).element,
);
this.finalize_construction();

View File

@ -2,7 +2,6 @@ import { bind_children_to, div, h2, Icon, table, tbody, td, tr } from "../../cor
import "./WantedItemsView.css";
import { Button } from "../../core/gui/Button";
import { Disposer } from "../../core/observable/Disposer";
import { Widget } from "../../core/gui/Widget";
import { WantedItemModel } from "../model";
import { NumberInput } from "../../core/gui/NumberInput";
import { ComboBox } from "../../core/gui/ComboBox";
@ -12,22 +11,23 @@ import { Disposable } from "../../core/observable/Disposable";
import { ServerMap } from "../../core/stores/ServerMap";
import { HuntOptimizerStore } from "../stores/HuntOptimizerStore";
import { LogManager } from "../../core/Logger";
import { View } from "../../core/gui/View";
const logger = LogManager.get("hunt_optimizer/gui/WantedItemsView");
export class WantedItemsView extends Widget {
readonly element = div({ className: "hunt_optimizer_WantedItemsView" });
export class WantedItemsView extends View {
private readonly tbody_element = tbody();
private readonly store_disposer = this.disposable(new Disposer());
readonly element = div({ className: "hunt_optimizer_WantedItemsView" });
constructor(private readonly hunt_optimizer_stores: ServerMap<HuntOptimizerStore>) {
super();
const huntable_items = list_property<ItemType>();
const filtered_huntable_items = list_property<ItemType>();
const combo_box = this.disposable(
const combo_box = this.add(
new ComboBox({
items: filtered_huntable_items,
to_label: item_type => item_type.name,

View File

@ -32,7 +32,9 @@ export function initialize_hunt_optimizer(
),
);
const view = disposer.add(new HuntOptimizerView(hunt_optimizer_stores, hunt_method_stores));
const view = disposer.add(
new HuntOptimizerView(gui_store, hunt_optimizer_stores, hunt_method_stores),
);
return {
view,

View File

@ -1,4 +1,3 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { editor, KeyCode, KeyMod, Range } from "monaco-editor";
import { AsmEditorToolBar } from "./AsmEditorToolBar";
import { EditorHistory } from "./EditorHistory";
@ -8,6 +7,7 @@ import { GuiStore } from "../../core/stores/GuiStore";
import { AsmEditorStore } from "../stores/AsmEditorStore";
import { QuestRunner } from "../QuestRunner";
import { div } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
editor.defineTheme("phantasmal-world", {
@ -31,7 +31,7 @@ editor.defineTheme("phantasmal-world", {
const DUMMY_MODEL = editor.createModel("", "psoasm");
export class AsmEditorView extends ResizableWidget {
export class AsmEditorView extends ResizableView {
private readonly tool_bar_view: AsmEditorToolBar;
private readonly editor: IStandaloneCodeEditor;
private readonly history: EditorHistory;
@ -48,7 +48,7 @@ export class AsmEditorView extends ResizableWidget {
) {
super();
this.tool_bar_view = this.disposable(new AsmEditorToolBar(asm_editor_store));
this.tool_bar_view = this.add(new AsmEditorToolBar(asm_editor_store));
this.element.append(this.tool_bar_view.element);

View File

@ -1,12 +1,12 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { bind_attr, div, table, td, th, tr } from "../../core/gui/dom";
import { UnavailableView } from "./UnavailableView";
import "./EntityInfoView.css";
import { NumberInput } from "../../core/gui/NumberInput";
import { rad_to_deg } from "../../core/math";
import { EntityInfoController } from "../controllers/EntityInfoController";
import { ResizableView } from "../../core/gui/ResizableView";
export class EntityInfoView extends ResizableWidget {
export class EntityInfoView extends ResizableView {
readonly element = div({ className: "quest_editor_EntityInfoView", tabIndex: -1 });
private readonly no_entity_view = new UnavailableView("No entity selected.");
@ -18,12 +18,12 @@ export class EntityInfoView extends ResizableWidget {
private readonly section_id_element: HTMLTableCellElement;
private readonly wave_element: HTMLTableCellElement;
private readonly wave_row_element: HTMLTableRowElement;
private readonly pos_x_element = this.disposable(new NumberInput(0, { round_to: 3 }));
private readonly pos_y_element = this.disposable(new NumberInput(0, { round_to: 3 }));
private readonly pos_z_element = this.disposable(new NumberInput(0, { round_to: 3 }));
private readonly rot_x_element = this.disposable(new NumberInput(0, { round_to: 3 }));
private readonly rot_y_element = this.disposable(new NumberInput(0, { round_to: 3 }));
private readonly rot_z_element = this.disposable(new NumberInput(0, { round_to: 3 }));
private readonly pos_x_element = this.add(new NumberInput(0, { round_to: 3 }));
private readonly pos_y_element = this.add(new NumberInput(0, { round_to: 3 }));
private readonly pos_z_element = this.add(new NumberInput(0, { round_to: 3 }));
private readonly rot_x_element = this.add(new NumberInput(0, { round_to: 3 }));
private readonly rot_y_element = this.add(new NumberInput(0, { round_to: 3 }));
private readonly rot_z_element = this.add(new NumberInput(0, { round_to: 3 }));
constructor(private readonly ctrl: EntityInfoController) {
super();

View File

@ -1,4 +1,3 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { bind_children_to, div, img, span } from "../../core/gui/dom";
import "./EntityListView.css";
import { entity_data, EntityType } from "../../core/data_formats/parsing/quest/entities";
@ -7,8 +6,9 @@ import { WritableListProperty } from "../../core/observable/property/list/Writab
import { list_property } from "../../core/observable";
import { QuestEditorStore } from "../stores/QuestEditorStore";
import { EntityImageRenderer } from "../rendering/EntityImageRenderer";
import { ResizableView } from "../../core/gui/ResizableView";
export abstract class EntityListView<T extends EntityType> extends ResizableWidget {
export abstract class EntityListView<T extends EntityType> extends ResizableView {
readonly element: HTMLElement;
protected readonly entities: WritableListProperty<T> = list_property();

View File

@ -1,4 +1,3 @@
import { Widget } from "../../core/gui/Widget";
import { bind_children_to, div } from "../../core/gui/dom";
import { QuestEventDagModel, QuestEventDagModelChangeType } from "../model/QuestEventDagModel";
import { QuestEventModel } from "../model/QuestEventModel";
@ -14,13 +13,14 @@ import {
} from "../../core/observable/property/list/ListProperty";
import { WritableProperty } from "../../core/observable/property/WritableProperty";
import { LogManager } from "../../core/Logger";
import { View } from "../../core/gui/View";
const logger = LogManager.get("quest_editor/gui/EventSubGraphView");
const EDGE_HORIZONTAL_SPACING = 8;
const EDGE_VERTICAL_SPACING = 20;
export class EventSubGraphView extends Widget {
export class EventSubGraphView extends View {
/**
* Maps event IDs to GUI data.
*/

View File

@ -1,4 +1,3 @@
import { Widget } from "../../core/gui/Widget";
import {
bind_attr,
bind_children_to,
@ -23,8 +22,9 @@ import { Disposer } from "../../core/observable/Disposer";
import { property } from "../../core/observable";
import { DropDown } from "../../core/gui/DropDown";
import { Button } from "../../core/gui/Button";
import { View } from "../../core/gui/View";
export class EventView extends Widget {
export class EventView extends View {
private readonly inputs_enabled = property(true);
private readonly delay_input: NumberInput;
@ -34,11 +34,11 @@ export class EventView extends Widget {
super();
const wave_node = document.createTextNode(event.wave.id.val.toString());
this.delay_input = this.disposable(
this.delay_input = this.add(
new NumberInput(event.delay.val, { min: 0, step: 1, enabled: this.inputs_enabled }),
);
const action_table = table({ className: "quest_editor_EventView_actions" });
const add_action_dropdown: DropDown<QuestEventActionType> = this.disposable(
const add_action_dropdown: DropDown<QuestEventActionType> = this.add(
new DropDown({
text: "Add action",
items: QuestEventActionTypes,

View File

@ -1,4 +1,3 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import "./EventsView.css";
import { EventsController } from "../controllers/EventsController";
import { UnavailableView } from "./UnavailableView";
@ -13,8 +12,9 @@ import {
ListProperty,
} from "../../core/observable/property/list/ListProperty";
import { QuestEventModel } from "../model/QuestEventModel";
import { ResizableView } from "../../core/gui/ResizableView";
export class EventsView extends ResizableWidget {
export class EventsView extends ResizableView {
private readonly sub_graph_views: Map<
ListProperty<QuestEventModel>,
EventSubGraphView
@ -37,9 +37,8 @@ export class EventsView extends ResizableWidget {
{ className: "quest_editor_EventsView", tabIndex: -1 },
(this.container_element = div(
{ className: "quest_editor_EventsView_container" },
this.disposable(
new ToolBar((this.add_event_button = new Button({ text: "Add event" }))),
).element,
this.add(new ToolBar((this.add_event_button = new Button({ text: "Add event" }))))
.element,
(this.dag_container_element = div({
className: "quest_editor_EventsView_sub_graph_container",
})),

View File

@ -1,14 +1,14 @@
import { bind_children_to, div } from "../../core/gui/dom";
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { ToolBar } from "../../core/gui/ToolBar";
import "./LogView.css";
import { log_store } from "../stores/LogStore";
import { Select } from "../../core/gui/Select";
import { LogEntry, LogLevel, LogLevels, time_to_string } from "../../core/Logger";
import { ResizableView } from "../../core/gui/ResizableView";
const AUTOSCROLL_TRESHOLD = 5;
export class LogView extends ResizableWidget {
export class LogView extends ResizableView {
readonly element = div({ className: "quest_editor_LogView", tabIndex: -1 });
// container is needed to get a scrollbar in the right place
@ -26,7 +26,7 @@ export class LogView extends ResizableWidget {
this.list_container = div({ className: "quest_editor_LogView_list_container" });
this.list_element = div({ className: "quest_editor_LogView_message_list" });
this.level_filter = this.disposable(
this.level_filter = this.add(
new Select({
class: "quest_editor_LogView_level_filter",
label: "Level:",
@ -35,7 +35,7 @@ export class LogView extends ResizableWidget {
}),
);
this.settings_bar = this.disposable(
this.settings_bar = this.add(
new ToolBar({ class: "quest_editor_LogView_settings" }, this.level_filter),
);

View File

@ -1,15 +1,15 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { bind_attr, div, table, td, th, tr } from "../../core/gui/dom";
import "./NpcCountsView.css";
import { UnavailableView } from "./UnavailableView";
import { NameWithCount, NpcCountsController } from "../controllers/NpcCountsController";
import { ResizableView } from "../../core/gui/ResizableView";
export class NpcCountsView extends ResizableWidget {
export class NpcCountsView extends ResizableView {
readonly element = div({ className: "quest_editor_NpcCountsView" });
private readonly table_element = table();
private readonly unavailable_view = new UnavailableView("No quest loaded.");
private readonly unavailable_view = this.add(new UnavailableView("No quest loaded."));
constructor(ctrl: NpcCountsController) {
super();

View File

@ -3,7 +3,6 @@ import { QuestEditorStore } from "../stores/QuestEditorStore";
import { QuestEditor3DModelManager } from "../rendering/QuestEditor3DModelManager";
import { QuestRendererView } from "./QuestRendererView";
import { QuestEntityControls } from "../rendering/QuestEntityControls";
import { GuiStore } from "../../core/stores/GuiStore";
import { AreaAssetLoader } from "../loading/AreaAssetLoader";
import { EntityAssetLoader } from "../loading/EntityAssetLoader";
import { DisposableThreeRenderer } from "../../core/rendering/Renderer";
@ -12,14 +11,12 @@ export class QuestEditorRendererView extends QuestRendererView {
private readonly entity_controls: QuestEntityControls;
constructor(
gui_store: GuiStore,
quest_editor_store: QuestEditorStore,
area_asset_loader: AreaAssetLoader,
entity_asset_loader: EntityAssetLoader,
three_renderer: DisposableThreeRenderer,
) {
super(
gui_store,
quest_editor_store,
"quest_editor_QuestEditorRendererView",
new QuestRenderer(

View File

@ -1,4 +1,3 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { QuestEditorToolBar } from "./QuestEditorToolBar";
import GoldenLayout, { Container, ContentItem, ItemConfigType } from "golden-layout";
import { QuestInfoView } from "./QuestInfoView";
@ -18,8 +17,11 @@ import { QuestRunnerRendererView } from "./QuestRunnerRendererView";
import { QuestEditorStore } from "../stores/QuestEditorStore";
import { QuestEditorUiPersister } from "../persistence/QuestEditorUiPersister";
import { LogManager } from "../../core/Logger";
import { ErrorView } from "../../core/gui/ErrorView";
import { ErrorWidget } from "../../core/gui/ErrorWidget";
import { div } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
import { Widget } from "../../core/gui/Widget";
import { Resizable } from "../../core/gui/Resizable";
const logger = LogManager.get("quest_editor/gui/QuestEditorView");
@ -40,22 +42,22 @@ const DEFAULT_LAYOUT_CONFIG = {
},
};
export class QuestEditorView extends ResizableWidget {
export class QuestEditorView extends ResizableView {
readonly element = div({ className: "quest_editor_QuestEditorView" });
/**
* Maps views to names and creation functions.
*/
private readonly view_map: Map<
new (...args: never) => ResizableWidget,
{ name: string; create(): ResizableWidget }
new (...args: never) => Widget & Resizable,
{ name: string; create(): Widget & Resizable }
>;
private readonly layout_element = div({ className: "quest_editor_gl_container" });
private readonly layout: Promise<GoldenLayout>;
private loaded_layout: GoldenLayout | undefined;
private readonly sub_views = new Map<string, ResizableWidget>();
private readonly sub_views = new Map<string, Widget & Resizable>();
constructor(
private readonly gui_store: GuiStore,
@ -77,8 +79,8 @@ export class QuestEditorView extends ResizableWidget {
// Don't change the values of this map, as they are persisted in the user's browser.
this.view_map = new Map<
new (...args: never) => ResizableWidget,
{ name: string; create(): ResizableWidget }
new (...args: never) => Widget & Resizable,
{ name: string; create(): Widget & Resizable }
>([
[
QuestInfoView,
@ -185,6 +187,22 @@ export class QuestEditorView extends ResizableWidget {
this.finalize_construction();
}
activate(): void {
super.activate();
for (const sub_view of this.sub_views.values()) {
sub_view.activate();
}
}
deactivate(): void {
for (const sub_view of this.sub_views.values()) {
sub_view.deactivate();
}
super.deactivate();
}
resize(width: number, height: number): this {
super.resize(width, height);
@ -239,21 +257,25 @@ export class QuestEditorView extends ResizableWidget {
private attempt_gl_init(config: GoldenLayout.Config): GoldenLayout {
const layout = new GoldenLayout(config, this.layout_element);
const sub_views = this.sub_views;
const self = this; // eslint-disable-line @typescript-eslint/no-this-alias
try {
for (const { name, create } of this.view_map.values()) {
// registerComponent expects a regular function and not an arrow function. This
// function will be called with new.
layout.registerComponent(name, function(container: Container) {
let view: ResizableWidget;
let view: Widget & Resizable;
try {
view = create();
if (self.active) {
view.activate();
}
} catch (e) {
logger.error(`Couldn't instantiate "${name}".`, e);
view = new ErrorView("Something went wrong while creating this window.");
view = new ErrorWidget("Something went wrong while creating this window.");
}
container.on("close", () => view.dispose());
@ -265,7 +287,7 @@ export class QuestEditorView extends ResizableWidget {
view.resize(container.width, container.height);
sub_views.set(name, view);
self.sub_views.set(name, view);
container.getElement().append(view.element);
});
}

View File

@ -1,4 +1,3 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
import { NumberInput } from "../../core/gui/NumberInput";
import { Disposer } from "../../core/observable/Disposer";
@ -7,20 +6,21 @@ import { TextArea } from "../../core/gui/TextArea";
import "./QuestInfoView.css";
import { UnavailableView } from "./UnavailableView";
import { QuestInfoController } from "../controllers/QuestInfoController";
import { div, table, td, th, tr } from "../../core/gui/dom";
import { bind_attr, div, table, td, th, tr } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
export class QuestInfoView extends ResizableWidget {
export class QuestInfoView extends ResizableView {
readonly element = div({ className: "quest_editor_QuestInfoView", tabIndex: -1 });
private readonly table_element = table();
private readonly episode_element: HTMLElement;
private readonly id_input = this.disposable(new NumberInput(0, { min: 0, step: 1 }));
private readonly name_input = this.disposable(
private readonly id_input = this.add(new NumberInput(0, { min: 0, step: 1 }));
private readonly name_input = this.add(
new TextInput("", {
max_length: 32,
}),
);
private readonly short_description_input = this.disposable(
private readonly short_description_input = this.add(
new TextArea("", {
max_length: 128,
font_family: '"Courier New", monospace',
@ -28,7 +28,7 @@ export class QuestInfoView extends ResizableWidget {
rows: 5,
}),
);
private readonly long_description_input = this.disposable(
private readonly long_description_input = this.add(
new TextArea("", {
max_length: 288,
font_family: '"Courier New", monospace',
@ -37,7 +37,7 @@ export class QuestInfoView extends ResizableWidget {
}),
);
private readonly unavailable_view = new UnavailableView("No quest loaded.");
private readonly unavailable_view = this.add(new UnavailableView("No quest loaded."));
private readonly quest_disposer = this.disposable(new Disposer());
@ -56,8 +56,6 @@ export class QuestInfoView extends ResizableWidget {
tr(td({ colSpan: 2 }, this.long_description_input.element)),
);
this.bind_hidden(this.table_element, ctrl.unavailable);
this.element.append(this.table_element, this.unavailable_view.element);
this.element.addEventListener("focus", ctrl.focused, true);
@ -65,6 +63,8 @@ export class QuestInfoView extends ResizableWidget {
this.disposables(
this.unavailable_view.visible.bind_to(ctrl.unavailable),
bind_attr(this.table_element, "hidden", ctrl.unavailable),
quest.observe(({ value: q }) => {
this.quest_disposer.dispose_all();

View File

@ -1,11 +1,10 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { RendererWidget } from "../../core/gui/RendererWidget";
import { QuestRenderer } from "../rendering/QuestRenderer";
import { GuiStore, GuiTool } from "../../core/stores/GuiStore";
import { QuestEditorStore } from "../stores/QuestEditorStore";
import { div } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
export abstract class QuestRendererView extends ResizableWidget {
export abstract class QuestRendererView extends ResizableView {
private readonly renderer_view: RendererWidget;
protected readonly renderer: QuestRenderer;
@ -13,7 +12,6 @@ export abstract class QuestRendererView extends ResizableWidget {
readonly element: HTMLElement;
protected constructor(
gui_store: GuiStore,
quest_editor_store: QuestEditorStore,
className: string,
renderer: QuestRenderer,
@ -22,25 +20,26 @@ export abstract class QuestRendererView extends ResizableWidget {
this.element = div({ className: className, tabIndex: -1 });
this.renderer = renderer;
this.renderer_view = this.disposable(new RendererWidget(this.renderer));
this.renderer_view = this.add(new RendererWidget(this.renderer));
this.element.append(this.renderer_view.element);
this.renderer_view.start_rendering();
this.disposables(
gui_store.tool.observe(({ value: tool }) => {
if (tool === GuiTool.QuestEditor) {
this.renderer_view.start_rendering();
} else {
this.renderer_view.stop_rendering();
}
}),
quest_editor_store.debug.observe(({ value }) => (this.renderer.debug = value)),
);
this.finalize_construction();
}
activate(): void {
this.renderer_view.start_rendering();
super.activate();
}
deactivate(): void {
super.deactivate();
this.renderer_view.stop_rendering();
}
resize(width: number, height: number): this {
super.resize(width, height);

View File

@ -1,7 +1,6 @@
import { QuestRenderer } from "../rendering/QuestRenderer";
import { QuestRunner3DModelManager } from "../rendering/QuestRunner3DModelManager";
import { QuestRendererView } from "./QuestRendererView";
import { GuiStore } from "../../core/stores/GuiStore";
import { QuestEditorStore } from "../stores/QuestEditorStore";
import { AreaAssetLoader } from "../loading/AreaAssetLoader";
import { EntityAssetLoader } from "../loading/EntityAssetLoader";
@ -9,14 +8,12 @@ import { DisposableThreeRenderer } from "../../core/rendering/Renderer";
export class QuestRunnerRendererView extends QuestRendererView {
constructor(
gui_store: GuiStore,
quest_editor_store: QuestEditorStore,
area_asset_loader: AreaAssetLoader,
entity_asset_loader: EntityAssetLoader,
three_renderer: DisposableThreeRenderer,
) {
super(
gui_store,
quest_editor_store,
"quest_editor_QuestRunnerRendererView",
new QuestRenderer(

View File

@ -1,4 +1,3 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { REGISTER_COUNT } from "../scripting/vm/VirtualMachine";
import { TextInput } from "../../core/gui/TextInput";
import { ToolBar } from "../../core/gui/ToolBar";
@ -8,6 +7,7 @@ import "./RegistersView.css";
import { Select } from "../../core/gui/Select";
import { QuestRunner } from "../QuestRunner";
import { div } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
enum RegisterDisplayType {
Signed,
@ -19,8 +19,8 @@ enum RegisterDisplayType {
type RegisterGetterFunction = (register: number) => number;
export class RegistersView extends ResizableWidget {
private readonly type_select = this.disposable(
export class RegistersView extends ResizableView {
private readonly type_select = this.add(
new Select({
label: "Display type:",
tooltip: "Select which data type register values should be displayed as.",
@ -38,16 +38,14 @@ export class RegistersView extends ResizableWidget {
RegisterDisplayType.Signed,
);
private readonly hex_checkbox = this.disposable(
private readonly hex_checkbox = this.add(
new CheckBox(false, {
label: "Hex",
tooltip: "Display register values in hexadecimal.",
}),
);
private readonly settings_bar = this.disposable(
new ToolBar(this.type_select, this.hex_checkbox),
);
private readonly settings_bar = this.add(new ToolBar(this.type_select, this.hex_checkbox));
private readonly register_els: TextInput[];
private readonly list_element = div({ className: "quest_editor_RegistersView_list" });
@ -70,7 +68,7 @@ export class RegistersView extends ResizableWidget {
// create register elements
const register_els: TextInput[] = Array(REGISTER_COUNT);
for (let i = 0; i < REGISTER_COUNT; i++) {
const value_el = this.disposable(
const value_el = this.add(
new TextInput("", {
class: "quest_editor_RegistersView_value",
label: `r${i}:`,

View File

@ -1,12 +1,12 @@
import { Widget } from "../../core/gui/Widget";
import { Label } from "../../core/gui/Label";
import "./UnavailableView.css";
import { div } from "../../core/gui/dom";
import { View } from "../../core/gui/View";
/**
* Used to show that a view exists but is unavailable at the moment.
*/
export class UnavailableView extends Widget {
export class UnavailableView extends View {
readonly element = div({ className: "quest_editor_UnavailableView" });
private readonly label: Label;

View File

@ -69,7 +69,6 @@ export function initialize_quest_editor(
() => new NpcCountsView(disposer.add(new NpcCountsController(quest_editor_store))),
() =>
new QuestEditorRendererView(
gui_store,
quest_editor_store,
area_asset_loader,
entity_asset_loader,
@ -82,7 +81,6 @@ export function initialize_quest_editor(
() => new EventsView(disposer.add(new EventsController(quest_editor_store))),
() =>
new QuestRunnerRendererView(
gui_store,
quest_editor_store,
area_asset_loader,
entity_asset_loader,

View File

@ -1,14 +1,13 @@
import { div, Icon } from "../../core/gui/dom";
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { FileButton } from "../../core/gui/FileButton";
import { ToolBar } from "../../core/gui/ToolBar";
import { RendererWidget } from "../../core/gui/RendererWidget";
import { TextureRenderer } from "../rendering/TextureRenderer";
import { GuiStore, GuiTool } from "../../core/stores/GuiStore";
import { TextureStore } from "../stores/TextureStore";
import { DisposableThreeRenderer } from "../../core/rendering/Renderer";
import { ResizableView } from "../../core/gui/ResizableView";
export class TextureView extends ResizableWidget {
export class TextureView extends ResizableView {
readonly element = div({ className: "viewer_TextureView" });
private readonly open_file_button = new FileButton("Open file...", {
@ -16,44 +15,38 @@ export class TextureView extends ResizableWidget {
accept: ".afs, .xvm",
});
private readonly tool_bar = this.disposable(new ToolBar(this.open_file_button));
private readonly tool_bar = this.add(new ToolBar(this.open_file_button));
private readonly renderer_view: RendererWidget;
constructor(
gui_store: GuiStore,
texture_store: TextureStore,
three_renderer: DisposableThreeRenderer,
) {
constructor(texture_store: TextureStore, three_renderer: DisposableThreeRenderer) {
super();
this.renderer_view = this.disposable(
this.renderer_view = this.add(
new RendererWidget(new TextureRenderer(three_renderer, texture_store)),
);
this.element.append(this.tool_bar.element, this.renderer_view.element);
this.disposable(
this.disposables(
this.open_file_button.files.observe(({ value: files }) => {
if (files.length) texture_store.load_file(files[0]);
}),
);
this.renderer_view.start_rendering();
this.disposables(
gui_store.tool.observe(({ value: tool }) => {
if (tool === GuiTool.Viewer) {
this.renderer_view.start_rendering();
} else {
this.renderer_view.stop_rendering();
}
}),
);
this.finalize_construction();
}
activate(): void {
this.renderer_view.start_rendering();
super.activate();
}
deactivate(): void {
super.deactivate();
this.renderer_view.stop_rendering();
}
resize(width: number, height: number): this {
super.resize(width, height);

View File

@ -1,28 +1,47 @@
import { TabContainer } from "../../core/gui/TabContainer";
import { Model3DView } from "./model_3d/Model3DView";
import { TextureView } from "./TextureView";
import { ResizableView } from "../../core/gui/ResizableView";
import { GuiStore } from "../../core/stores/GuiStore";
export class ViewerView extends ResizableView {
private readonly tab_container: TabContainer;
get element(): HTMLElement {
return this.tab_container.element;
}
export class ViewerView extends TabContainer {
constructor(
gui_store: GuiStore,
create_model_3d_view: () => Promise<Model3DView>,
create_texture_view: () => Promise<TextureView>,
) {
super({
class: "viewer_ViewerView",
tabs: [
{
title: "Models",
key: "model",
create_view: create_model_3d_view,
},
{
title: "Textures",
key: "texture",
create_view: create_texture_view,
},
],
});
super();
this.tab_container = this.add(
new TabContainer(gui_store, {
class: "viewer_ViewerView",
tabs: [
{
title: "Models",
key: "models",
path: "/models",
create_view: create_model_3d_view,
},
{
title: "Textures",
key: "textures",
path: "/textures",
create_view: create_texture_view,
},
],
}),
);
this.finalize_construction();
}
resize(width: number, height: number): void {
this.tab_container.resize(width, height);
}
}

View File

@ -1,9 +1,9 @@
import { ResizableWidget } from "../../../core/gui/ResizableWidget";
import "./Model3DSelectListView.css";
import { Property } from "../../../core/observable/property/Property";
import { li, ul } from "../../../core/gui/dom";
import { ResizableView } from "../../../core/gui/ResizableView";
export class Model3DSelectListView<T extends { name: string }> extends ResizableWidget {
export class Model3DSelectListView<T extends { name: string }> extends ResizableView {
readonly element = ul({ className: "viewer_Model3DSelectListView" });
set borders(borders: boolean) {
@ -32,7 +32,7 @@ export class Model3DSelectListView<T extends { name: string }> extends Resizable
this.element.append(li({ data: { index: index.toString() } }, model.name));
});
this.disposable(
this.disposables(
selected.observe(
({ value: model }) => {
if (this.selected_element) {

View File

@ -6,9 +6,22 @@ import { PSO_FRAME_RATE } from "../../../core/rendering/conversion/ninja_animati
import { Label } from "../../../core/gui/Label";
import { Icon } from "../../../core/gui/dom";
import { Model3DStore } from "../../stores/Model3DStore";
import { View } from "../../../core/gui/View";
export class Model3DToolBarView extends View {
private readonly toolbar: ToolBar;
get element(): HTMLElement {
return this.toolbar.element;
}
get height(): number {
return this.toolbar.height;
}
export class Model3DToolBar extends ToolBar {
constructor(model_3d_store: Model3DStore) {
super();
const open_file_button = new FileButton("Open file...", {
icon_left: Icon.File,
accept: ".afs, .nj, .njm, .xj, .xvm",
@ -31,13 +44,15 @@ export class Model3DToolBar extends ToolBar {
model_3d_store.animation_frame_count.map(count => `/ ${count}`),
);
super(
open_file_button,
skeleton_checkbox,
play_animation_checkbox,
animation_frame_rate_input,
animation_frame_input,
animation_frame_count_label,
this.toolbar = this.add(
new ToolBar(
open_file_button,
skeleton_checkbox,
play_animation_checkbox,
animation_frame_rate_input,
animation_frame_input,
animation_frame_count_label,
),
);
// Always-enabled controls.

View File

@ -1,50 +1,50 @@
import { ResizableWidget } from "../../../core/gui/ResizableWidget";
import "./Model3DView.css";
import { GuiStore, GuiTool } from "../../../core/stores/GuiStore";
import { RendererWidget } from "../../../core/gui/RendererWidget";
import { Model3DRenderer } from "../../rendering/Model3DRenderer";
import { Model3DToolBar } from "./Model3DToolBar";
import { Model3DToolBarView } from "./Model3DToolBarView";
import { Model3DSelectListView } from "./Model3DSelectListView";
import { CharacterClassModel } from "../../model/CharacterClassModel";
import { CharacterClassAnimationModel } from "../../model/CharacterClassAnimationModel";
import { Model3DStore } from "../../stores/Model3DStore";
import { DisposableThreeRenderer } from "../../../core/rendering/Renderer";
import { div } from "../../../core/gui/dom";
import { ResizableView } from "../../../core/gui/ResizableView";
import { GuiStore } from "../../../core/stores/GuiStore";
const MODEL_LIST_WIDTH = 100;
const ANIMATION_LIST_WIDTH = 140;
export class Model3DView extends ResizableWidget {
export class Model3DView extends ResizableView {
readonly element = div({ className: "viewer_Model3DView" });
private tool_bar_view: Model3DToolBar;
private tool_bar_view: Model3DToolBarView;
private model_list_view: Model3DSelectListView<CharacterClassModel>;
private animation_list_view: Model3DSelectListView<CharacterClassAnimationModel>;
private renderer_view: RendererWidget;
constructor(
gui_store: GuiStore,
private readonly gui_store: GuiStore,
model_3d_store: Model3DStore,
three_renderer: DisposableThreeRenderer,
) {
super();
this.tool_bar_view = this.disposable(new Model3DToolBar(model_3d_store));
this.model_list_view = this.disposable(
this.tool_bar_view = this.add(new Model3DToolBarView(model_3d_store));
this.model_list_view = this.add(
new Model3DSelectListView(
model_3d_store.models,
model_3d_store.current_model,
model_3d_store.set_current_model,
),
);
this.animation_list_view = this.disposable(
this.animation_list_view = this.add(
new Model3DSelectListView(
model_3d_store.animations,
model_3d_store.current_animation,
model_3d_store.set_current_animation,
),
);
this.renderer_view = this.disposable(
this.renderer_view = this.add(
new RendererWidget(new Model3DRenderer(three_renderer, model_3d_store)),
);
@ -60,21 +60,19 @@ export class Model3DView extends ResizableWidget {
),
);
this.renderer_view.start_rendering();
this.disposable(
gui_store.tool.observe(({ value: tool }) => {
if (tool === GuiTool.Viewer) {
this.renderer_view.start_rendering();
} else {
this.renderer_view.stop_rendering();
}
}),
);
this.finalize_construction();
}
activate(): void {
this.renderer_view.start_rendering();
super.activate();
}
deactivate(): void {
super.deactivate();
this.renderer_view.stop_rendering();
}
resize(width: number, height: number): this {
super.resize(width, height);

View File

@ -13,6 +13,7 @@ export function initialize_viewer(
const disposer = new Disposer();
const view = new ViewerView(
gui_store,
async () => {
const { Model3DStore } = await import("./stores/Model3DStore");
const { Model3DView } = await import("./gui/model_3d/Model3DView");
@ -42,7 +43,7 @@ export function initialize_viewer(
disposer.add(store);
}
return new TextureView(gui_store, store, create_three_renderer());
return new TextureView(store, create_three_renderer());
},
);

View File

@ -1 +1 @@
43
44

View File

@ -15,17 +15,16 @@ module.exports = merge(common, {
moduleIds: "hashed",
runtimeChunk: "single",
splitChunks: {
chunks: "all",
cacheGroups: {
styles: {
name: "style",
test: /\.css$/,
chunks: "all",
enforce: true,
},
vendor: {
test: /node_modules/,
name: "vendors",
chunks: "all",
},
},
},