diff --git a/src/core/gui/Control.ts b/src/core/gui/Control.ts index d6a97c19..8f509507 100644 --- a/src/core/gui/Control.ts +++ b/src/core/gui/Control.ts @@ -1,3 +1,5 @@ -import { Widget } from "./Widget"; +import { Widget, WidgetOptions } from "./Widget"; + +export type ControlOptions = WidgetOptions; export abstract class Control extends Widget {} diff --git a/src/core/gui/FileButton.ts b/src/core/gui/FileButton.ts index b4a9601f..6b7a5874 100644 --- a/src/core/gui/FileButton.ts +++ b/src/core/gui/FileButton.ts @@ -3,9 +3,14 @@ import "./FileButton.css"; import "./Button.css"; import { property } from "../observable"; import { Property } from "../observable/property/Property"; -import { Control } from "./Control"; +import { Control, ControlOptions } from "./Control"; import { WritableProperty } from "../observable/property/WritableProperty"; +export type FileButtonOptions = ControlOptions & { + accept?: string; + icon_left?: Icon; +}; + export class FileButton extends Control { readonly files: Property; @@ -15,11 +20,12 @@ export class FileButton extends Control { private readonly _files: WritableProperty = property([]); - constructor(text: string, options?: { accept?: string; icon_left?: Icon }) { + constructor(text: string, options?: FileButtonOptions) { super( create_element("label", { class: "core_FileButton core_Button", }), + options, ); this.files = this._files; @@ -64,4 +70,8 @@ export class FileButton extends Control { }), ); } + + click(): void { + this.input.click(); + } } diff --git a/src/core/gui/Menu.css b/src/core/gui/Menu.css index 26c7a4db..affa8779 100644 --- a/src/core/gui/Menu.css +++ b/src/core/gui/Menu.css @@ -17,4 +17,5 @@ .core_Menu .core_Menu_inner > *:hover { background-color: var(--control-bg-color-hover); + color: var(--control-text-color-hover); } diff --git a/src/core/stores/GuiStore.ts b/src/core/stores/GuiStore.ts index 47e48c03..ef02feb5 100644 --- a/src/core/stores/GuiStore.ts +++ b/src/core/stores/GuiStore.ts @@ -18,17 +18,51 @@ const STRING_TO_GUI_TOOL = new Map([...GUI_TOOL_TO_STRING.entries()].map(([k, v] class GuiStore implements Disposable { readonly tool: WritableProperty = property(GuiTool.Viewer); - private hash_disposer = this.tool.observe(({ value: tool }) => { + private readonly hash_disposer = this.tool.observe(({ value: tool }) => { window.location.hash = `#/${gui_tool_to_string(tool)}`; }); + private readonly global_keyup_handlers = new Map void>(); constructor() { const tool = window.location.hash.slice(2); this.tool.val = string_to_gui_tool(tool) || GuiTool.Viewer; + + window.addEventListener("keyup", this.dispatch_global_keyup); } dispose(): void { this.hash_disposer.dispose(); + this.global_keyup_handlers.clear(); + + window.removeEventListener("keyup", this.dispatch_global_keyup); + } + + on_global_keyup(tool: GuiTool, binding: string, handler: () => void): Disposable { + const key = this.handler_key(tool, binding); + this.global_keyup_handlers.set(key, handler); + + return { + dispose: () => { + this.global_keyup_handlers.delete(key); + }, + }; + } + + private dispatch_global_keyup = (e: KeyboardEvent) => { + const binding_parts: string[] = []; + if (e.ctrlKey) binding_parts.push("Ctrl"); + if (e.shiftKey) binding_parts.push("Shift"); + if (e.altKey) binding_parts.push("Alt"); + binding_parts.push(e.key.toUpperCase()); + + const binding = binding_parts.join("-"); + + const handler = this.global_keyup_handlers.get(this.handler_key(this.tool.val, binding)); + if (handler) handler(); + }; + + private handler_key(tool: GuiTool, binding: string): string { + return `${(GuiTool as any)[tool]} -> ${binding}`; } } diff --git a/src/old/quest_editor/domain/observable_quest_entities.ts b/src/old/quest_editor/domain/observable_quest_entities.ts deleted file mode 100644 index 73aa7f87..00000000 --- a/src/old/quest_editor/domain/observable_quest_entities.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { ObjectType } from "../../../core/data_formats/parsing/quest/object_types"; -import { action, computed, observable } from "mobx"; -import { Vec3 } from "../../../core/data_formats/vector"; -import { EntityType } from "../../../core/data_formats/parsing/quest/entities"; -import { SectionModel } from "../../../quest_editor/model/SectionModel"; -import { NpcType } from "../../../core/data_formats/parsing/quest/npc_types"; - -/** - * Abstract class from which ObservableQuestNpc and QuestObjectModel derive. - */ -export abstract class QuestEntityModel { - readonly type: Type; - - @observable area_id: number; - - private readonly _section_id: number; - - @computed get section_id(): number { - return this.section ? this.section.id : this._section_id; - } - - @observable.ref section?: SectionModel; - - /** - * Section-relative position - */ - @observable.ref position: Vec3; - - @observable.ref rotation: Vec3; - - /** - * World position - */ - @computed get world_position(): Vec3 { - if (this.section) { - let { x: rel_x, y: rel_y, z: rel_z } = this.position; - - const sin = -this.section.sin_y_axis_rotation; - const cos = this.section.cos_y_axis_rotation; - const rot_x = cos * rel_x - sin * rel_z; - const rot_z = sin * rel_x + cos * rel_z; - const x = rot_x + this.section.position.x; - const y = rel_y + this.section.position.y; - const z = rot_z + this.section.position.z; - return new Vec3(x, y, z); - } else { - return this.position; - } - } - - set world_position(pos: Vec3) { - let { x, y, z } = pos; - - if (this.section) { - const rel_x = x - this.section.position.x; - const rel_y = y - this.section.position.y; - const rel_z = z - this.section.position.z; - const sin = -this.section.sin_y_axis_rotation; - const cos = this.section.cos_y_axis_rotation; - const rot_x = cos * rel_x + sin * rel_z; - const rot_z = -sin * rel_x + cos * rel_z; - x = rot_x; - y = rel_y; - z = rot_z; - } - - this.position = new Vec3(x, y, z); - } - - protected constructor( - type: Type, - area_id: number, - section_id: number, - position: Vec3, - rotation: Vec3, - ) { - if (type == undefined) throw new Error("type is required."); - if (!Number.isInteger(area_id) || area_id < 0) - throw new Error(`Expected area_id to be a non-negative integer, got ${area_id}.`); - if (!Number.isInteger(section_id) || section_id < 0) - throw new Error(`Expected section_id to be a non-negative integer, got ${section_id}.`); - if (!position) throw new Error("position is required."); - if (!rotation) throw new Error("rotation is required."); - - this.type = type; - this.area_id = area_id; - this._section_id = section_id; - this.position = position; - this.rotation = rotation; - } - - @action - set_world_position_and_section(world_position: Vec3, section?: SectionModel): void { - this.world_position = world_position; - this.section = section; - } -} - -export class ObservableQuestObject extends QuestEntityModel { - readonly id: number; - readonly group_id: number; - - @observable private readonly properties: Map; - - /** - * @returns a copy of this object's type-specific properties. - */ - props(): Map { - return new Map(this.properties); - } - - get_prop(prop: string): number | undefined { - return this.properties.get(prop); - } - - @action - set_prop(prop: string, value: number): void { - if (!this.properties.has(prop)) throw new Error(`Object doesn't have property ${prop}.`); - - this.properties.set(prop, value); - } - - /** - * Data of which the purpose hasn't been discovered yet. - */ - readonly unknown: number[][]; - - constructor( - type: ObjectType, - id: number, - group_id: number, - area_id: number, - section_id: number, - position: Vec3, - rotation: Vec3, - properties: Map, - unknown: number[][], - ) { - super(type, area_id, section_id, position, rotation); - - this.id = id; - this.group_id = group_id; - this.properties = properties; - this.unknown = unknown; - } -} - -export class ObservableQuestNpc extends QuestEntityModel { - readonly pso_type_id: number; - readonly npc_id: number; - readonly script_label: number; - readonly roaming: number; - readonly scale: Vec3; - /** - * Data of which the purpose hasn't been discovered yet. - */ - readonly unknown: number[][]; - - constructor( - type: NpcType, - pso_type_id: number, - npc_id: number, - script_label: number, - roaming: number, - area_id: number, - section_id: number, - position: Vec3, - rotation: Vec3, - scale: Vec3, - unknown: number[][], - ) { - super(type, area_id, section_id, position, rotation); - - this.pso_type_id = pso_type_id; - this.npc_id = npc_id; - this.script_label = script_label; - this.roaming = roaming; - this.unknown = unknown; - this.scale = scale; - } -} diff --git a/src/old/quest_editor/stores/QuestEditorStore.ts b/src/old/quest_editor/stores/QuestEditorStore.ts deleted file mode 100644 index 3c85534e..00000000 --- a/src/old/quest_editor/stores/QuestEditorStore.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { action, observable } from "mobx"; -import { write_quest_qst } from "../../../core/data_formats/parsing/quest"; - -class QuestEditorStore { - @observable current_quest_filename?: string; - - @observable save_dialog_filename?: string; - @observable save_dialog_open: boolean = false; - - constructor() { - // application_store.on_global_keyup("quest_editor", "Ctrl-Z", () => { - // // Let Monaco handle its own key bindings. - // if (undo_manager.current !== this.script_undo) { - // undo_manager.undo(); - // } - // }); - // application_store.on_global_keyup("quest_editor", "Ctrl-Shift-Z", () => { - // // Let Monaco handle its own key bindings. - // if (undo_manager.current !== this.script_undo) { - // undo_manager.redo(); - // } - // }); - // application_store.on_global_keyup("quest_editor", "Ctrl-Alt-D", this.toggle_debug); - } - - @action - open_save_dialog = () => { - this.save_dialog_filename = this.current_quest_filename - ? this.current_quest_filename.endsWith(".qst") - ? this.current_quest_filename.slice(0, -4) - : this.current_quest_filename - : ""; - - this.save_dialog_open = true; - }; - - @action - close_save_dialog = () => { - this.save_dialog_open = false; - }; - - @action - set_save_dialog_filename = (filename: string) => { - this.save_dialog_filename = filename; - }; - - save_current_quest_to_file = (file_name: string) => { - const quest = this.current_quest; - - if (quest) { - const buffer = write_quest_qst( - { - id: quest.id, - language: quest.language, - name: quest.name, - short_description: quest.short_description, - long_description: quest.long_description, - episode: quest.episode, - objects: quest.objects.map(obj => ({ - type: obj.type, - area_id: obj.area_id, - section_id: obj.section_id, - position: obj.position, - rotation: obj.rotation, - unknown: obj.unknown, - id: obj.id, - group_id: obj.group_id, - properties: obj.props(), - })), - npcs: quest.npcs.map(npc => ({ - type: npc.type, - area_id: npc.area_id, - section_id: npc.section_id, - position: npc.position, - rotation: npc.rotation, - scale: npc.scale, - unknown: npc.unknown, - pso_type_id: npc.pso_type_id, - npc_id: npc.npc_id, - script_label: npc.script_label, - roaming: npc.roaming, - })), - dat_unknowns: quest.dat_unknowns, - object_code: quest.object_code, - shop_items: quest.shop_items, - map_designations: quest.map_designations, - }, - 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); - } - - this.save_dialog_open = false; - }; -} diff --git a/src/old/quest_editor/ui/Toolbar.css b/src/old/quest_editor/ui/Toolbar.css deleted file mode 100644 index eec59bd4..00000000 --- a/src/old/quest_editor/ui/Toolbar.css +++ /dev/null @@ -1,8 +0,0 @@ -.main { - display: flex; - padding: 6px 3px; -} - -.main > * { - margin: 0 3px !important; -} diff --git a/src/old/quest_editor/ui/Toolbar.tsx b/src/old/quest_editor/ui/Toolbar.tsx deleted file mode 100644 index 2e5b73b6..00000000 --- a/src/old/quest_editor/ui/Toolbar.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { Button, Dropdown, Form, Icon, Input, Menu, Modal, Select, Upload } from "antd"; -import { ClickParam } from "antd/lib/menu"; -import { UploadChangeParam, UploadFile } from "antd/lib/upload/interface"; -import { observer } from "mobx-react"; -import React, { ChangeEvent, Component, ReactNode } from "react"; -import { area_store } from "../stores/AreaStore"; -import { quest_editor_store } from "../stores/QuestEditorStore"; -import { undo_manager } from "../../core/undo"; -import styles from "./Toolbar.css"; -import { Episode } from "../../../core/data_formats/parsing/quest/Episode"; - -@observer -export class Toolbar extends Component { - render(): ReactNode { - const quest = quest_editor_store.current_quest; - - return ( -
- - Episode I - - } - trigger={["click"]} - > - - - false} - > - - - - - - - -
- ); - } - - private new_quest({ key }: ClickParam): void { - quest_editor_store.new_quest((Episode as any)[key]); - } - - private open_file(info: UploadChangeParam): void { - if (info.file.originFileObj) { - quest_editor_store.open_file(info.file.name, info.file.originFileObj as File); - } - } - - private undo(): void { - undo_manager.undo(); - } - - private redo(): void { - undo_manager.redo(); - } -} - -@observer -class AreaComponent extends Component { - render(): ReactNode { - const quest = quest_editor_store.current_quest; - const areas = quest ? area_store.get_areas_for_episode(quest.episode) : []; - const area = quest_editor_store.current_area; - - return ( - - ); - } -} - -@observer -class SaveQuestComponent extends Component { - render(): ReactNode { - return ( - - Save as... - - } - visible={quest_editor_store.save_dialog_open} - onOk={this.ok} - onCancel={this.cancel} - > -
- - - -
-
- ); - } - - private name_changed(e: ChangeEvent): void { - quest_editor_store.set_save_dialog_filename(e.currentTarget.value); - } - - private ok(): void { - quest_editor_store.save_current_quest_to_file( - quest_editor_store.save_dialog_filename || "untitled", - ); - } - - private cancel(): void { - quest_editor_store.close_save_dialog(); - } -} diff --git a/src/quest_editor/gui/QuestEditorToolBar.ts b/src/quest_editor/gui/QuestEditorToolBar.ts index 7ae92999..261e3a86 100644 --- a/src/quest_editor/gui/QuestEditorToolBar.ts +++ b/src/quest_editor/gui/QuestEditorToolBar.ts @@ -9,6 +9,9 @@ import { AreaModel } from "../model/AreaModel"; import { Icon } from "../../core/gui/dom"; import { DropDownButton } from "../../core/gui/DropDownButton"; import { Episode } from "../../core/data_formats/parsing/quest/Episode"; +import { area_store } from "../stores/AreaStore"; +import { gui_store, GuiTool } from "../../core/stores/GuiStore"; +import { asm_editor_store } from "../stores/AsmEditorStore"; export class QuestEditorToolBar extends ToolBar { constructor() { @@ -23,31 +26,45 @@ export class QuestEditorToolBar extends ToolBar { const open_file_button = new FileButton("Open file...", { icon_left: Icon.File, accept: ".qst", + tooltip: "Open a quest file (Ctrl-O)", + }); + const save_as_button = new Button("Save as...", { + icon_left: Icon.Save, + tooltip: "Save this quest to new file (Ctrl-Shift-S)", }); - const save_as_button = new Button("Save as...", { icon_left: Icon.Save }); const undo_button = new Button("Undo", { icon_left: Icon.Undo, - tooltip: undo_manager.first_undo.map(action => - action ? `Undo "${action.description}"` : "Nothing to undo", + tooltip: undo_manager.first_undo.map( + action => + (action ? `Undo "${action.description}"` : "Nothing to undo") + " (Ctrl-Z)", ), }); const redo_button = new Button("Redo", { icon_left: Icon.Redo, - tooltip: undo_manager.first_redo.map(action => - action ? `Redo "${action.description}"` : "Nothing to redo", + tooltip: undo_manager.first_redo.map( + action => + (action ? `Redo "${action.description}"` : "Nothing to redo") + + " (Ctrl-Shift-Z)", ), }); const area_select = new Select( quest_editor_store.current_quest.flat_map(quest => { if (quest) { - return quest.area_variants.map(variants => - variants.map(variant => variant.area), - ); + return array_property(...area_store.get_areas_for_episode(quest.episode)); } else { return array_property(); } }), - element => element.name, + 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; + } + }, ); super({ @@ -75,6 +92,7 @@ export class QuestEditorToolBar extends ToolBar { }), save_as_button.enabled.bind_to(quest_loaded), + save_as_button.click.observe(quest_editor_store.save_as), undo_button.enabled.bind_to(undo_manager.can_undo), undo_button.click.observe(() => undo_manager.undo()), @@ -87,6 +105,30 @@ export class QuestEditorToolBar extends ToolBar { area_select.selected.observe(({ value: area }) => quest_editor_store.set_current_area(area), ), + + gui_store.on_global_keyup(GuiTool.QuestEditor, "Ctrl-O", () => + open_file_button.click(), + ), + + gui_store.on_global_keyup( + GuiTool.QuestEditor, + "Ctrl-Shift-S", + quest_editor_store.save_as, + ), + + gui_store.on_global_keyup(GuiTool.QuestEditor, "Ctrl-Z", () => { + // Let Monaco handle its own key bindings. + if (undo_manager.current.val !== asm_editor_store.undo) { + undo_manager.undo(); + } + }), + + gui_store.on_global_keyup(GuiTool.QuestEditor, "Ctrl-Shift-Z", () => { + // Let Monaco handle its own key bindings. + if (undo_manager.current.val !== asm_editor_store.undo) { + undo_manager.redo(); + } + }), ); } } diff --git a/src/quest_editor/gui/QuestEditorView.ts b/src/quest_editor/gui/QuestEditorView.ts index 1bc7cde1..de3613ac 100644 --- a/src/quest_editor/gui/QuestEditorView.ts +++ b/src/quest_editor/gui/QuestEditorView.ts @@ -10,6 +10,8 @@ import { NpcCountsView } from "./NpcCountsView"; import { QuestRendererView } from "./QuestRendererView"; import { AsmEditorView } from "./AsmEditorView"; import { EntityInfoView } from "./EntityInfoView"; +import { gui_store, GuiTool } from "../../core/stores/GuiStore"; +import { quest_editor_store } from "../stores/QuestEditorStore"; import Logger = require("js-logger"); const logger = Logger.get("quest_editor/gui/QuestEditorView"); @@ -105,6 +107,14 @@ export class QuestEditorView extends ResizableWidget { this.element.append(this.tool_bar_view.element, this.layout_element); this.layout = this.init_golden_layout(); + + this.disposables( + gui_store.on_global_keyup( + GuiTool.QuestEditor, + "Ctrl-Alt-D", + () => (quest_editor_store.debug.val = !quest_editor_store.debug.val), + ), + ); } resize(width: number, height: number): this { diff --git a/src/quest_editor/model/QuestEntityModel.ts b/src/quest_editor/model/QuestEntityModel.ts index 21efca57..cb175b26 100644 --- a/src/quest_editor/model/QuestEntityModel.ts +++ b/src/quest_editor/model/QuestEntityModel.ts @@ -10,11 +10,9 @@ export abstract class QuestEntityModel { readonly area_id: number; - private readonly _section_id: WritableProperty; readonly section_id: Property; - private readonly _section: WritableProperty = property(undefined); - readonly section: Property = this._section; + readonly section: Property; set_section(section: SectionModel): this { this._section.val = section; @@ -25,14 +23,12 @@ export abstract class QuestEntityModel { /** * Section-relative position */ - private readonly _position: WritableProperty; readonly position: Property; set_position(position: Vec3): void { this._position.val = position; } - private readonly _rotation: WritableProperty; readonly rotation: Property; set_rotation(rotation: Vec3): void { @@ -65,6 +61,11 @@ export abstract class QuestEntityModel { return this; } + private readonly _section_id: WritableProperty; + private readonly _section: WritableProperty = property(undefined); + private readonly _position: WritableProperty; + private readonly _rotation: WritableProperty; + protected constructor( type: Type, area_id: number, @@ -74,6 +75,7 @@ export abstract class QuestEntityModel { ) { this.type = type; this.area_id = area_id; + this.section = this._section; this._section_id = property(section_id); this.section_id = this._section_id; this._position = property(position); diff --git a/src/quest_editor/model/QuestObjectModel.ts b/src/quest_editor/model/QuestObjectModel.ts index 30a495fa..7a46293c 100644 --- a/src/quest_editor/model/QuestObjectModel.ts +++ b/src/quest_editor/model/QuestObjectModel.ts @@ -5,6 +5,11 @@ import { Vec3 } from "../../core/data_formats/vector"; export class QuestObjectModel extends QuestEntityModel { readonly id: number; readonly group_id: number; + readonly properties: Map; + /** + * Data of which the purpose hasn't been discovered yet. + */ + readonly unknown: number[][]; constructor( type: ObjectType, @@ -21,5 +26,7 @@ export class QuestObjectModel extends QuestEntityModel { this.id = id; this.group_id = group_id; + this.properties = properties; + this.unknown = unknown; } } diff --git a/src/quest_editor/stores/QuestEditorStore.ts b/src/quest_editor/stores/QuestEditorStore.ts index f926579d..6372edff 100644 --- a/src/quest_editor/stores/QuestEditorStore.ts +++ b/src/quest_editor/stores/QuestEditorStore.ts @@ -2,7 +2,7 @@ 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 } from "../../core/data_formats/parsing/quest"; +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 { WritableProperty } from "../../core/observable/property/WritableProperty"; @@ -141,6 +141,66 @@ export class QuestEditorStore implements Disposable { } }; + save_as = () => { + const quest = this.current_quest.val; + if (!quest) return; + + let file_name = prompt("File name:"); + if (!file_name) return; + + const buffer = write_quest_qst( + { + id: quest.id.val, + language: quest.language.val, + name: quest.name.val, + short_description: quest.short_description.val, + long_description: quest.long_description.val, + episode: quest.episode, + objects: quest.objects.val.map(obj => ({ + type: obj.type, + area_id: obj.area_id, + section_id: obj.section_id.val, + position: obj.position.val, + rotation: obj.rotation.val, + unknown: obj.unknown, + id: obj.id, + group_id: obj.group_id, + properties: obj.properties, + })), + npcs: quest.npcs.val.map(npc => ({ + type: npc.type, + area_id: npc.area_id, + section_id: npc.section_id.val, + position: npc.position.val, + rotation: npc.rotation.val, + scale: npc.scale, + unknown: npc.unknown, + pso_type_id: npc.pso_type_id, + npc_id: npc.npc_id, + script_label: npc.script_label, + roaming: npc.roaming, + })), + dat_unknowns: quest.dat_unknowns, + object_code: quest.object_code, + shop_items: quest.shop_items, + map_designations: quest.map_designations.val, + }, + 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); + }; + push_edit_id_action = (event: PropertyChangeEvent) => { if (this.current_quest.val) { this.undo.push(new EditIdAction(this.current_quest.val, event)).redo();