diff --git a/src/core/rendering/Renderer.ts b/src/core/rendering/Renderer.ts index 83ece4f2..ceed3d1f 100644 --- a/src/core/rendering/Renderer.ts +++ b/src/core/rendering/Renderer.ts @@ -1,7 +1,6 @@ import CameraControls from "camera-controls"; import * as THREE from "three"; import { - Camera, Clock, Color, Group, @@ -34,8 +33,8 @@ export abstract class Renderer implements Disposable { this._debug = debug; } - readonly camera: Camera; - readonly controls: CameraControls; + abstract readonly camera: PerspectiveCamera | OrthographicCamera; + readonly controls!: CameraControls; readonly scene = new Scene(); readonly light_holder = new Group(); @@ -44,23 +43,19 @@ export abstract class Renderer implements Disposable { private animation_frame_handle?: number = undefined; private light = new HemisphereLight(0xffffff, 0x505050, 1.2); private controls_clock = new Clock(); + private size = new Vector2(); - protected constructor(camera: PerspectiveCamera | OrthographicCamera) { - this.camera = camera; - + protected constructor() { this.dom_element.tabIndex = 0; this.dom_element.addEventListener("mousedown", this.on_mouse_down); this.dom_element.style.outline = "none"; - this.controls = new CameraControls(camera, this.renderer.domElement); - this.controls.dampingFactor = 1; - this.controls.draggingDampingFactor = 1; - this.scene.background = new Color(0x181818); this.light_holder.add(this.light); this.scene.add(this.light_holder); this.renderer.setPixelRatio(window.devicePixelRatio); + this.renderer.getSize(this.size); } get dom_element(): HTMLElement { @@ -68,15 +63,13 @@ export abstract class Renderer implements Disposable { } set_size(width: number, height: number): void { + this.size.set(width, height); this.renderer.setSize(width, height); this.schedule_render(); } - pointer_pos_to_device_coords(e: MouseEvent): Vector2 { - const coords = this.renderer.getSize(new Vector2()); - coords.width = (e.offsetX / coords.width) * 2 - 1; - coords.height = (e.offsetY / coords.height) * -2 + 1; - return coords; + pointer_pos_to_device_coords(pos: Vector2): void { + pos.set((pos.x / this.size.width) * 2 - 1, (pos.y / this.size.height) * -2 + 1); } start_rendering(): void { @@ -108,6 +101,16 @@ export abstract class Renderer implements Disposable { dispose(): void { this.renderer.dispose(); + this.controls.dispose(); + } + + protected init_camera_controls(): void { + (this.controls as CameraControls) = new CameraControls( + this.camera, + this.renderer.domElement, + ); + this.controls.dampingFactor = 1; + this.controls.draggingDampingFactor = 1; } protected render(): void { diff --git a/src/quest_editor/actions/CreateEntityAction.ts b/src/quest_editor/actions/CreateEntityAction.ts new file mode 100644 index 00000000..10c6f205 --- /dev/null +++ b/src/quest_editor/actions/CreateEntityAction.ts @@ -0,0 +1,30 @@ +import { Action } from "../../core/undo/Action"; +import { QuestEntityModel } from "../model/QuestEntityModel"; +import { entity_data } from "../../core/data_formats/parsing/quest/entities"; +import { quest_editor_store } from "../stores/QuestEditorStore"; + +export class CreateEntityAction implements Action { + readonly description: string; + + constructor(private entity: QuestEntityModel) { + this.description = `Create ${entity_data(entity.type).name}`; + } + + undo(): void { + const quest = quest_editor_store.current_quest.val; + + if (quest) { + quest.remove_entity(this.entity); + } + } + + redo(): void { + const quest = quest_editor_store.current_quest.val; + + if (quest) { + quest.add_entity(this.entity); + + quest_editor_store.set_selected_entity(this.entity); + } + } +} diff --git a/src/quest_editor/model/QuestModel.ts b/src/quest_editor/model/QuestModel.ts index 4e5e351b..19e0ce02 100644 --- a/src/quest_editor/model/QuestModel.ts +++ b/src/quest_editor/model/QuestModel.ts @@ -12,6 +12,7 @@ import { area_store } from "../stores/AreaStore"; import { ListProperty } from "../../core/observable/property/list/ListProperty"; import { WritableListProperty } from "../../core/observable/property/list/WritableListProperty"; import { QuestEntityModel } from "./QuestEntityModel"; +import { entity_type_to_string } from "../../core/data_formats/parsing/quest/entities"; const logger = Logger.get("quest_editor/model/QuestModel"); @@ -111,6 +112,7 @@ export class QuestModel { private readonly _long_description: WritableProperty = property(""); private readonly _map_designations: WritableProperty>; private readonly _area_variants: WritableListProperty = list_property(); + private readonly _objects: WritableListProperty; private readonly _npcs: WritableListProperty; constructor( @@ -150,7 +152,8 @@ export class QuestModel { this.episode = episode; this._map_designations = property(map_designations); this.map_designations = this._map_designations; - this.objects = list_property(undefined, ...objects); + this._objects = list_property(undefined, ...objects); + this.objects = this._objects; this._npcs = list_property(undefined, ...npcs); this.npcs = this._npcs; this.dat_unknowns = dat_unknowns; @@ -179,15 +182,31 @@ export class QuestModel { this.map_designations.observe(this.update_area_variants); } + add_entity(entity: QuestEntityModel): void { + if (entity instanceof QuestObjectModel) { + this.add_object(entity); + } else if (entity instanceof QuestNpcModel) { + this.add_npc(entity); + } else { + throw new Error(`${entity_type_to_string(entity.type)} not supported.`); + } + } + + add_object(object: QuestObjectModel): void { + this._objects.push(object); + } + add_npc(npc: QuestNpcModel): void { this._npcs.push(npc); } remove_entity(entity: QuestEntityModel): void { - if (entity instanceof QuestNpcModel) { + if (entity instanceof QuestObjectModel) { + this._objects.remove(entity); + } else if (entity instanceof QuestNpcModel) { this._npcs.remove(entity); } else { - // TODO: objects + throw new Error(`${entity_type_to_string(entity.type)} not supported.`); } } diff --git a/src/quest_editor/rendering/QuestEntityControls.ts b/src/quest_editor/rendering/QuestEntityControls.ts index 4eccb013..16d728d3 100644 --- a/src/quest_editor/rendering/QuestEntityControls.ts +++ b/src/quest_editor/rendering/QuestEntityControls.ts @@ -61,6 +61,8 @@ export class QuestEntityControls implements Disposable { * Iff defined, the user is transforming the selected entity. */ private pick?: Pick; + private pointer_position = new Vector2(0, 0); + private pointer_device_position = new Vector2(0, 0); private last_pointer_position = new Vector2(0, 0); private moved_since_last_mouse_down = false; private disposer = new Disposer(); @@ -82,8 +84,6 @@ export class QuestEntityControls implements Disposable { ); renderer.dom_element.addEventListener("mousedown", this.mousedown); - renderer.dom_element.addEventListener("mousemove", this.mousemove); - renderer.dom_element.addEventListener("mouseup", this.mouseup); add_entity_dnd_listener(renderer.dom_element, "dragenter", this.dragenter); add_entity_dnd_listener(renderer.dom_element, "dragover", this.dragover); add_entity_dnd_listener(renderer.dom_element, "dragleave", this.dragleave); @@ -92,8 +92,8 @@ export class QuestEntityControls implements Disposable { dispose(): void { this.renderer.dom_element.removeEventListener("mousedown", this.mousedown); - this.renderer.dom_element.removeEventListener("mousemove", this.mousemove); - this.renderer.dom_element.removeEventListener("mouseup", this.mouseup); + document.removeEventListener("mousemove", this.doc_mousemove); + document.removeEventListener("mouseup", this.doc_mouseup); remove_entity_dnd_listener(this.renderer.dom_element, "dragenter", this.dragenter); remove_entity_dnd_listener(this.renderer.dom_element, "dragover", this.dragover); remove_entity_dnd_listener(this.renderer.dom_element, "dragleave", this.dragleave); @@ -120,9 +120,13 @@ export class QuestEntityControls implements Disposable { private mousedown = (e: MouseEvent) => { this.process_event(e); + + document.addEventListener("mouseup", this.doc_mouseup); + document.addEventListener("mousemove", this.doc_mousemove); + this.stop_transforming(); - const new_pick = this.pick_entity(this.renderer.pointer_pos_to_device_coords(e)); + const new_pick = this.pick_entity(this.pointer_device_position); if (new_pick) { // Disable camera controls while the user is transforming an entity. @@ -137,11 +141,9 @@ export class QuestEntityControls implements Disposable { this.renderer.schedule_render(); }; - private mousemove = (e: MouseEvent) => { + private doc_mousemove = (e: MouseEvent) => { this.process_event(e); - const pointer_device_pos = this.renderer.pointer_pos_to_device_coords(e); - if (this.selected && this.pick) { if (this.moved_since_last_mouse_down) { // User is transforming selected entity. @@ -151,7 +153,7 @@ export class QuestEntityControls implements Disposable { } } else { // User is hovering. - const new_pick = this.pick_entity(pointer_device_pos); + const new_pick = this.pick_entity(this.pointer_device_position); if (this.mark_hovered(new_pick)) { this.renderer.schedule_render(); @@ -159,10 +161,12 @@ export class QuestEntityControls implements Disposable { } }; - // TODO: deal with mouseup outside of 3D-view - private mouseup = (e: MouseEvent) => { + private doc_mouseup = (e: MouseEvent) => { this.process_event(e); + document.removeEventListener("mousemove", this.doc_mousemove); + document.removeEventListener("mouseup", this.doc_mouseup); + if (!this.moved_since_last_mouse_down && !this.pick) { // If the user clicks on nothing, deselect the currently selected entity. this.deselect(); @@ -176,6 +180,8 @@ export class QuestEntityControls implements Disposable { }; private dragenter = (e: EntityDragEvent) => { + this.process_event(e.event); + const area = quest_editor_store.current_area.val; const quest = quest_editor_store.current_quest.val; @@ -208,7 +214,7 @@ export class QuestEntityControls implements Disposable { ); const grab_offset = new Vector3(0, 0, 0); const drag_adjust = new Vector3(0, 0, 0); - this.translate_entity_horizontally(npc, e.event, grab_offset, drag_adjust); + this.translate_entity_horizontally(npc, grab_offset, drag_adjust); quest.add_npc(npc); quest_editor_store.set_selected_entity(npc); @@ -224,6 +230,8 @@ export class QuestEntityControls implements Disposable { }; private dragover = (e: EntityDragEvent) => { + this.process_event(e.event); + if (!quest_editor_store.current_area.val) return; if (this.pick && this.pick.mode === PickMode.Creating) { @@ -242,6 +250,8 @@ export class QuestEntityControls implements Disposable { }; private dragleave = (e: EntityDragEvent) => { + this.process_event(e.event); + if (!quest_editor_store.current_area.val) return; e.drag_element.style.display = "flex"; @@ -253,24 +263,31 @@ export class QuestEntityControls implements Disposable { } }; - private drop = () => { - // TODO: push onto undo stack. - this.pick = undefined; + private drop = (e: EntityDragEvent) => { + this.process_event(e.event); + + if (this.selected && this.pick && this.pick.mode === PickMode.Creating) { + quest_editor_store.push_create_entity_action(this.selected.entity); + + this.pick = undefined; + } }; private process_event(e: MouseEvent): void { + const { left, top } = this.renderer.dom_element.getBoundingClientRect(); + this.pointer_position.set(e.clientX - left, e.clientY - top); + this.pointer_device_position.copy(this.pointer_position); + this.renderer.pointer_pos_to_device_coords(this.pointer_device_position); + if (e.type === "mousedown") { this.moved_since_last_mouse_down = false; - } else { - if ( - e.offsetX !== this.last_pointer_position.x || - e.offsetY !== this.last_pointer_position.y - ) { + } else if (e.type === "mousemove" || e.type === "mouseup") { + if (!this.pointer_position.equals(this.last_pointer_position)) { this.moved_since_last_mouse_down = true; } } - this.last_pointer_position.set(e.offsetX, e.offsetY); + this.last_pointer_position.copy(this.pointer_position); } /** @@ -330,20 +347,10 @@ export class QuestEntityControls implements Disposable { private translate_entity(e: MouseEvent, selected: Highlighted, pick: Pick): void { if (e.shiftKey) { // Vertical movement. - this.translate_entity_vertically( - selected.entity, - e, - pick.drag_adjust, - pick.grab_offset, - ); + this.translate_entity_vertically(selected.entity, pick.drag_adjust, pick.grab_offset); } else { // Horizontal movement across the ground. - this.translate_entity_horizontally( - selected.entity, - e, - pick.drag_adjust, - pick.grab_offset, - ); + this.translate_entity_horizontally(selected.entity, pick.drag_adjust, pick.grab_offset); } this.renderer.schedule_render(); @@ -351,15 +358,12 @@ export class QuestEntityControls implements Disposable { private translate_entity_vertically( entity: QuestEntityModel, - e: MouseEvent, drag_adjust: Vector3, grab_offset: Vector3, ): void { - const pointer_position = this.renderer.pointer_pos_to_device_coords(e); - // We intersect with a plane that's oriented toward the camera and that's coplanar with the // point where the entity was grabbed. - this.raycaster.setFromCamera(pointer_position, this.renderer.camera); + this.raycaster.setFromCamera(this.pointer_device_position, this.renderer.camera); const ray = this.raycaster.ray; const negative_world_dir = this.renderer.camera.getWorldDirection(new Vector3()).negate(); @@ -386,14 +390,14 @@ export class QuestEntityControls implements Disposable { */ private translate_entity_horizontally( entity: QuestEntityModel, - e: MouseEvent, drag_adjust: Vector3, grab_offset: Vector3, ): void { - const pointer_position = this.renderer.pointer_pos_to_device_coords(e); - // Cast ray adjusted for dragging entities. - const { intersection, section } = this.pick_ground(pointer_position, drag_adjust); + const { intersection, section } = this.pick_ground( + this.pointer_device_position, + drag_adjust, + ); if (intersection) { entity.set_world_position( @@ -410,7 +414,7 @@ export class QuestEntityControls implements Disposable { } else { // If the pointer is not over the ground, we translate the entity across the horizontal // plane in which the entity's origin lies. - this.raycaster.setFromCamera(pointer_position, this.renderer.camera); + this.raycaster.setFromCamera(this.pointer_device_position, this.renderer.camera); const ray = this.raycaster.ray; const plane = new Plane( new Vector3(0, 1, 0), diff --git a/src/quest_editor/rendering/QuestRenderer.ts b/src/quest_editor/rendering/QuestRenderer.ts index c3095684..df4980c9 100644 --- a/src/quest_editor/rendering/QuestRenderer.ts +++ b/src/quest_editor/rendering/QuestRenderer.ts @@ -8,6 +8,13 @@ import { QuestEntityControls } from "./QuestEntityControls"; import { EntityUserData } from "./conversion/entities"; export class QuestRenderer extends Renderer { + private _collision_geometry = new Object3D(); + private _render_geometry = new Object3D(); + private _entity_models = new Object3D(); + private readonly disposer = new Disposer(); + private readonly entity_to_mesh = new Map(); + private readonly entity_controls = this.disposer.add(new QuestEntityControls(this)); + get debug(): boolean { return super.debug; } @@ -20,7 +27,7 @@ export class QuestRenderer extends Renderer { } } - private _collision_geometry = new Object3D(); + readonly camera = new PerspectiveCamera(60, 1, 10, 10000); get collision_geometry(): Object3D { return this._collision_geometry; @@ -32,8 +39,6 @@ export class QuestRenderer extends Renderer { this.scene.add(collision_geometry); } - private _render_geometry = new Object3D(); - set render_geometry(render_geometry: Object3D) { this.scene.remove(this._render_geometry); this._render_geometry = render_geometry; @@ -41,27 +46,24 @@ export class QuestRenderer extends Renderer { this.scene.add(render_geometry); } - private _entity_models = new Object3D(); - get entity_models(): Object3D { return this._entity_models; } - private readonly disposer = new Disposer(); - private readonly perspective_camera: PerspectiveCamera; - private readonly entity_to_mesh = new Map(); - private readonly entity_controls = this.disposer.add(new QuestEntityControls(this)); - constructor() { - super(new PerspectiveCamera(60, 1, 10, 10000)); - - this.perspective_camera = this.camera as PerspectiveCamera; + super(); this.disposer.add_all( new QuestModelManager(this), quest_editor_store.debug.observe(({ value }) => (this.debug = value)), ); + + // Initialize camera-controls after QuestEntityControls to ensure correct order of event + // listener registration. This is a fragile work-around for the fact that camera-controls + // doesn't support intercepting pointer events. + this.init_camera_controls(); + } dispose(): void { @@ -70,8 +72,8 @@ export class QuestRenderer extends Renderer { } set_size(width: number, height: number): void { - this.perspective_camera.aspect = width / height; - this.perspective_camera.updateProjectionMatrix(); + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); super.set_size(width, height); } @@ -99,6 +101,7 @@ export class QuestRenderer extends Renderer { const mesh = this.entity_to_mesh.get(entity); if (mesh) { + this.entity_to_mesh.delete(entity); this._entity_models.remove(mesh); this.schedule_render(); } diff --git a/src/quest_editor/stores/QuestEditorStore.ts b/src/quest_editor/stores/QuestEditorStore.ts index e05cb80d..986c51be 100644 --- a/src/quest_editor/stores/QuestEditorStore.ts +++ b/src/quest_editor/stores/QuestEditorStore.ts @@ -25,6 +25,7 @@ import { EditIdAction } from "../actions/EditIdAction"; import { Episode } from "../../core/data_formats/parsing/quest/Episode"; import { create_new_quest } from "./quest_creation"; import Logger = require("js-logger"); +import { CreateEntityAction } from "../actions/CreateEntityAction"; const logger = Logger.get("quest_editor/gui/QuestEditorStore"); @@ -269,6 +270,10 @@ export class QuestEditorStore implements Disposable { .redo(); }; + push_create_entity_action = (entity: QuestEntityModel) => { + this.undo.push(new CreateEntityAction(entity)); + }; + private async set_quest(quest?: QuestModel, filename?: string): Promise { this.undo.reset(); diff --git a/src/viewer/rendering/Model3DRenderer.ts b/src/viewer/rendering/Model3DRenderer.ts index fc04adde..b6be4e30 100644 --- a/src/viewer/rendering/Model3DRenderer.ts +++ b/src/viewer/rendering/Model3DRenderer.ts @@ -27,7 +27,6 @@ import { Disposer } from "../../core/observable/Disposer"; import { ChangeEvent } from "../../core/observable/Observable"; export class Model3DRenderer extends Renderer implements Disposable { - private readonly perspective_camera: PerspectiveCamera; private readonly disposer = new Disposer(); private readonly clock = new Clock(); private mesh?: Object3D; @@ -39,10 +38,10 @@ export class Model3DRenderer extends Renderer implements Disposable { }; private update_animation_time = true; - constructor() { - super(new PerspectiveCamera(75, 1, 1, 200)); + readonly camera = new PerspectiveCamera(75, 1, 1, 200); - this.perspective_camera = this.camera as PerspectiveCamera; + constructor() { + super(); this.disposer.add_all( model_store.current_nj_data.observe(this.nj_data_or_xvm_changed), @@ -53,11 +52,13 @@ export class Model3DRenderer extends Renderer implements Disposable { model_store.animation_frame_rate.observe(this.animation_frame_rate_changed), model_store.animation_frame.observe(this.animation_frame_changed), ); + + this.init_camera_controls(); } set_size(width: number, height: number): void { - this.perspective_camera.aspect = width / height; - this.perspective_camera.updateProjectionMatrix(); + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); super.set_size(width, height); } @@ -71,7 +72,7 @@ export class Model3DRenderer extends Renderer implements Disposable { this.animation.mixer.update(this.clock.getDelta()); } - this.light_holder.quaternion.copy(this.perspective_camera.quaternion); + this.light_holder.quaternion.copy(this.camera.quaternion); super.render(); if (this.animation && !this.animation.action.paused) { diff --git a/src/viewer/rendering/TextureRenderer.ts b/src/viewer/rendering/TextureRenderer.ts index 4fc287c9..975237f8 100644 --- a/src/viewer/rendering/TextureRenderer.ts +++ b/src/viewer/rendering/TextureRenderer.ts @@ -12,24 +12,19 @@ import { Renderer } from "../../core/rendering/Renderer"; import { Disposer } from "../../core/observable/Disposer"; import { Xvm } from "../../core/data_formats/parsing/ninja/texture"; import { xvm_texture_to_texture } from "../../core/rendering/conversion/ninja_textures"; -import Logger = require("js-logger"); import { texture_store } from "../stores/TextureStore"; +import Logger = require("js-logger"); const logger = Logger.get("viewer/rendering/TextureRenderer"); export class TextureRenderer extends Renderer implements Disposable { - private readonly ortho_camera: OrthographicCamera; private readonly disposer = new Disposer(); private readonly quad_meshes: Mesh[] = []; + readonly camera = new OrthographicCamera(-400, 400, 300, -300, 1, 10); + constructor() { - super(new OrthographicCamera(-400, 400, 300, -300, 1, 10)); - - this.ortho_camera = this.camera as OrthographicCamera; - this.controls.dollySpeed = -1; - - this.controls.azimuthRotateSpeed = 0; - this.controls.polarRotateSpeed = 0; + super(); this.disposer.add_all( texture_store.current_xvm.observe(({ value: xvm }) => { @@ -43,14 +38,19 @@ export class TextureRenderer extends Renderer implements Disposable { this.schedule_render(); }), ); + + this.init_camera_controls(); + this.controls.dollySpeed = -1; + this.controls.azimuthRotateSpeed = 0; + this.controls.polarRotateSpeed = 0; } set_size(width: number, height: number): void { - this.ortho_camera.left = -Math.floor(width / 2); - this.ortho_camera.right = Math.ceil(width / 2); - this.ortho_camera.top = Math.floor(height / 2); - this.ortho_camera.bottom = -Math.ceil(height / 2); - this.ortho_camera.updateProjectionMatrix(); + this.camera.left = -Math.floor(width / 2); + this.camera.right = Math.ceil(width / 2); + this.camera.top = Math.floor(height / 2); + this.camera.bottom = -Math.ceil(height / 2); + this.camera.updateProjectionMatrix(); super.set_size(width, height); }