From 243638879c4c4642af95de089c356a2252172cd7 Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Tue, 24 Dec 2019 03:04:18 +0100 Subject: [PATCH] Entity counts in area select are now updated when adding or removing entities. Added more unit tests. --- FEATURES.md | 1 - src/application/gui/NavigationView.ts | 5 +- src/core/controllers/Controller.ts | 18 ++ src/core/gui/ComboBox.ts | 8 +- src/core/gui/DropDown.ts | 23 +- src/core/gui/Input.ts | 2 +- src/core/gui/LabelledControl.ts | 2 +- src/core/gui/Menu.ts | 25 +- src/core/gui/Select.ts | 20 +- src/core/undo/UndoManager.ts | 48 ++-- src/quest_editor/QuestRunner.ts | 9 +- src/quest_editor/actions/QuestEditAction.ts | 9 +- .../QuestEditorToolBarController.test.ts | 65 ++++++ .../QuestEditorToolBarController.ts | 217 ++++++++++++++++++ .../controllers/QuestInfoController.test.ts | 40 ++++ .../controllers/QuestInfoController.ts | 46 ++-- src/quest_editor/gui/LogView.ts | 4 +- .../gui/QuestEditorToolBar.test.ts | 19 ++ src/quest_editor/gui/QuestEditorToolBar.ts | 159 +++---------- src/quest_editor/gui/QuestEditorView.ts | 80 ++----- src/quest_editor/gui/QuestInfoView.test.ts | 37 +-- src/quest_editor/gui/QuestInfoView.ts | 19 +- src/quest_editor/gui/RegistersView.ts | 14 +- src/quest_editor/index.ts | 46 +++- .../persistence/QuestEditorUiPersister.ts | 30 ++- src/quest_editor/scripting/vm/Debugger.ts | 8 +- .../scripting/vm/VirtualMachine.ts | 7 +- src/quest_editor/stores/QuestEditorStore.ts | 65 +----- src/quest_editor/stores/quest_creation.ts | 105 +++------ ...uest_editor_store.ts => store_creation.ts} | 13 +- 30 files changed, 674 insertions(+), 470 deletions(-) create mode 100644 src/core/controllers/Controller.ts create mode 100644 src/quest_editor/controllers/QuestEditorToolBarController.test.ts create mode 100644 src/quest_editor/controllers/QuestEditorToolBarController.ts create mode 100644 src/quest_editor/controllers/QuestInfoController.test.ts create mode 100644 src/quest_editor/gui/QuestEditorToolBar.test.ts rename test/src/quest_editor/stores/{create_quest_editor_store.ts => store_creation.ts} (56%) diff --git a/FEATURES.md b/FEATURES.md index 6330144d..97ff3c2a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -155,4 +155,3 @@ 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 -- [Area Selection](#area-selection): Entity numbers don't update in select widget when adding/deleting entities. diff --git a/src/application/gui/NavigationView.ts b/src/application/gui/NavigationView.ts index d5f9a99d..d5c190ea 100644 --- a/src/application/gui/NavigationView.ts +++ b/src/application/gui/NavigationView.ts @@ -4,7 +4,6 @@ import { GuiStore, 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"], @@ -17,8 +16,10 @@ export class NavigationView extends Widget { TOOLS.map(([value, text]) => [value, this.disposable(new NavigationButton(value, text))]), ); private readonly server_select = this.disposable( - new Select(property(["Ephinea"]), server => server, { + new Select({ label: "Server:", + items: ["Ephinea"], + to_label: server => server, enabled: false, selected: "Ephinea", tooltip: "Only Ephinea is supported at the moment", diff --git a/src/core/controllers/Controller.ts b/src/core/controllers/Controller.ts new file mode 100644 index 00000000..544cb5d7 --- /dev/null +++ b/src/core/controllers/Controller.ts @@ -0,0 +1,18 @@ +import { Disposable } from "../observable/Disposable"; +import { Disposer } from "../observable/Disposer"; + +export abstract class Controller implements Disposable { + private readonly disposer = new Disposer(); + + dispose(): void { + this.disposer.dispose(); + } + + protected disposable(disposable: T): T { + return this.disposer.add(disposable); + } + + protected disposables(...disposables: Disposable[]): void { + this.disposer.add_all(...disposables); + } +} diff --git a/src/core/gui/ComboBox.ts b/src/core/gui/ComboBox.ts index 55d8963f..56fd8eff 100644 --- a/src/core/gui/ComboBox.ts +++ b/src/core/gui/ComboBox.ts @@ -34,7 +34,13 @@ export class ComboBox extends LabelledControl { this._selected = new WidgetProperty(this, undefined, this.set_selected); this.selected = this._selected; - this.menu = this.disposable(new Menu(options.items, options.to_label, this.element)); + this.menu = this.disposable( + new Menu({ + items: options.items, + to_label: options.to_label, + related_element: this.element, + }), + ); this.menu.element.onmousedown = e => e.preventDefault(); this.input_element.placeholder = options.placeholder_text || ""; diff --git a/src/core/gui/DropDown.ts b/src/core/gui/DropDown.ts index 6b3151cf..1abd5a50 100644 --- a/src/core/gui/DropDown.ts +++ b/src/core/gui/DropDown.ts @@ -8,7 +8,11 @@ import { Observable } from "../observable/Observable"; import { Emitter } from "../observable/Emitter"; import { emitter } from "../observable"; -export type DropDownOptions = ButtonOptions; +export type DropDownOptions = ButtonOptions & { + readonly text: string; + readonly items: readonly T[] | Property; + readonly to_label: (element: T) => string; +}; export class DropDown extends Control { readonly element = el.div({ class: "core_DropDown" }); @@ -20,21 +24,22 @@ export class DropDown extends Control { private readonly _chosen: Emitter; private just_opened: boolean; - constructor( - text: string, - items: readonly T[] | Property, - to_label: (element: T) => string, - options?: DropDownOptions, - ) { + constructor(options: DropDownOptions) { super(options); this.button = this.disposable( - new Button(text, { + new Button(options.text, { icon_left: options && options.icon_left, icon_right: Icon.TriangleDown, }), ); - this.menu = this.disposable(new Menu(items, to_label, this.element)); + this.menu = this.disposable( + new Menu({ + items: options.items, + to_label: options.to_label, + related_element: this.element, + }), + ); this.element.append(this.button.element, this.menu.element); this._chosen = emitter(); diff --git a/src/core/gui/Input.ts b/src/core/gui/Input.ts index 0a1c5363..098c3dfd 100644 --- a/src/core/gui/Input.ts +++ b/src/core/gui/Input.ts @@ -6,7 +6,7 @@ import { is_any_property, Property } from "../observable/property/Property"; import "./Input.css"; import { WidgetProperty } from "../observable/property/WidgetProperty"; -export type InputOptions = { readonly?: boolean } & LabelledControlOptions; +export type InputOptions = { readonly readonly?: boolean } & LabelledControlOptions; export abstract class Input extends LabelledControl { readonly element: HTMLElement; diff --git a/src/core/gui/LabelledControl.ts b/src/core/gui/LabelledControl.ts index 797c9ca8..d6822b8c 100644 --- a/src/core/gui/LabelledControl.ts +++ b/src/core/gui/LabelledControl.ts @@ -3,7 +3,7 @@ import { Control } from "./Control"; import { WidgetOptions } from "./Widget"; export type LabelledControlOptions = WidgetOptions & { - label?: string; + readonly label?: string; }; export type LabelPosition = "left" | "right" | "top" | "bottom"; diff --git a/src/core/gui/Menu.ts b/src/core/gui/Menu.ts index 008decf8..4224221a 100644 --- a/src/core/gui/Menu.ts +++ b/src/core/gui/Menu.ts @@ -1,11 +1,17 @@ import { disposable_listener, el } from "./dom"; import { Widget } from "./Widget"; -import { Property } from "../observable/property/Property"; +import { is_any_property, 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 type MenuOptions = { + readonly items: readonly T[] | Property; + readonly to_label: (element: T) => string; + readonly related_element: HTMLElement; +}; + export class Menu extends Widget { readonly element = el.div({ class: "core_Menu", tab_index: -1 }); readonly selected: WritableProperty; @@ -18,11 +24,7 @@ export class Menu extends Widget { private hovered_index?: number; private hovered_element?: HTMLElement; - constructor( - items: readonly T[] | Property, - to_label: (element: T) => string, - related_element: HTMLElement, - ) { + constructor(options: MenuOptions) { super(); this.visible.val = false; @@ -33,9 +35,9 @@ export class Menu extends Widget { 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 as Property); - this.related_element = related_element; + this.to_label = options.to_label; + this.items = is_any_property(options.items) ? options.items : property(options.items); + this.related_element = options.related_element; this._selected = new WidgetProperty(this, undefined, this.set_selected); this.selected = this._selected; @@ -46,7 +48,10 @@ export class Menu extends Widget { this.inner_element.innerHTML = ""; this.inner_element.append( ...items.map((item, index) => - el.div({ text: to_label(item), data: { index: index.toString() } }), + el.div({ + text: this.to_label(item), + data: { index: index.toString() }, + }), ), ); this.hover_item(); diff --git a/src/core/gui/Select.ts b/src/core/gui/Select.ts index d999e2eb..72d93a0f 100644 --- a/src/core/gui/Select.ts +++ b/src/core/gui/Select.ts @@ -8,7 +8,9 @@ import { WidgetProperty } from "../observable/property/WidgetProperty"; import { Menu } from "./Menu"; export type SelectOptions = LabelledControlOptions & { - selected?: T | Property; + readonly items: readonly T[] | Property; + readonly to_label: (element: T) => string; + readonly selected?: T | Property; }; export class Select extends LabelledControl { @@ -24,22 +26,24 @@ export class Select extends LabelledControl { private readonly _selected: WidgetProperty; private just_opened: boolean; - constructor( - items: readonly T[] | Property, - to_label: (element: T) => string, - options?: SelectOptions, - ) { + constructor(options: SelectOptions) { super(options); this.preferred_label_position = "left"; - this.to_label = to_label; + this.to_label = options.to_label; this.button = this.disposable( new Button(" ", { icon_right: Icon.TriangleDown, }), ); - this.menu = this.disposable(new Menu(items, to_label, this.element)); + this.menu = this.disposable( + new Menu({ + items: options.items, + to_label: this.to_label, + related_element: this.element, + }), + ); this.element.append(this.button.element, this.menu.element); this._selected = new WidgetProperty(this, undefined, this.set_selected); diff --git a/src/core/undo/UndoManager.ts b/src/core/undo/UndoManager.ts index 1c7f8ae9..b98ddf40 100644 --- a/src/core/undo/UndoManager.ts +++ b/src/core/undo/UndoManager.ts @@ -1,7 +1,30 @@ import { property } from "../observable"; import { Undo } from "./Undo"; -class UndoManager { +const NOOP_UNDO: Undo = { + can_redo: property(false), + can_undo: property(false), + first_redo: property(undefined), + first_undo: property(undefined), + + make_current() { + undo_manager.current.val = this; + }, + + redo() { + return false; + }, + + reset() { + // Do nothing. + }, + + undo() { + return false; + }, +}; + +export class UndoManager { readonly current = property(NOOP_UNDO); can_undo = this.current.flat_map(c => c.can_undo); @@ -26,26 +49,3 @@ class UndoManager { } export const undo_manager = new UndoManager(); - -export const NOOP_UNDO: Undo = { - can_redo: property(false), - can_undo: property(false), - first_redo: property(undefined), - first_undo: property(undefined), - - make_current() { - undo_manager.current.val = this; - }, - - redo() { - return false; - }, - - reset() { - // Do nothing. - }, - - undo() { - return false; - }, -}; diff --git a/src/quest_editor/QuestRunner.ts b/src/quest_editor/QuestRunner.ts index 1ef351b6..d47e7cd4 100644 --- a/src/quest_editor/QuestRunner.ts +++ b/src/quest_editor/QuestRunner.ts @@ -116,7 +116,7 @@ export class QuestRunner { this.vm.start_thread(0); // Debugger. - this.debugger.reset(); + this.debugger.activate_breakpoints(); this._state.val = QuestRunnerState.Running; @@ -156,7 +156,7 @@ export class QuestRunner { } this.vm.halt(); - this.debugger.reset(); + this.debugger.deactivate_breakpoints(); this._state.val = QuestRunnerState.Stopped; this._pause_location.val = undefined; this.npcs.splice(0, this.npcs.length); @@ -217,10 +217,7 @@ export class QuestRunner { case ExecutionResult.Paused: this._state.val = QuestRunnerState.Paused; - - pause_location = this.vm.get_current_instruction_pointer()?.source_location - ?.line_no; - + pause_location = this.vm.get_instruction_pointer()?.source_location?.line_no; break; case ExecutionResult.WaitingVsync: diff --git a/src/quest_editor/actions/QuestEditAction.ts b/src/quest_editor/actions/QuestEditAction.ts index 9d6629c6..6f603df2 100644 --- a/src/quest_editor/actions/QuestEditAction.ts +++ b/src/quest_editor/actions/QuestEditAction.ts @@ -1,16 +1,15 @@ import { Action } from "../../core/undo/Action"; import { QuestModel } from "../model/QuestModel"; -import { PropertyChangeEvent } from "../../core/observable/property/Property"; export abstract class QuestEditAction implements Action { abstract readonly description: string; - protected readonly new: T; protected readonly old: T; + protected readonly new: T; - constructor(protected readonly quest: QuestModel, event: PropertyChangeEvent) { - this.new = event.value; - this.old = event.old_value; + constructor(protected readonly quest: QuestModel, old_value: T, new_value: T) { + this.old = old_value; + this.new = new_value; } abstract undo(): void; diff --git a/src/quest_editor/controllers/QuestEditorToolBarController.test.ts b/src/quest_editor/controllers/QuestEditorToolBarController.test.ts new file mode 100644 index 00000000..dbb7589e --- /dev/null +++ b/src/quest_editor/controllers/QuestEditorToolBarController.test.ts @@ -0,0 +1,65 @@ +/** + * @jest-environment jsdom + */ +import { GuiStore } from "../../core/stores/GuiStore"; +import { create_area_store } from "../../../test/src/quest_editor/stores/store_creation"; +import { QuestEditorStore } from "../stores/QuestEditorStore"; +import { QuestEditorToolBarController } from "./QuestEditorToolBarController"; +import { Episode } from "../../core/data_formats/parsing/quest/Episode"; + +test("Some widgets should only be enabled when a quest is loaded.", async () => { + const gui_store = new GuiStore(); + const area_store = create_area_store(); + const quest_editor_store = new QuestEditorStore(gui_store, area_store); + const ctrl = new QuestEditorToolBarController(gui_store, area_store, quest_editor_store); + + expect(ctrl.can_save.val).toBe(false); + expect(ctrl.can_debug.val).toBe(false); + expect(ctrl.can_select_area.val).toBe(false); + expect(ctrl.can_step.val).toBe(false); + + await ctrl.create_new_quest(Episode.I); + + expect(ctrl.can_save.val).toBe(true); + expect(ctrl.can_debug.val).toBe(true); + expect(ctrl.can_select_area.val).toBe(true); + expect(ctrl.can_step.val).toBe(false); +}); + +test("Debugging controls should be enabled and disabled at the right times.", async () => { + const gui_store = new GuiStore(); + const area_store = create_area_store(); + const quest_editor_store = new QuestEditorStore(gui_store, area_store); + const ctrl = new QuestEditorToolBarController(gui_store, area_store, quest_editor_store); + + await ctrl.create_new_quest(Episode.I); + + try { + expect(ctrl.can_step.val).toBe(false); + expect(ctrl.can_stop.val).toBe(false); + + ctrl.debug(); + await next_animation_frame(); + + expect(ctrl.can_step.val).toBe(false); + expect(ctrl.can_stop.val).toBe(true); + + ctrl.stop(); + + expect(ctrl.can_step.val).toBe(false); + expect(ctrl.can_stop.val).toBe(false); + + quest_editor_store.quest_runner.set_breakpoint(5); + ctrl.debug(); + await next_animation_frame(); + + expect(ctrl.can_step.val).toBe(true); + expect(ctrl.can_stop.val).toBe(true); + } finally { + ctrl.stop(); + } +}); + +function next_animation_frame(): Promise { + return new Promise(resolve => requestAnimationFrame(() => resolve())); +} diff --git a/src/quest_editor/controllers/QuestEditorToolBarController.ts b/src/quest_editor/controllers/QuestEditorToolBarController.ts new file mode 100644 index 00000000..c5b4b67d --- /dev/null +++ b/src/quest_editor/controllers/QuestEditorToolBarController.ts @@ -0,0 +1,217 @@ +import { GuiStore, GuiTool } from "../../core/stores/GuiStore"; +import { AreaStore } from "../stores/AreaStore"; +import { QuestEditorStore } from "../stores/QuestEditorStore"; +import { AreaModel } from "../model/AreaModel"; +import { list_property, map } from "../../core/observable"; +import { Property } from "../../core/observable/property/Property"; +import { undo_manager } from "../../core/undo/UndoManager"; +import { Controller } from "../../core/controllers/Controller"; +import { Episode } from "../../core/data_formats/parsing/quest/Episode"; +import { create_new_quest } from "../stores/quest_creation"; +import { read_file } from "../../core/read_file"; +import { parse_quest, write_quest_qst } from "../../core/data_formats/parsing/quest"; +import { ArrayBufferCursor } from "../../core/data_formats/cursor/ArrayBufferCursor"; +import { Endianness } from "../../core/data_formats/Endianness"; +import { convert_quest_from_model, convert_quest_to_model } from "../stores/model_conversion"; +import Logger from "js-logger"; +import { create_element } from "../../core/gui/dom"; + +const logger = Logger.get("quest_editor/controllers/QuestEditorToolBarController"); + +export type AreaAndLabel = { readonly area: AreaModel; readonly label: string }; + +export class QuestEditorToolBarController extends Controller { + private quest_filename?: string; + + readonly vm_feature_active: boolean; + readonly areas: Property; + readonly current_area: Property; + readonly can_save: Property; + readonly can_undo: Property; + readonly can_redo: Property; + readonly can_select_area: Property; + readonly can_debug: Property; + readonly can_step: Property; + readonly can_stop: Property; + + constructor( + gui_store: GuiStore, + private readonly area_store: AreaStore, + private readonly quest_editor_store: QuestEditorStore, + ) { + super(); + + this.vm_feature_active = gui_store.feature_active("vm"); + + // Ensure the areas list is updated when entities are added or removed (the count in the + // label should update). + this.areas = quest_editor_store.current_quest.flat_map(quest => { + if (quest) { + return quest?.entities_per_area.flat_map(entities_per_area => { + return list_property( + undefined, + ...area_store.get_areas_for_episode(quest.episode).map(area => { + const entity_count = entities_per_area.get(area.id); + return { + area, + label: area.name + (entity_count ? ` (${entity_count})` : ""), + }; + }), + ); + }); + } else { + return list_property(); + } + }); + + this.current_area = map( + (areas, area) => areas.find(al => al.area == area)!, + this.areas, + quest_editor_store.current_area, + ); + + const quest_loaded = quest_editor_store.current_quest.map(q => q != undefined); + this.can_save = quest_loaded; + this.can_select_area = quest_loaded; + this.can_debug = quest_loaded; + + this.can_undo = map( + (c, r) => c && !r, + undo_manager.can_undo, + quest_editor_store.quest_runner.running, + ); + + this.can_redo = map( + (c, r) => c && !r, + undo_manager.can_redo, + quest_editor_store.quest_runner.running, + ); + + this.can_step = quest_editor_store.quest_runner.paused; + + this.can_stop = quest_editor_store.quest_runner.running; + + this.disposables( + quest_editor_store.current_quest.observe(() => { + this.quest_filename = undefined; + }), + + gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-O", () => { + const input: HTMLInputElement = create_element("input"); + input.type = "file"; + input.onchange = () => { + if (input.files && input.files.length) { + this.open_file(input.files[0]); + } + }; + input.click(); + }), + + gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-Shift-S", this.save_as), + + gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-Z", () => { + undo_manager.undo(); + }), + + gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-Shift-Z", () => { + undo_manager.redo(); + }), + + gui_store.on_global_keydown(GuiTool.QuestEditor, "F5", this.debug), + + gui_store.on_global_keydown(GuiTool.QuestEditor, "Shift-F5", this.stop), + + gui_store.on_global_keydown(GuiTool.QuestEditor, "F6", this.resume), + + gui_store.on_global_keydown(GuiTool.QuestEditor, "F8", this.step_over), + + gui_store.on_global_keydown(GuiTool.QuestEditor, "F7", this.step_in), + + gui_store.on_global_keydown(GuiTool.QuestEditor, "Shift-F8", this.step_out), + ); + } + + create_new_quest = async (episode: Episode): Promise => + this.quest_editor_store.set_quest(create_new_quest(this.area_store, episode)); + + // TODO: notify user of problems. + open_file = async (file: File): Promise => { + try { + const buffer = await read_file(file); + const quest = parse_quest(new ArrayBufferCursor(buffer, Endianness.Little)); + + if (!quest) { + logger.error("Couldn't parse quest file."); + } + + await this.quest_editor_store.set_quest( + quest && convert_quest_to_model(this.area_store, quest), + ); + + this.quest_filename = file.name; + } catch (e) { + logger.error("Couldn't read file.", e); + } + }; + + set_area = ({ area }: AreaAndLabel): void => { + this.quest_editor_store.set_current_area(area); + }; + + save_as = (): void => { + const quest = this.quest_editor_store.current_quest.val; + if (!quest) return; + + let default_file_name = this.quest_filename; + + if (default_file_name) { + const ext_start = default_file_name.lastIndexOf("."); + if (ext_start !== -1) default_file_name = default_file_name.slice(0, ext_start); + } + + let file_name = prompt("File name:", default_file_name); + if (!file_name) return; + + const buffer = write_quest_qst(convert_quest_from_model(quest), file_name); + + if (!file_name.endsWith(".qst")) { + file_name += ".qst"; + } + + const a = document.createElement("a"); + a.href = URL.createObjectURL(new Blob([buffer], { type: "application/octet-stream" })); + a.download = file_name; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(a.href); + document.body.removeChild(a); + }; + + debug = (): void => { + const quest = this.quest_editor_store.current_quest.val; + + if (quest) { + this.quest_editor_store.quest_runner.run(quest); + } + }; + + resume = (): void => { + this.quest_editor_store.quest_runner.resume(); + }; + + step_over = (): void => { + this.quest_editor_store.quest_runner.step_over(); + }; + + step_in = (): void => { + this.quest_editor_store.quest_runner.step_into(); + }; + + step_out = (): void => { + this.quest_editor_store.quest_runner.step_out(); + }; + + stop = (): void => { + this.quest_editor_store.quest_runner.stop(); + }; +} diff --git a/src/quest_editor/controllers/QuestInfoController.test.ts b/src/quest_editor/controllers/QuestInfoController.test.ts new file mode 100644 index 00000000..019f1317 --- /dev/null +++ b/src/quest_editor/controllers/QuestInfoController.test.ts @@ -0,0 +1,40 @@ +/** + * @jest-environment jsdom + */ +import { + create_area_store, + create_quest_editor_store, +} from "../../../test/src/quest_editor/stores/store_creation"; +import { QuestInfoController } from "./QuestInfoController"; +import { create_new_quest } from "../stores/quest_creation"; +import { Episode } from "../../core/data_formats/parsing/quest/Episode"; + +test("When a property's input value changes, this should be reflected in the current quest object and the undo stack.", async () => { + const area_store = create_area_store(); + const store = create_quest_editor_store(area_store); + const ctrl = new QuestInfoController(store); + + await store.set_quest(create_new_quest(area_store, Episode.I)); + + ctrl.set_id(3004); + expect(store.current_quest.val!.id.val).toBe(3004); + expect(store.undo.undo()).toBe(true); + expect(store.current_quest.val!.id.val).toBe(0); + + ctrl.set_name("Correct Horse Battery Staple"); + expect(store.current_quest.val!.name.val).toBe("Correct Horse Battery Staple"); + expect(store.undo.undo()).toBe(true); + expect(store.current_quest.val!.name.val).toBe("Untitled"); + + ctrl.set_short_description("This is a short description."); + expect(store.current_quest.val!.short_description.val).toBe("This is a short description."); + expect(store.undo.undo()).toBe(true); + expect(store.current_quest.val!.short_description.val).toBe("Created with phantasmal.world."); + + ctrl.set_long_description("This is a somewhat longer description."); + expect(store.current_quest.val!.long_description.val).toBe( + "This is a somewhat longer description.", + ); + expect(store.undo.undo()).toBe(true); + expect(store.current_quest.val!.long_description.val).toBe("Created with phantasmal.world."); +}); diff --git a/src/quest_editor/controllers/QuestInfoController.ts b/src/quest_editor/controllers/QuestInfoController.ts index ba1a1d0a..fa92a649 100644 --- a/src/quest_editor/controllers/QuestInfoController.ts +++ b/src/quest_editor/controllers/QuestInfoController.ts @@ -1,4 +1,4 @@ -import { Property, PropertyChangeEvent } from "../../core/observable/property/Property"; +import { Property } from "../../core/observable/property/Property"; import { EditIdAction } from "../actions/EditIdAction"; import { EditNameAction } from "../actions/EditNameAction"; import { EditShortDescriptionAction } from "../actions/EditShortDescriptionAction"; @@ -21,30 +21,50 @@ export class QuestInfoController { this.store.undo.make_current(); }; - id_changed = (event: PropertyChangeEvent): void => { - if (this.current_quest.val) { - this.store.undo.push(new EditIdAction(this.current_quest.val, event)).redo(); + set_id = (id: number): void => { + const quest = this.current_quest.val; + + if (quest) { + this.store.undo.push(new EditIdAction(quest, quest.id.val, id)).redo(); } }; - name_changed = (event: PropertyChangeEvent): void => { - if (this.current_quest.val) { - this.store.undo.push(new EditNameAction(this.current_quest.val, event)).redo(); + set_name = (name: string): void => { + const quest = this.current_quest.val; + + if (quest) { + this.store.undo.push(new EditNameAction(quest, quest.name.val, name)).redo(); } }; - short_description_changed = (event: PropertyChangeEvent): void => { - if (this.current_quest.val) { + set_short_description = (short_description: string): void => { + const quest = this.current_quest.val; + + if (quest) { this.store.undo - .push(new EditShortDescriptionAction(this.current_quest.val, event)) + .push( + new EditShortDescriptionAction( + quest, + quest.short_description.val, + short_description, + ), + ) .redo(); } }; - long_description_changed = (event: PropertyChangeEvent): void => { - if (this.current_quest.val) { + set_long_description = (long_description: string): void => { + const quest = this.current_quest.val; + + if (quest) { this.store.undo - .push(new EditLongDescriptionAction(this.current_quest.val, event)) + .push( + new EditLongDescriptionAction( + quest, + quest.long_description.val, + long_description, + ), + ) .redo(); } }; diff --git a/src/quest_editor/gui/LogView.ts b/src/quest_editor/gui/LogView.ts index d4ede53d..6b481dce 100644 --- a/src/quest_editor/gui/LogView.ts +++ b/src/quest_editor/gui/LogView.ts @@ -26,9 +26,11 @@ export class LogView extends ResizableWidget { this.list_element = el.div({ class: "quest_editor_LogView_message_list" }); this.level_filter = this.disposable( - new Select(LogLevels, level => LogLevel[level], { + new Select({ class: "quest_editor_LogView_level_filter", label: "Level:", + items: LogLevels, + to_label: level => LogLevel[level], }), ); diff --git a/src/quest_editor/gui/QuestEditorToolBar.test.ts b/src/quest_editor/gui/QuestEditorToolBar.test.ts new file mode 100644 index 00000000..8dbaafb4 --- /dev/null +++ b/src/quest_editor/gui/QuestEditorToolBar.test.ts @@ -0,0 +1,19 @@ +/** + * @jest-environment jsdom + */ +import { QuestEditorToolBarController } from "../controllers/QuestEditorToolBarController"; +import { QuestEditorToolBar } from "./QuestEditorToolBar"; +import { GuiStore } from "../../core/stores/GuiStore"; +import { create_area_store } from "../../../test/src/quest_editor/stores/store_creation"; +import { QuestEditorStore } from "../stores/QuestEditorStore"; + +test("Renders correctly.", () => { + const gui_store = new GuiStore(); + const area_store = create_area_store(); + const quest_editor_store = new QuestEditorStore(gui_store, area_store); + const tool_bar = new QuestEditorToolBar( + new QuestEditorToolBarController(gui_store, area_store, quest_editor_store), + ); + + expect(tool_bar.element).toMatchSnapshot(); +}); diff --git a/src/quest_editor/gui/QuestEditorToolBar.ts b/src/quest_editor/gui/QuestEditorToolBar.ts index 78255f64..c86c4e6a 100644 --- a/src/quest_editor/gui/QuestEditorToolBar.ts +++ b/src/quest_editor/gui/QuestEditorToolBar.ts @@ -3,25 +3,22 @@ import { FileButton } from "../../core/gui/FileButton"; import { Button } from "../../core/gui/Button"; import { undo_manager } from "../../core/undo/UndoManager"; import { Select } from "../../core/gui/Select"; -import { list_property, map } from "../../core/observable"; -import { AreaModel } from "../model/AreaModel"; import { Icon } from "../../core/gui/dom"; import { DropDown } from "../../core/gui/DropDown"; import { Episode } from "../../core/data_formats/parsing/quest/Episode"; -import { GuiStore, GuiTool } from "../../core/stores/GuiStore"; -import { QuestEditorStore } from "../stores/QuestEditorStore"; -import { AreaStore } from "../stores/AreaStore"; +import { + AreaAndLabel, + QuestEditorToolBarController, +} from "../controllers/QuestEditorToolBarController"; export class QuestEditorToolBar extends ToolBar { - constructor(gui_store: GuiStore, area_store: AreaStore, quest_editor_store: QuestEditorStore) { - const new_quest_button = new DropDown( - "New quest", - [Episode.I], - episode => `Episode ${Episode[episode]}`, - { - icon_left: Icon.NewFile, - }, - ); + constructor(ctrl: QuestEditorToolBarController) { + const new_quest_button = new DropDown({ + text: "New quest", + icon_left: Icon.NewFile, + items: [Episode.I], + to_label: episode => `Episode ${Episode[episode]}`, + }); const open_file_button = new FileButton("Open file...", { icon_left: Icon.File, accept: ".qst", @@ -47,28 +44,10 @@ export class QuestEditorToolBar extends ToolBar { ), }); // TODO: make sure select menu is updated when entity counts change. - const area_select = new Select( - quest_editor_store.current_quest.flat_map(quest => { - if (quest) { - return list_property( - undefined, - ...area_store.get_areas_for_episode(quest.episode), - ); - } else { - return list_property(); - } - }), - area => { - const quest = quest_editor_store.current_quest.val; - - if (quest) { - const entity_count = quest.entities_per_area.val.get(area.id); - return area.name + (entity_count ? ` (${entity_count})` : ""); - } else { - return area.name; - } - }, - ); + const area_select = new Select({ + items: ctrl.areas, + to_label: ({ label }) => label, + }); const debug_button = new Button("Debug", { icon_left: Icon.Play, tooltip: "Debug the current quest in a virtual machine (F5)", @@ -103,7 +82,7 @@ export class QuestEditorToolBar extends ToolBar { area_select, ]; - if (gui_store.feature_active("vm")) { + if (ctrl.vm_feature_active) { children.push( debug_button, resume_button, @@ -116,109 +95,45 @@ export class QuestEditorToolBar extends ToolBar { super({ children }); - const quest_loaded = quest_editor_store.current_quest.map(q => q != undefined); - - const step_controls_enabled = quest_editor_store.quest_runner.paused; - this.disposables( - new_quest_button.chosen.observe(({ value: episode }) => - quest_editor_store.new_quest(episode), - ), + new_quest_button.chosen.observe(({ value: episode }) => ctrl.create_new_quest(episode)), open_file_button.files.observe(({ value: files }) => { if (files.length) { - quest_editor_store.open_file(files[0]); + ctrl.open_file(files[0]); } }), - save_as_button.click.observe(quest_editor_store.save_as), - save_as_button.enabled.bind_to(quest_loaded), + save_as_button.click.observe(ctrl.save_as), + save_as_button.enabled.bind_to(ctrl.can_save), undo_button.click.observe(() => undo_manager.undo()), - undo_button.enabled.bind_to( - map( - (c, r) => c && !r, - undo_manager.can_undo, - quest_editor_store.quest_runner.running, - ), - ), + undo_button.enabled.bind_to(ctrl.can_undo), redo_button.click.observe(() => undo_manager.redo()), - redo_button.enabled.bind_to( - map( - (c, r) => c && !r, - undo_manager.can_redo, - quest_editor_store.quest_runner.running, - ), - ), + redo_button.enabled.bind_to(ctrl.can_redo), - area_select.selected.bind_to(quest_editor_store.current_area), - area_select.selected.observe(({ value: area }) => - quest_editor_store.set_current_area(area), - ), - area_select.enabled.bind_to(quest_loaded), + area_select.selected.bind_to(ctrl.current_area), + area_select.selected.observe(({ value }) => ctrl.set_area(value!)), + area_select.enabled.bind_to(ctrl.can_select_area), - debug_button.click.observe(quest_editor_store.debug_current_quest), - debug_button.enabled.bind_to(quest_loaded), + debug_button.click.observe(ctrl.debug), + debug_button.enabled.bind_to(ctrl.can_debug), - resume_button.click.observe(() => quest_editor_store.quest_runner.resume()), - resume_button.enabled.bind_to(step_controls_enabled), + resume_button.click.observe(ctrl.resume), + resume_button.enabled.bind_to(ctrl.can_step), - step_over_button.click.observe(() => quest_editor_store.quest_runner.step_over()), - step_over_button.enabled.bind_to(step_controls_enabled), + step_over_button.click.observe(ctrl.step_over), + step_over_button.enabled.bind_to(ctrl.can_step), - step_in_button.click.observe(() => quest_editor_store.quest_runner.step_into()), - step_in_button.enabled.bind_to(step_controls_enabled), + step_in_button.click.observe(ctrl.step_in), + step_in_button.enabled.bind_to(ctrl.can_step), - step_out_button.click.observe(() => quest_editor_store.quest_runner.step_out()), - step_out_button.enabled.bind_to(step_controls_enabled), + step_out_button.click.observe(ctrl.step_out), + step_out_button.enabled.bind_to(ctrl.can_step), - stop_button.click.observe(() => quest_editor_store.quest_runner.stop()), - stop_button.enabled.bind_to(quest_editor_store.quest_runner.running), - - gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-O", () => - open_file_button.click(), - ), - - gui_store.on_global_keydown( - GuiTool.QuestEditor, - "Ctrl-Shift-S", - quest_editor_store.save_as, - ), - - gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-Z", () => { - undo_manager.undo(); - }), - - gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-Shift-Z", () => { - undo_manager.redo(); - }), - - gui_store.on_global_keydown( - GuiTool.QuestEditor, - "F5", - quest_editor_store.debug_current_quest, - ), - - gui_store.on_global_keydown(GuiTool.QuestEditor, "Shift-F5", () => - quest_editor_store.quest_runner.stop(), - ), - - gui_store.on_global_keydown(GuiTool.QuestEditor, "F6", () => - quest_editor_store.quest_runner.resume(), - ), - - gui_store.on_global_keydown(GuiTool.QuestEditor, "F8", () => - quest_editor_store.quest_runner.step_over(), - ), - - gui_store.on_global_keydown(GuiTool.QuestEditor, "F7", () => - quest_editor_store.quest_runner.step_into(), - ), - - gui_store.on_global_keydown(GuiTool.QuestEditor, "Shift-F8", () => - quest_editor_store.quest_runner.step_out(), - ), + stop_button.click.observe(ctrl.stop), + stop_button.enabled.bind_to(ctrl.can_stop), ); this.finalize_construction(); diff --git a/src/quest_editor/gui/QuestEditorView.ts b/src/quest_editor/gui/QuestEditorView.ts index eab1e9c6..ff8b5382 100644 --- a/src/quest_editor/gui/QuestEditorView.ts +++ b/src/quest_editor/gui/QuestEditorView.ts @@ -17,15 +17,8 @@ import { RegistersView } from "./RegistersView"; import { LogView } from "./LogView"; import { QuestRunnerRendererView } from "./QuestRunnerRendererView"; import { QuestEditorStore } from "../stores/QuestEditorStore"; -import { AsmEditorStore } from "../stores/AsmEditorStore"; -import { AreaStore } from "../stores/AreaStore"; -import { EntityImageRenderer } from "../rendering/EntityImageRenderer"; -import { AreaAssetLoader } from "../loading/AreaAssetLoader"; -import { EntityAssetLoader } from "../loading/EntityAssetLoader"; -import { DisposableThreeRenderer } from "../../core/rendering/Renderer"; import { QuestEditorUiPersister } from "../persistence/QuestEditorUiPersister"; import Logger = require("js-logger"); -import { QuestInfoController } from "../controllers/QuestInfoController"; const logger = Logger.get("quest_editor/gui/QuestEditorView"); @@ -57,8 +50,6 @@ export class QuestEditorView extends ResizableWidget { { name: string; create(): ResizableWidget } >; - private readonly tool_bar: QuestEditorToolBar; - private readonly layout_element = create_element("div", { class: "quest_editor_gl_container" }); private readonly layout: Promise; private loaded_layout: GoldenLayout | undefined; @@ -67,14 +58,19 @@ export class QuestEditorView extends ResizableWidget { constructor( private readonly gui_store: GuiStore, - area_store: AreaStore, quest_editor_store: QuestEditorStore, - asm_editor_store: AsmEditorStore, - area_asset_loader: AreaAssetLoader, - entity_asset_loader: EntityAssetLoader, - entity_image_renderer: EntityImageRenderer, private readonly quest_editor_ui_persister: QuestEditorUiPersister, - create_three_renderer: () => DisposableThreeRenderer, + private readonly tool_bar: QuestEditorToolBar, + create_quest_info_view: () => QuestInfoView, + create_npc_counts_view: () => NpcCountsView, + create_editor_renderer_view: () => QuestEditorRendererView, + create_asm_editor_view: () => AsmEditorView, + create_entity_info_view: () => EntityInfoView, + create_npc_list_view: () => NpcListView, + create_object_list_view: () => ObjectListView, + create_events_view: () => EventsView, + create_quest_runner_renderer_view: () => QuestRunnerRendererView, + create_registers_view: () => RegistersView, ) { super(); @@ -87,55 +83,37 @@ export class QuestEditorView extends ResizableWidget { QuestInfoView, { name: "quest_info", - create: () => new QuestInfoView(new QuestInfoController(quest_editor_store)), + create: create_quest_info_view, }, ], - [ - NpcCountsView, - { name: "npc_counts", create: () => new NpcCountsView(quest_editor_store) }, - ], + [NpcCountsView, { name: "npc_counts", create: create_npc_counts_view }], [ QuestEditorRendererView, { name: "quest_renderer", - create: () => - new QuestEditorRendererView( - gui_store, - quest_editor_store, - area_asset_loader, - entity_asset_loader, - create_three_renderer(), - ), + create: create_editor_renderer_view, }, ], [ AsmEditorView, { name: "asm_editor", - create: () => - new AsmEditorView( - gui_store, - quest_editor_store.quest_runner, - asm_editor_store, - ), + create: create_asm_editor_view, }, ], - [ - EntityInfoView, - { name: "entity_info", create: () => new EntityInfoView(quest_editor_store) }, - ], + [EntityInfoView, { name: "entity_info", create: create_entity_info_view }], [ NpcListView, { name: "npc_list_view", - create: () => new NpcListView(quest_editor_store, entity_image_renderer), + create: create_npc_list_view, }, ], [ ObjectListView, { name: "object_list_view", - create: () => new ObjectListView(quest_editor_store, entity_image_renderer), + create: create_object_list_view, }, ], ]); @@ -143,33 +121,22 @@ export class QuestEditorView extends ResizableWidget { if (gui_store.feature_active("events")) { this.view_map.set(EventsView, { name: "events_view", - create: () => new EventsView(quest_editor_store), + create: create_events_view, }); } if (gui_store.feature_active("vm")) { this.view_map.set(QuestRunnerRendererView, { name: "quest_runner", - create: () => - new QuestRunnerRendererView( - gui_store, - quest_editor_store, - area_asset_loader, - entity_asset_loader, - create_three_renderer(), - ), + create: create_quest_runner_renderer_view, }); this.view_map.set(LogView, { name: "log_view", create: () => new LogView() }); this.view_map.set(RegistersView, { name: "registers_view", - create: () => new RegistersView(quest_editor_store.quest_runner), + create: create_registers_view, }); } - this.tool_bar = this.disposable( - new QuestEditorToolBar(gui_store, area_store, quest_editor_store), - ); - this.element.append(this.tool_bar.element, this.layout_element); this.layout = this.init_golden_layout(); @@ -271,8 +238,7 @@ export class QuestEditorView extends ResizableWidget { private attempt_gl_init(config: GoldenLayout.Config): GoldenLayout { const layout = new GoldenLayout(config, this.layout_element); - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; + const sub_views = this.sub_views; try { for (const { name, create } of this.view_map.values()) { @@ -290,7 +256,7 @@ export class QuestEditorView extends ResizableWidget { view.resize(container.width, container.height); - self.sub_views.set(name, view); + sub_views.set(name, view); container.getElement().append(view.element); }); } diff --git a/src/quest_editor/gui/QuestInfoView.test.ts b/src/quest_editor/gui/QuestInfoView.test.ts index a131b02e..147ad6e0 100644 --- a/src/quest_editor/gui/QuestInfoView.test.ts +++ b/src/quest_editor/gui/QuestInfoView.test.ts @@ -5,7 +5,11 @@ import { QuestInfoController } from "../controllers/QuestInfoController"; import { undo_manager } from "../../core/undo/UndoManager"; import { QuestInfoView } from "./QuestInfoView"; import { Episode } from "../../core/data_formats/parsing/quest/Episode"; -import { create_quest_editor_store } from "../../../test/src/quest_editor/stores/create_quest_editor_store"; +import { + create_area_store, + create_quest_editor_store, +} from "../../../test/src/quest_editor/stores/store_creation"; +import { create_new_quest } from "../stores/quest_creation"; test("Renders correctly without a current quest.", () => { const view = new QuestInfoView(new QuestInfoController(create_quest_editor_store())); @@ -13,15 +17,17 @@ test("Renders correctly without a current quest.", () => { expect(view.element).toMatchSnapshot('should render a "No quest loaded." view'); }); -test("Renders correctly with a current quest.", () => { +test("Renders correctly with a current quest.", async () => { + const area_store = create_area_store(); const store = create_quest_editor_store(); const view = new QuestInfoView(new QuestInfoController(store)); - store.new_quest(Episode.I); + + await store.set_quest(create_new_quest(area_store, Episode.I)); expect(view.element).toMatchSnapshot("should render property inputs"); }); -test("When its element is focused the store's undo stack should become the current stack.", () => { +test("When the view's element is focused the store's undo stack should become the current stack.", () => { const store = create_quest_editor_store(); const view = new QuestInfoView(new QuestInfoController(store)); @@ -31,26 +37,3 @@ test("When its element is focused the store's undo stack should become the curre expect(undo_manager.current.val).toBe(store.undo); }); - -test("When a property's input value changes, this should be reflected in the current quest object.", async () => { - const store = create_quest_editor_store(); - const view = new QuestInfoView(new QuestInfoController(store)); - - await store.new_quest(Episode.I); - - for (const [prop, value] of [ - ["id", 3004], - ["name", "Correct Horse Battery Staple"], - ["short_description", "This is a short description."], - ["long_description", "This is a somewhat longer description."], - ]) { - const input = view.element.querySelector( - `#quest_editor_QuestInfoView_${prop} input, #quest_editor_QuestInfoView_${prop} textarea`, - ) as HTMLInputElement; - - input.value = String(value); - input.dispatchEvent(new Event("change")); - - expect((store.current_quest.val as any)[prop].val).toBe(value); - } -}); diff --git a/src/quest_editor/gui/QuestInfoView.ts b/src/quest_editor/gui/QuestInfoView.ts index 1511c5db..92875d37 100644 --- a/src/quest_editor/gui/QuestInfoView.ts +++ b/src/quest_editor/gui/QuestInfoView.ts @@ -14,18 +14,14 @@ export class QuestInfoView extends ResizableWidget { private readonly table_element = el.table(); private readonly episode_element: HTMLElement; - private readonly id_input = this.disposable( - new NumberInput(0, { id: "quest_editor_QuestInfoView_id" }), - ); + private readonly id_input = this.disposable(new NumberInput(0)); private readonly name_input = this.disposable( new TextInput("", { - id: "quest_editor_QuestInfoView_name", max_length: 32, }), ); private readonly short_description_input = this.disposable( new TextArea("", { - id: "quest_editor_QuestInfoView_short_description", max_length: 128, font_family: '"Courier New", monospace', cols: 25, @@ -34,7 +30,6 @@ export class QuestInfoView extends ResizableWidget { ); private readonly long_description_input = this.disposable( new TextArea("", { - id: "quest_editor_QuestInfoView_long_description", max_length: 288, font_family: '"Courier New", monospace', cols: 25, @@ -78,16 +73,20 @@ export class QuestInfoView extends ResizableWidget { if (q) { this.quest_disposer.add_all( this.id_input.value.bind_to(q.id), - this.id_input.value.observe(ctrl.id_changed), + this.id_input.value.observe(({ value }) => ctrl.set_id(value)), this.name_input.value.bind_to(q.name), - this.name_input.value.observe(ctrl.name_changed), + this.name_input.value.observe(({ value }) => ctrl.set_name(value)), this.short_description_input.value.bind_to(q.short_description), - this.short_description_input.value.observe(ctrl.short_description_changed), + this.short_description_input.value.observe(({ value }) => + ctrl.set_short_description(value), + ), this.long_description_input.value.bind_to(q.long_description), - this.long_description_input.value.observe(ctrl.long_description_changed), + this.long_description_input.value.observe(({ value }) => + ctrl.set_long_description(value), + ), this.enabled.bind_to(ctrl.enabled), ); diff --git a/src/quest_editor/gui/RegistersView.ts b/src/quest_editor/gui/RegistersView.ts index 13ce5b5c..9ff02b34 100644 --- a/src/quest_editor/gui/RegistersView.ts +++ b/src/quest_editor/gui/RegistersView.ts @@ -21,20 +21,18 @@ type RegisterGetterFunction = (register: number) => number; export class RegistersView extends ResizableWidget { private readonly type_select = this.disposable( - new Select( - [ + new Select({ + label: "Display type:", + tooltip: "Select which data type register values should be displayed as.", + items: [ RegisterDisplayType.Signed, RegisterDisplayType.Unsigned, RegisterDisplayType.Word, RegisterDisplayType.Byte, RegisterDisplayType.Float, ], - type => RegisterDisplayType[type], - { - tooltip: "Select which data type register values should be displayed as.", - label: "Display type:", - }, - ), + to_label: type => RegisterDisplayType[type], + }), ); private register_getter: RegisterGetterFunction = this.get_register_getter( RegisterDisplayType.Unsigned, diff --git a/src/quest_editor/index.ts b/src/quest_editor/index.ts index 9b9b5b7b..21342a3f 100644 --- a/src/quest_editor/index.ts +++ b/src/quest_editor/index.ts @@ -9,6 +9,19 @@ import { EntityImageRenderer } from "./rendering/EntityImageRenderer"; import { EntityAssetLoader } from "./loading/EntityAssetLoader"; import { DisposableThreeRenderer } from "../core/rendering/Renderer"; import { QuestEditorUiPersister } from "./persistence/QuestEditorUiPersister"; +import { QuestEditorToolBar } from "./gui/QuestEditorToolBar"; +import { QuestEditorToolBarController } from "./controllers/QuestEditorToolBarController"; +import { QuestInfoView } from "./gui/QuestInfoView"; +import { NpcCountsView } from "./gui/NpcCountsView"; +import { QuestEditorRendererView } from "./gui/QuestEditorRendererView"; +import { AsmEditorView } from "./gui/AsmEditorView"; +import { EntityInfoView } from "./gui/EntityInfoView"; +import { NpcListView } from "./gui/NpcListView"; +import { ObjectListView } from "./gui/ObjectListView"; +import { EventsView } from "./gui/EventsView"; +import { QuestRunnerRendererView } from "./gui/QuestRunnerRendererView"; +import { RegistersView } from "./gui/RegistersView"; +import { QuestInfoController } from "./controllers/QuestInfoController"; export function initialize_quest_editor( http_client: HttpClient, @@ -33,13 +46,34 @@ export function initialize_quest_editor( // View return new QuestEditorView( gui_store, - area_store, quest_editor_store, - asm_editor_store, - area_asset_loader, - entity_asset_loader, - entity_image_renderer, quest_editor_ui_persister, - create_three_renderer, + new QuestEditorToolBar( + new QuestEditorToolBarController(gui_store, area_store, quest_editor_store), + ), + () => new QuestInfoView(new QuestInfoController(quest_editor_store)), + () => new NpcCountsView(quest_editor_store), + () => + new QuestEditorRendererView( + gui_store, + quest_editor_store, + area_asset_loader, + entity_asset_loader, + create_three_renderer(), + ), + () => new AsmEditorView(gui_store, quest_editor_store.quest_runner, asm_editor_store), + () => new EntityInfoView(quest_editor_store), + () => new NpcListView(quest_editor_store, entity_image_renderer), + () => new ObjectListView(quest_editor_store, entity_image_renderer), + () => new EventsView(quest_editor_store), + () => + new QuestRunnerRendererView( + gui_store, + quest_editor_store, + area_asset_loader, + entity_asset_loader, + create_three_renderer(), + ), + () => new RegistersView(quest_editor_store.quest_runner), ); } diff --git a/src/quest_editor/persistence/QuestEditorUiPersister.ts b/src/quest_editor/persistence/QuestEditorUiPersister.ts index 68b35a03..f033be86 100644 --- a/src/quest_editor/persistence/QuestEditorUiPersister.ts +++ b/src/quest_editor/persistence/QuestEditorUiPersister.ts @@ -87,32 +87,30 @@ export class QuestEditorUiPersister extends Persister { break; case "stack": - { - // Remove empty stacks. - if (config.content == undefined || config.content.length === 0) { - return undefined; - } - - // Remove corrupted activeItemIndex properties. - const cfg = config as any; - - if ( - cfg.activeItemIndex != undefined && - cfg.content != undefined && - cfg.activeItemIndex >= cfg.content.length - ) { - cfg.activeItemIndex = undefined; - } + // Remove empty stacks. + if (config.content == undefined || config.content.length === 0) { + return undefined; } break; } + // Sanitize child items. if (config.content) { config.content = config.content .map(child => this.sanitize_layout_child(child, components, found)) .filter(item => item) as ItemConfigType[]; } + // Remove corrupted activeItemIndex properties. + const cfg = config as any; + + if ( + cfg.activeItemIndex != undefined && + (cfg.content == undefined || cfg.activeItemIndex >= cfg.content.length) + ) { + cfg.activeItemIndex = undefined; + } + return config; } diff --git a/src/quest_editor/scripting/vm/Debugger.ts b/src/quest_editor/scripting/vm/Debugger.ts index 4b335920..e69fe911 100644 --- a/src/quest_editor/scripting/vm/Debugger.ts +++ b/src/quest_editor/scripting/vm/Debugger.ts @@ -68,11 +68,17 @@ export class Debugger { } } - reset(): void { + activate_breakpoints(): void { for (const bp of this._breakpoints) { bp.activate(); } } + + deactivate_breakpoints(): void { + for (const bp of this._breakpoints) { + bp.deactivate(); + } + } } export class Breakpoint { diff --git a/src/quest_editor/scripting/vm/VirtualMachine.ts b/src/quest_editor/scripting/vm/VirtualMachine.ts index a0cca5a2..fed5ef6b 100644 --- a/src/quest_editor/scripting/vm/VirtualMachine.ts +++ b/src/quest_editor/scripting/vm/VirtualMachine.ts @@ -225,6 +225,7 @@ export class VirtualMachine { this._object_code = object_code; this.episode = episode; + this.label_to_seg_idx.clear(); let i = 0; for (const segment of this._object_code) { @@ -337,7 +338,7 @@ export class VirtualMachine { // Not paused, the next instruction can be executed. this.paused = false; - const result = this.execute_instruction(thread, inst_ptr, execution_counter); + const result = this.execute_instruction(thread, inst_ptr); // Only return WaitingVsync when all threads have yielded. if (result != undefined && result !== ExecutionResult.WaitingVsync) { @@ -383,7 +384,6 @@ export class VirtualMachine { this.registers.zero(); this.string_arg_store = ""; - this.label_to_seg_idx.clear(); this.threads = []; this.thread_idx = 0; this.window_msg_open = false; @@ -401,7 +401,7 @@ export class VirtualMachine { return this.current_thread()?.current_stack_frame(); } - get_current_instruction_pointer(): InstructionPointer | undefined { + get_instruction_pointer(): InstructionPointer | undefined { return this.get_current_stack_frame()?.instruction_pointer; } @@ -470,7 +470,6 @@ export class VirtualMachine { private execute_instruction( thread: Thread, inst_ptr: InstructionPointer, - execution_counter: number, ): ExecutionResult | undefined { const inst = inst_ptr.instruction; diff --git a/src/quest_editor/stores/QuestEditorStore.ts b/src/quest_editor/stores/QuestEditorStore.ts index acf5feff..268dc284 100644 --- a/src/quest_editor/stores/QuestEditorStore.ts +++ b/src/quest_editor/stores/QuestEditorStore.ts @@ -1,10 +1,6 @@ import { property } from "../../core/observable"; import { QuestModel } from "../model/QuestModel"; import { Property, PropertyChangeEvent } from "../../core/observable/property/Property"; -import { read_file } from "../../core/read_file"; -import { parse_quest, write_quest_qst } from "../../core/data_formats/parsing/quest"; -import { ArrayBufferCursor } from "../../core/data_formats/cursor/ArrayBufferCursor"; -import { Endianness } from "../../core/data_formats/Endianness"; import { QuestObjectModel } from "../model/QuestObjectModel"; import { QuestNpcModel } from "../model/QuestNpcModel"; import { AreaModel } from "../model/AreaModel"; @@ -15,13 +11,10 @@ import { Disposer } from "../../core/observable/Disposer"; import { GuiStore, GuiTool } from "../../core/stores/GuiStore"; import { UndoStack } from "../../core/undo/UndoStack"; import { TranslateEntityAction } from "../actions/TranslateEntityAction"; -import { Episode } from "../../core/data_formats/parsing/quest/Episode"; -import { create_new_quest } from "./quest_creation"; import { CreateEntityAction } from "../actions/CreateEntityAction"; import { RemoveEntityAction } from "../actions/RemoveEntityAction"; import { Euler, Vector3 } from "three"; import { RotateEntityAction } from "../actions/RotateEntityAction"; -import { convert_quest_from_model, convert_quest_to_model } from "./model_conversion"; import { WritableProperty } from "../../core/observable/property/WritableProperty"; import { QuestRunner } from "../QuestRunner"; import { AreaStore } from "./AreaStore"; @@ -35,7 +28,6 @@ const logger = Logger.get("quest_editor/gui/QuestEditorStore"); export class QuestEditorStore implements Disposable { private readonly disposer = new Disposer(); - private readonly _current_quest_filename = property(undefined); private readonly _current_quest = property(undefined); private readonly _current_area = property(undefined); private readonly _selected_entity = property(undefined); @@ -43,7 +35,6 @@ export class QuestEditorStore implements Disposable { readonly quest_runner: QuestRunner; readonly debug: WritableProperty = property(false); readonly undo = new UndoStack(); - readonly current_quest_filename: Property = this._current_quest_filename; readonly current_quest: Property = this._current_quest; readonly current_area: Property = this._current_area; readonly selected_entity: Property = this._selected_entity; @@ -114,49 +105,6 @@ export class QuestEditorStore implements Disposable { this._selected_entity.val = entity; }; - new_quest = async (episode: Episode): Promise => - this.set_quest(create_new_quest(this.area_store, episode)); - - // TODO: notify user of problems. - open_file = async (file: File): Promise => { - try { - const buffer = await read_file(file); - const quest = parse_quest(new ArrayBufferCursor(buffer, Endianness.Little)); - this.set_quest(quest && convert_quest_to_model(this.area_store, quest), file.name); - } catch (e) { - logger.error("Couldn't read file.", e); - } - }; - - save_as = (): void => { - const quest = this.current_quest.val; - if (!quest) return; - - let default_file_name = this.current_quest_filename.val; - - if (default_file_name) { - const ext_start = default_file_name.lastIndexOf("."); - if (ext_start !== -1) default_file_name = default_file_name.slice(0, ext_start); - } - - let file_name = prompt("File name:", default_file_name); - if (!file_name) return; - - const buffer = write_quest_qst(convert_quest_from_model(quest), file_name); - - if (!file_name.endsWith(".qst")) { - file_name += ".qst"; - } - - const a = document.createElement("a"); - a.href = URL.createObjectURL(new Blob([buffer], { type: "application/octet-stream" })); - a.download = file_name; - document.body.appendChild(a); - a.click(); - URL.revokeObjectURL(a.href); - document.body.removeChild(a); - }; - translate_entity = ( entity: QuestEntityModel, old_section: SectionModel | undefined, @@ -207,7 +155,7 @@ export class QuestEditorStore implements Disposable { this.undo.push(new EditEventDelayAction(event, e.old_value, e.value)).redo(); }; - private async set_quest(quest?: QuestModel, filename?: string): Promise { + async set_quest(quest?: QuestModel): Promise { this.undo.reset(); this.quest_runner.stop(); @@ -215,7 +163,6 @@ export class QuestEditorStore implements Disposable { this._current_area.val = undefined; this._selected_entity.val = undefined; - this._current_quest_filename.val = filename; this._current_quest.val = quest; if (quest) { @@ -242,8 +189,6 @@ export class QuestEditorStore implements Disposable { } } } - } else { - logger.error("Couldn't parse quest file."); } } @@ -259,12 +204,4 @@ export class QuestEditorStore implements Disposable { logger.warn(`Section ${entity.section_id.val} not found.`); } }; - - debug_current_quest = (): void => { - const quest = this.current_quest.val; - - if (quest) { - this.quest_runner.run(quest); - } - }; } diff --git a/src/quest_editor/stores/quest_creation.ts b/src/quest_editor/stores/quest_creation.ts index 9d7e0cf2..a316b0a5 100644 --- a/src/quest_editor/stores/quest_creation.ts +++ b/src/quest_editor/stores/quest_creation.ts @@ -1,24 +1,14 @@ import { Episode } from "../../core/data_formats/parsing/quest/Episode"; import { QuestModel } from "../model/QuestModel"; -import { new_arg, new_instruction, SegmentType } from "../scripting/instructions"; import { ObjectType } from "../../core/data_formats/parsing/quest/object_types"; import { NpcType } from "../../core/data_formats/parsing/quest/npc_types"; -import { - OP_ARG_PUSHL, - OP_ARG_PUSHR, - OP_ARG_PUSHW, - OP_BB_MAP_DESIGNATE, - OP_LETI, - OP_P_SETPOS, - OP_RET, - OP_SET_EPISODE, - OP_SET_FLOOR_HANDLER, -} from "../scripting/opcodes"; import { QuestObjectModel } from "../model/QuestObjectModel"; import { QuestNpcModel } from "../model/QuestNpcModel"; import { Euler, Vector3 } from "three"; import { QuestEventDagModel } from "../model/QuestEventDagModel"; import { AreaStore } from "./AreaStore"; +import { assemble } from "../scripting/assembly"; +import { Segment } from "../scripting/instructions"; export function create_new_quest(area_store: AreaStore, episode: Episode): QuestModel { if (episode === Episode.II) throw new Error("Episode II not yet supported."); @@ -37,62 +27,7 @@ export function create_new_quest(area_store: AreaStore, episode: Episode): Quest create_default_npcs(), create_default_event_chains(), [], - [ - { - labels: [0], - type: SegmentType.Instructions, - instructions: [ - new_instruction(OP_SET_EPISODE, [new_arg(0, 4)]), - new_instruction(OP_ARG_PUSHL, [new_arg(0, 4)]), - new_instruction(OP_ARG_PUSHW, [new_arg(150, 2)]), - new_instruction(OP_SET_FLOOR_HANDLER, []), - new_instruction(OP_BB_MAP_DESIGNATE, [ - new_arg(0, 1), - new_arg(0, 2), - new_arg(0, 1), - new_arg(0, 1), - ]), - new_instruction(OP_RET, []), - ], - asm: { labels: [] }, - }, - { - labels: [150], - type: SegmentType.Instructions, - instructions: [ - new_instruction(OP_LETI, [new_arg(60, 1), new_arg(237, 4)]), - new_instruction(OP_LETI, [new_arg(61, 1), new_arg(0, 4)]), - new_instruction(OP_LETI, [new_arg(62, 1), new_arg(333, 4)]), - new_instruction(OP_LETI, [new_arg(63, 1), new_arg(-15, 4)]), - new_instruction(OP_ARG_PUSHL, [new_arg(0, 4)]), - new_instruction(OP_ARG_PUSHR, [new_arg(60, 1)]), - new_instruction(OP_P_SETPOS, []), - new_instruction(OP_LETI, [new_arg(60, 1), new_arg(255, 4)]), - new_instruction(OP_LETI, [new_arg(61, 1), new_arg(0, 4)]), - new_instruction(OP_LETI, [new_arg(62, 1), new_arg(338, 4)]), - new_instruction(OP_LETI, [new_arg(63, 1), new_arg(-43, 4)]), - new_instruction(OP_ARG_PUSHL, [new_arg(1, 4)]), - new_instruction(OP_ARG_PUSHR, [new_arg(60, 1)]), - new_instruction(OP_P_SETPOS, []), - new_instruction(OP_LETI, [new_arg(60, 1), new_arg(222, 4)]), - new_instruction(OP_LETI, [new_arg(61, 1), new_arg(0, 4)]), - new_instruction(OP_LETI, [new_arg(62, 1), new_arg(322, 4)]), - new_instruction(OP_LETI, [new_arg(63, 1), new_arg(25, 4)]), - new_instruction(OP_ARG_PUSHL, [new_arg(2, 4)]), - new_instruction(OP_ARG_PUSHR, [new_arg(60, 1)]), - new_instruction(OP_P_SETPOS, []), - new_instruction(OP_LETI, [new_arg(60, 1), new_arg(248, 4)]), - new_instruction(OP_LETI, [new_arg(61, 1), new_arg(0, 4)]), - new_instruction(OP_LETI, [new_arg(62, 1), new_arg(323, 4)]), - new_instruction(OP_LETI, [new_arg(63, 1), new_arg(-20, 4)]), - new_instruction(OP_ARG_PUSHL, [new_arg(3, 4)]), - new_instruction(OP_ARG_PUSHR, [new_arg(60, 1)]), - new_instruction(OP_P_SETPOS, []), - new_instruction(OP_RET, []), - ], - asm: { labels: [] }, - }, - ], + create_default_object_code(), [], ); } @@ -970,3 +905,37 @@ function create_default_npcs(): QuestNpcModel[] { function create_default_event_chains(): QuestEventDagModel[] { return []; } + +function create_default_object_code(): Segment[] { + return assemble( + `.code + +0: + set_episode 0 + set_floor_handler 0, 150 + bb_map_designate 0, 0, 0, 0 + ret +150: + leti r60, 237 + leti r61, 0 + leti r62, 333 + leti r63, -15 + p_setpos 0, r60 + leti r60, 255 + leti r61, 0 + leti r62, 338 + leti r63, -43 + p_setpos 1, r60 + leti r60, 222 + leti r61, 0 + leti r62, 322 + leti r63, 25 + p_setpos 2, r60 + leti r60, 248 + leti r61, 0 + leti r62, 323 + leti r63, -20 + p_setpos 3, r60 + ret`.split("\n"), + ).object_code; +} diff --git a/test/src/quest_editor/stores/create_quest_editor_store.ts b/test/src/quest_editor/stores/store_creation.ts similarity index 56% rename from test/src/quest_editor/stores/create_quest_editor_store.ts rename to test/src/quest_editor/stores/store_creation.ts index 5af4caef..820f017d 100644 --- a/test/src/quest_editor/stores/create_quest_editor_store.ts +++ b/test/src/quest_editor/stores/store_creation.ts @@ -4,9 +4,12 @@ import { AreaStore } from "../../../../src/quest_editor/stores/AreaStore"; import { AreaAssetLoader } from "../../../../src/quest_editor/loading/AreaAssetLoader"; import { FileSystemHttpClient } from "../../core/FileSystemHttpClient"; -export function create_quest_editor_store(): QuestEditorStore { - return new QuestEditorStore( - new GuiStore(), - new AreaStore(new AreaAssetLoader(new FileSystemHttpClient())), - ); +export function create_area_store(): AreaStore { + return new AreaStore(new AreaAssetLoader(new FileSystemHttpClient())); +} + +export function create_quest_editor_store( + area_store: AreaStore = create_area_store(), +): QuestEditorStore { + return new QuestEditorStore(new GuiStore(), area_store); }