Entity counts in area select are now updated when adding or removing entities. Added more unit tests.

This commit is contained in:
Daan Vanden Bosch 2019-12-24 03:04:18 +01:00
parent 100272a115
commit 243638879c
30 changed files with 674 additions and 470 deletions

View File

@ -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.

View File

@ -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",

View File

@ -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<T extends Disposable>(disposable: T): T {
return this.disposer.add(disposable);
}
protected disposables(...disposables: Disposable[]): void {
this.disposer.add_all(...disposables);
}
}

View File

@ -34,7 +34,13 @@ export class ComboBox<T> extends LabelledControl {
this._selected = new WidgetProperty<T | undefined>(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 || "";

View File

@ -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<T> = ButtonOptions & {
readonly text: string;
readonly items: readonly T[] | Property<readonly T[]>;
readonly to_label: (element: T) => string;
};
export class DropDown<T> extends Control {
readonly element = el.div({ class: "core_DropDown" });
@ -20,21 +24,22 @@ export class DropDown<T> extends Control {
private readonly _chosen: Emitter<T>;
private just_opened: boolean;
constructor(
text: string,
items: readonly T[] | Property<readonly T[]>,
to_label: (element: T) => string,
options?: DropDownOptions,
) {
constructor(options: DropDownOptions<T>) {
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<T>(items, to_label, this.element));
this.menu = this.disposable(
new Menu<T>({
items: options.items,
to_label: options.to_label,
related_element: this.element,
}),
);
this.element.append(this.button.element, this.menu.element);
this._chosen = emitter();

View File

@ -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<T> extends LabelledControl {
readonly element: HTMLElement;

View File

@ -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";

View File

@ -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<T> = {
readonly items: readonly T[] | Property<readonly T[]>;
readonly to_label: (element: T) => string;
readonly related_element: HTMLElement;
};
export class Menu<T> extends Widget {
readonly element = el.div({ class: "core_Menu", tab_index: -1 });
readonly selected: WritableProperty<T | undefined>;
@ -18,11 +24,7 @@ export class Menu<T> extends Widget {
private hovered_index?: number;
private hovered_element?: HTMLElement;
constructor(
items: readonly T[] | Property<readonly T[]>,
to_label: (element: T) => string,
related_element: HTMLElement,
) {
constructor(options: MenuOptions<T>) {
super();
this.visible.val = false;
@ -33,9 +35,9 @@ export class Menu<T> 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<readonly T[]>);
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<T | undefined>(this, undefined, this.set_selected);
this.selected = this._selected;
@ -46,7 +48,10 @@ export class Menu<T> 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();

View File

@ -8,7 +8,9 @@ import { WidgetProperty } from "../observable/property/WidgetProperty";
import { Menu } from "./Menu";
export type SelectOptions<T> = LabelledControlOptions & {
selected?: T | Property<T>;
readonly items: readonly T[] | Property<readonly T[]>;
readonly to_label: (element: T) => string;
readonly selected?: T | Property<T>;
};
export class Select<T> extends LabelledControl {
@ -24,22 +26,24 @@ export class Select<T> extends LabelledControl {
private readonly _selected: WidgetProperty<T | undefined>;
private just_opened: boolean;
constructor(
items: readonly T[] | Property<readonly T[]>,
to_label: (element: T) => string,
options?: SelectOptions<T>,
) {
constructor(options: SelectOptions<T>) {
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<T>(items, to_label, this.element));
this.menu = this.disposable(
new Menu<T>({
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<T | undefined>(this, undefined, this.set_selected);

View File

@ -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<Undo>(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;
},
};

View File

@ -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:

View File

@ -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<T> 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<T>) {
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;

View File

@ -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<void> {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}

View File

@ -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 AreaAndLabel[]>;
readonly current_area: Property<AreaAndLabel>;
readonly can_save: Property<boolean>;
readonly can_undo: Property<boolean>;
readonly can_redo: Property<boolean>;
readonly can_select_area: Property<boolean>;
readonly can_debug: Property<boolean>;
readonly can_step: Property<boolean>;
readonly can_stop: Property<boolean>;
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<AreaAndLabel>(
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<AreaAndLabel>();
}
});
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<void> =>
this.quest_editor_store.set_quest(create_new_quest(this.area_store, episode));
// TODO: notify user of problems.
open_file = async (file: File): Promise<void> => {
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();
};
}

View File

@ -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.");
});

View File

@ -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<number>): 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<string>): 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<string>): 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<string>): 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();
}
};

View File

@ -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],
}),
);

View File

@ -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();
});

View File

@ -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]}`,
{
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<AreaModel>(
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<AreaModel>();
}
}),
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<AreaAndLabel>({
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();

View File

@ -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<GoldenLayout>;
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);
});
}

View File

@ -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);
}
});

View File

@ -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),
);

View File

@ -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,

View File

@ -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,
quest_editor_ui_persister,
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,
entity_image_renderer,
quest_editor_ui_persister,
create_three_renderer,
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),
);
}

View File

@ -87,31 +87,29 @@ export class QuestEditorUiPersister extends Persister {
break;
case "stack":
{
// 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.content == undefined || cfg.activeItemIndex >= cfg.content.length)
) {
cfg.activeItemIndex = undefined;
}
}
break;
}
if (config.content) {
config.content = config.content
.map(child => this.sanitize_layout_child(child, components, found))
.filter(item => item) as ItemConfigType[];
}
return config;
}

View File

@ -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 {

View File

@ -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;

View File

@ -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<string | undefined>(undefined);
private readonly _current_quest = property<QuestModel | undefined>(undefined);
private readonly _current_area = property<AreaModel | undefined>(undefined);
private readonly _selected_entity = property<QuestEntityModel | undefined>(undefined);
@ -43,7 +35,6 @@ export class QuestEditorStore implements Disposable {
readonly quest_runner: QuestRunner;
readonly debug: WritableProperty<boolean> = property(false);
readonly undo = new UndoStack();
readonly current_quest_filename: Property<string | undefined> = this._current_quest_filename;
readonly current_quest: Property<QuestModel | undefined> = this._current_quest;
readonly current_area: Property<AreaModel | undefined> = this._current_area;
readonly selected_entity: Property<QuestEntityModel | undefined> = this._selected_entity;
@ -114,49 +105,6 @@ export class QuestEditorStore implements Disposable {
this._selected_entity.val = entity;
};
new_quest = async (episode: Episode): Promise<void> =>
this.set_quest(create_new_quest(this.area_store, episode));
// TODO: notify user of problems.
open_file = async (file: File): Promise<void> => {
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<void> {
async set_quest(quest?: QuestModel): Promise<void> {
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);
}
};
}

View File

@ -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;
}

View File

@ -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);
}