import { QuestEntityModel } from "../model/QuestEntityModel"; import { Euler, Intersection, Mesh, MeshLambertMaterial, Plane, Quaternion, Raycaster, Vector2, Vector3, } from "three"; import { QuestRenderer } from "./QuestRenderer"; import { quest_editor_store } from "../stores/QuestEditorStore"; import { ColorType, EntityUserData, NPC_COLORS, OBJECT_COLORS } from "./conversion/entities"; import { QuestNpcModel } from "../model/QuestNpcModel"; import { AreaUserData } from "./conversion/areas"; import { SectionModel } from "../model/SectionModel"; import { Disposable } from "../../core/observable/Disposable"; import { Disposer } from "../../core/observable/Disposer"; import { EntityType, is_npc_type } from "../../core/data_formats/parsing/quest/entities"; import { npc_data } from "../../core/data_formats/parsing/quest/npc_types"; import { add_entity_dnd_listener, EntityDragEvent, remove_entity_dnd_listener, } from "../gui/entity_dnd"; import { QuestObjectModel } from "../model/QuestObjectModel"; import { AreaModel } from "../model/AreaModel"; import { QuestModel } from "../model/QuestModel"; const ZERO_VECTOR = Object.freeze(new Vector3(0, 0, 0)); const UP_VECTOR = Object.freeze(new Vector3(0, 1, 0)); const DOWN_VECTOR = Object.freeze(new Vector3(0, -1, 0)); const PI2 = 2 * Math.PI; const raycaster = new Raycaster(); export class QuestEntityControls implements Disposable { private readonly disposer = new Disposer(); private readonly pointer_position = new Vector2(0, 0); private readonly pointer_device_position = new Vector2(0, 0); private readonly last_pointer_position = new Vector2(0, 0); private moved_since_last_pointer_down = false; private state: State; private hovered_mesh?: Mesh; private selected_mesh?: Mesh; constructor(private renderer: QuestRenderer) { this.disposer.add(quest_editor_store.selected_entity.observe(this.selected_entity_changed)); renderer.dom_element.addEventListener("keydown", this.keydown); renderer.dom_element.addEventListener("mousedown", this.mousedown); renderer.dom_element.addEventListener("mousemove", this.mousemove); 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); add_entity_dnd_listener(renderer.dom_element, "drop", this.drop); this.state = new IdleState(renderer); } dispose = (): void => { this.renderer.dom_element.removeEventListener("keydown", this.keydown); this.renderer.dom_element.removeEventListener("mousedown", this.mousedown); this.renderer.dom_element.removeEventListener("mousemove", this.mousemove); document.removeEventListener("mousemove", this.mousemove); document.removeEventListener("mouseup", this.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); remove_entity_dnd_listener(this.renderer.dom_element, "drop", this.drop); this.disposer.dispose(); }; mark_selected = (entity_mesh: Mesh): void => { if (entity_mesh === this.hovered_mesh) { this.hovered_mesh = undefined; } if (entity_mesh !== this.selected_mesh) { if (this.selected_mesh) { set_color(this.selected_mesh, ColorType.Normal); } set_color(entity_mesh, ColorType.Selected); this.renderer.schedule_render(); } this.selected_mesh = entity_mesh; }; private selected_entity_changed = ({ value: entity }: { value?: QuestEntityModel }) => { this.state.cancel(); if (entity) { const mesh = this.renderer.get_entity_mesh(entity); // Mesh might not be loaded yet. if (mesh) { this.mark_selected(mesh); } else { this.unmark_selected(); } } else { this.unmark_selected(); } }; private keydown = (e: KeyboardEvent) => { this.state = this.state.process_event({ type: EvtType.KeyDown, key: e.key, }); }; private mousedown = (e: MouseEvent) => { this.process_mouse_event(e); this.state = this.state.process_event({ type: EvtType.MouseDown, buttons: e.buttons, shift_key: e.shiftKey, pointer_device_position: this.pointer_device_position, moved_since_last_pointer_down: this.moved_since_last_pointer_down, mark_hovered: this.mark_hovered, }); this.renderer.dom_element.removeEventListener("mousemove", this.mousemove); document.addEventListener("mousemove", this.mousemove); document.addEventListener("mouseup", this.mouseup); }; private mousemove = (e: MouseEvent) => { this.process_mouse_event(e); this.state = this.state.process_event({ type: EvtType.MouseMove, buttons: e.buttons, shift_key: e.shiftKey, pointer_device_position: this.pointer_device_position, moved_since_last_pointer_down: this.moved_since_last_pointer_down, mark_hovered: this.mark_hovered, }); }; private mouseup = (e: MouseEvent) => { this.process_mouse_event(e); this.state = this.state.process_event({ type: EvtType.MouseUp, buttons: e.buttons, shift_key: e.shiftKey, pointer_device_position: this.pointer_device_position, moved_since_last_pointer_down: this.moved_since_last_pointer_down, mark_hovered: this.mark_hovered, }); this.renderer.dom_element.addEventListener("mousemove", this.mousemove); document.removeEventListener("mousemove", this.mousemove); document.removeEventListener("mouseup", this.mouseup); }; private dragenter = (e: EntityDragEvent) => { this.process_mouse_event(e.event); this.state = this.state.process_event({ type: EvtType.EntityDragEnter, shift_key: e.event.shiftKey, pointer_device_position: this.pointer_device_position, entity_type: e.entity_type, drag_element: e.drag_element, data_transfer: e.event.dataTransfer, prevent_default: () => e.event.preventDefault(), stop_propagation: () => e.event.stopPropagation(), }); }; private dragover = (e: EntityDragEvent) => { this.process_mouse_event(e.event); this.state = this.state.process_event({ type: EvtType.EntityDragOver, shift_key: e.event.shiftKey, pointer_device_position: this.pointer_device_position, entity_type: e.entity_type, drag_element: e.drag_element, data_transfer: e.event.dataTransfer, prevent_default: () => e.event.preventDefault(), stop_propagation: () => e.event.stopPropagation(), }); }; private dragleave = (e: EntityDragEvent) => { this.process_mouse_event(e.event); this.state = this.state.process_event({ type: EvtType.EntityDragLeave, shift_key: e.event.shiftKey, pointer_device_position: this.pointer_device_position, entity_type: e.entity_type, drag_element: e.drag_element, data_transfer: e.event.dataTransfer, prevent_default: () => e.event.preventDefault(), stop_propagation: () => e.event.stopPropagation(), }); }; private drop = (e: EntityDragEvent) => { this.process_mouse_event(e.event); this.state = this.state.process_event({ type: EvtType.EntityDrop, }); }; private process_mouse_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_pointer_down = false; } else if (e.type === "mousemove" || e.type === "mouseup") { if (!this.pointer_position.equals(this.last_pointer_position)) { this.moved_since_last_pointer_down = true; } } this.last_pointer_position.copy(this.pointer_position); } private mark_hovered = (entity_mesh?: Mesh): void => { if (!this.selected_mesh || entity_mesh !== this.selected_mesh) { if (entity_mesh !== this.hovered_mesh) { if (this.hovered_mesh) { set_color(this.hovered_mesh, ColorType.Normal); } if (entity_mesh) { set_color(entity_mesh, ColorType.Hovered); } this.renderer.schedule_render(); } this.hovered_mesh = entity_mesh; } }; private unmark_selected(): void { if (this.selected_mesh) { set_color(this.selected_mesh, ColorType.Normal); this.renderer.schedule_render(); } this.selected_mesh = undefined; } } type Pick = { entity: QuestEntityModel; mesh: Mesh; /** * Vector that points from the grabbing point to the model's origin. */ grab_offset: Vector3; /** * Vector that points from the grabbing point to the terrain point directly under the model's origin. */ drag_adjust: Vector3; }; enum EvtType { KeyDown, MouseDown, MouseMove, MouseUp, EntityDragEnter, EntityDragOver, EntityDragLeave, EntityDrop, } type Evt = KeyboardEvt | MouseEvt | EntityDragEvt | EntityDropEvt; type KeyboardEvt = { readonly type: EvtType.KeyDown; readonly key: string; }; type MouseEvt = { readonly type: EvtType.MouseDown | EvtType.MouseMove | EvtType.MouseUp; readonly buttons: number; readonly shift_key: boolean; readonly pointer_device_position: Vector2; readonly moved_since_last_pointer_down: boolean; mark_hovered(entity_mesh?: Mesh): void; }; type EntityDragEvt = { readonly type: EvtType.EntityDragEnter | EvtType.EntityDragOver | EvtType.EntityDragLeave; readonly shift_key: boolean; readonly pointer_device_position: Vector2; readonly entity_type: EntityType; readonly drag_element: HTMLElement; readonly data_transfer: DataTransfer | null; stop_propagation(): void; prevent_default(): void; }; type EntityDropEvt = { readonly type: EvtType.EntityDrop; }; interface State { process_event(evt: Evt): State; /** * The state object should stop doing what it's doing and revert to the idle state as soon as * possible. */ cancel(): void; } class IdleState implements State { private readonly renderer: QuestRenderer; constructor(renderer: QuestRenderer) { this.renderer = renderer; } process_event(evt: Evt): State { switch (evt.type) { case EvtType.KeyDown: { const entity = quest_editor_store.selected_entity.val; if (entity && evt.key === "Delete") { quest_editor_store.remove_entity(entity); } return this; } case EvtType.MouseDown: { const pick = this.pick_entity(evt.pointer_device_position); if (pick) { if (evt.buttons === 1) { quest_editor_store.set_selected_entity(pick.entity); return new TranslationState( this.renderer, pick.entity, pick.drag_adjust, pick.grab_offset, ); } else if (evt.buttons === 2) { quest_editor_store.set_selected_entity(pick.entity); return new RotationState( this.renderer, pick.entity, pick.mesh, pick.grab_offset, ); } } return this; } case EvtType.MouseMove: { // User is hovering. const pick_result = this.pick_entity(evt.pointer_device_position); evt.mark_hovered(pick_result && pick_result.mesh); return this; } case EvtType.MouseUp: { // If the user clicks on nothing, deselect the currently selected entity. if (!evt.moved_since_last_pointer_down) { quest_editor_store.set_selected_entity(undefined); } return this; } case EvtType.EntityDragEnter: { const area = quest_editor_store.current_area.val; const quest = quest_editor_store.current_quest.val; if (!area || !quest) return this; return new CreationState(this.renderer, evt, quest, area); } default: return this; } } cancel(): void { // Do nothing. } /** * @param pointer_position - pointer coordinates in normalized device space */ private pick_entity(pointer_position: Vector2): Pick | undefined { // Find the nearest object and NPC under the pointer. raycaster.setFromCamera(pointer_position, this.renderer.camera); const [intersection] = raycaster.intersectObjects(this.renderer.entity_models.children); if (!intersection) { return undefined; } const entity = (intersection.object.userData as EntityUserData).entity; const grab_offset = intersection.object.position.clone().sub(intersection.point); const drag_adjust = grab_offset.clone(); // Find vertical distance to the ground. raycaster.set(intersection.object.position, DOWN_VECTOR); const [collision_geom_intersection] = raycaster.intersectObjects( this.renderer.collision_geometry.children, true, ); if (collision_geom_intersection) { drag_adjust.y -= collision_geom_intersection.distance; } return { mesh: intersection.object as Mesh, entity, grab_offset, drag_adjust, }; } } class TranslationState implements State { private readonly initial_section: SectionModel | undefined; private readonly initial_position: Vector3; private cancelled = false; constructor( private readonly renderer: QuestRenderer, private readonly entity: QuestEntityModel, private readonly drag_adjust: Vector3, private readonly grab_offset: Vector3, ) { this.initial_section = entity.section.val; this.initial_position = entity.world_position.val; this.renderer.controls.enabled = false; } process_event(evt: Evt): State { switch (evt.type) { case EvtType.MouseMove: { if (this.cancelled) { return new IdleState(this.renderer); } if (evt.moved_since_last_pointer_down) { translate_entity( this.renderer, this.entity, this.drag_adjust, this.grab_offset, evt.pointer_device_position, evt.shift_key, ); } return this; } case EvtType.MouseUp: { this.renderer.controls.enabled = true; if (!this.cancelled && evt.moved_since_last_pointer_down) { quest_editor_store.translate_entity( this.entity, this.initial_section, this.entity.section.val, this.initial_position, this.entity.world_position.val, true, ); } return new IdleState(this.renderer); } default: return this.cancelled ? new IdleState(this.renderer) : this; } } cancel(): void { this.cancelled = true; this.renderer.controls.enabled = true; if (this.initial_section) { this.entity.set_section(this.initial_section); } this.entity.set_world_position(this.initial_position); } } // TODO: make entities rotatable with right mouse button. class RotationState implements State { private readonly initial_rotation: Euler; private readonly grab_point: Vector3; private cancelled = false; constructor( private readonly renderer: QuestRenderer, private readonly entity: QuestEntityModel, private readonly mesh: Mesh, grab_offset: Vector3, ) { this.initial_rotation = entity.world_rotation.val; this.grab_point = entity.world_position.val.clone().sub(grab_offset); this.renderer.controls.enabled = false; } process_event(evt: Evt): State { switch (evt.type) { case EvtType.MouseMove: { if (this.cancelled) { return new IdleState(this.renderer); } if (evt.moved_since_last_pointer_down) { rotate_entity( this.renderer, this.entity, this.mesh.quaternion, this.initial_rotation, this.grab_point, evt.pointer_device_position, ); } return this; } case EvtType.MouseUp: { this.renderer.controls.enabled = true; if (!this.cancelled && evt.moved_since_last_pointer_down) { quest_editor_store.rotate_entity( this.entity, this.initial_rotation, this.entity.world_rotation.val, true, ); } return new IdleState(this.renderer); } default: return this.cancelled ? new IdleState(this.renderer) : this; } } cancel(): void { this.cancelled = true; this.renderer.controls.enabled = true; this.entity.set_world_rotation(this.initial_rotation); } } class CreationState implements State { private readonly renderer: QuestRenderer; private readonly entity: QuestEntityModel; private readonly drag_adjust = new Vector3(0, 0, 0); private cancelled = false; constructor(renderer: QuestRenderer, evt: EntityDragEvt, quest: QuestModel, area: AreaModel) { this.renderer = renderer; evt.drag_element.style.display = "none"; if (evt.data_transfer) { evt.data_transfer.dropEffect = "copy"; } if (is_npc_type(evt.entity_type)) { const data = npc_data(evt.entity_type); this.entity = new QuestNpcModel( evt.entity_type, data.pso_type_id!, 0, 0, data.pso_roaming!, area.id, 0, new Vector3(0, 0, 0), new Euler(0, 0, 0, "ZXY"), new Vector3(1, 1, 1), // TODO: do the following values make sense? [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0]], ); } else { this.entity = new QuestObjectModel( evt.entity_type, 0, 0, area.id, 0, new Vector3(0, 0, 0), new Euler(0, 0, 0, "ZXY"), // TODO: which default properties? new Map(), // TODO: do the following values make sense? [[0, 0, 0, 0, 0, 0], [0, 0]], ); } translate_entity_horizontally( this.renderer, this.entity, this.drag_adjust, ZERO_VECTOR, evt.pointer_device_position, ); quest.add_entity(this.entity); quest_editor_store.set_selected_entity(this.entity); } process_event(evt: Evt): State { switch (evt.type) { case EvtType.EntityDragOver: { if (this.cancelled) { evt.drag_element.style.display = "flex"; if (evt.data_transfer) { evt.data_transfer.dropEffect = "none"; } return new IdleState(this.renderer); } evt.stop_propagation(); evt.prevent_default(); if (evt.data_transfer) { evt.data_transfer.dropEffect = "copy"; } // Only translation is possible while dragging in a new entity. translate_entity( this.renderer, this.entity, this.drag_adjust, ZERO_VECTOR, evt.pointer_device_position, evt.shift_key, ); return this; } case EvtType.EntityDragLeave: { evt.drag_element.style.display = "flex"; const quest = quest_editor_store.current_quest.val; if (quest) { quest.remove_entity(this.entity); } return new IdleState(this.renderer); } case EvtType.EntityDrop: { if (!this.cancelled) { quest_editor_store.push_create_entity_action(this.entity); } return new IdleState(this.renderer); } default: return this; } } cancel(): void { this.cancelled = true; const quest = quest_editor_store.current_quest.val; if (quest) { quest.remove_entity(this.entity); } } } function set_color(mesh: Mesh, type: ColorType): void { const entity = (mesh.userData as EntityUserData).entity; const color = entity instanceof QuestNpcModel ? NPC_COLORS[type] : OBJECT_COLORS[type]; if (mesh) { if (Array.isArray(mesh.material)) { for (const mat of mesh.material as MeshLambertMaterial[]) { if (type === ColorType.Normal && mat.map) { mat.color.set(0xffffff); } else { mat.color.set(color); } } } else { (mesh.material as MeshLambertMaterial).color.set(color); } } } function translate_entity( renderer: QuestRenderer, entity: QuestEntityModel, drag_adjust: Vector3, grab_offset: Vector3, pointer_device_position: Vector2, vertically: boolean, ): void { if (vertically) { translate_entity_vertically( renderer, entity, drag_adjust, grab_offset, pointer_device_position, ); } else { translate_entity_horizontally( renderer, entity, drag_adjust, grab_offset, pointer_device_position, ); } } /** * If the drag-adjusted pointer is over the ground, translate an entity horizontally across the * ground. Otherwise translate the entity over the horizontal plane that intersects its origin. */ const translate_entity_horizontally = (() => { const plane = new Plane(); const pointer_pos_on_plane = new Vector3(); return ( renderer: QuestRenderer, entity: QuestEntityModel, drag_adjust: Vector3, grab_offset: Vector3, pointer_device_position: Vector2, ): void => { // Cast ray adjusted for dragging entities. const { intersection, section } = pick_ground( renderer, pointer_device_position, drag_adjust, ); if (intersection) { entity.set_world_position( new Vector3( intersection.point.x, intersection.point.y + grab_offset.y - drag_adjust.y, intersection.point.z, ), ); if (section) { entity.set_section(section); } } else { // If the pointer is not over the ground, we translate the entity across the horizontal // plane in which the entity's origin lies. raycaster.setFromCamera(pointer_device_position, renderer.camera); plane.set(UP_VECTOR, -entity.world_position.val.y + grab_offset.y); if (raycaster.ray.intersectPlane(plane, pointer_pos_on_plane)) { entity.set_world_position( new Vector3( pointer_pos_on_plane.x + grab_offset.x, entity.world_position.val.y, pointer_pos_on_plane.z + grab_offset.z, ), ); } } }; })(); const translate_entity_vertically = (() => { const plane_normal = new Vector3(); const plane = new Plane(); const pointer_pos_on_plane = new Vector3(); const grab_point = new Vector3(); return ( renderer: QuestRenderer, entity: QuestEntityModel, drag_adjust: Vector3, grab_offset: Vector3, pointer_device_position: Vector2, ): void => { // Intersect with a plane that's oriented towards the camera and that's coplanar with the // point where the entity was grabbed. raycaster.setFromCamera(pointer_device_position, renderer.camera); renderer.camera.getWorldDirection(plane_normal); plane_normal.negate(); plane_normal.y = 0; plane_normal.normalize(); grab_point.set( entity.world_position.val.x, entity.world_position.val.y, entity.world_position.val.z, ); grab_point.sub(grab_offset); plane.setFromNormalAndCoplanarPoint(plane_normal, grab_point); if (raycaster.ray.intersectPlane(plane, pointer_pos_on_plane)) { const y = pointer_pos_on_plane.y + grab_offset.y; const y_delta = y - entity.world_position.val.y; drag_adjust.y -= y_delta; entity.set_world_position( new Vector3(entity.world_position.val.x, y, entity.world_position.val.z), ); } }; })(); const rotate_entity = (() => { const plane_normal = new Vector3(); const plane = new Plane(); const pointer_pos_on_plane = new Vector3(); const y_intersect = new Vector3(); const axis_to_grab = new Vector3(); const axis_to_pointer = new Vector3(); return ( renderer: QuestRenderer, entity: QuestEntityModel, rotation: Quaternion, initial_rotation: Euler, grab_point: Vector3, pointer_device_position: Vector2, ): void => { // Intersect with a plane that's oriented along the entity's y-axis and that's coplanar with // the point where the entity was grabbed. plane_normal.copy(UP_VECTOR); plane_normal.applyQuaternion(rotation); plane.setFromNormalAndCoplanarPoint(plane_normal, grab_point); raycaster.setFromCamera(pointer_device_position, renderer.camera); if (raycaster.ray.intersectPlane(plane, pointer_pos_on_plane)) { plane.projectPoint(entity.world_position.val, y_intersect); // Calculate vector from the entity's y-axis to the original grab point. axis_to_grab.subVectors(y_intersect, grab_point); // Calculate vector from the entity's y-axis to the new pointer position. axis_to_pointer.subVectors(y_intersect, pointer_pos_on_plane); // Calculate the angle between the two vectors and rotate the entity around its y-axis // by that angle. const cos = axis_to_grab.dot(axis_to_pointer); const sin = plane_normal.dot(axis_to_grab.cross(axis_to_pointer)); const angle = Math.atan2(sin, cos); entity.set_world_rotation( new Euler( initial_rotation.x, (initial_rotation.y + angle) % PI2, initial_rotation.z, "ZXY", ), ); } }; })(); /** * @param renderer * @param pointer_pos - pointer coordinates in normalized device space * @param drag_adjust - vector from origin of entity to grabbing point */ function pick_ground( renderer: QuestRenderer, pointer_pos: Vector2, drag_adjust: Vector3, ): { intersection?: Intersection; section?: SectionModel; } { raycaster.setFromCamera(pointer_pos, renderer.camera); raycaster.ray.origin.add(drag_adjust); const intersections = raycaster.intersectObjects(renderer.collision_geometry.children, true); // Don't allow entities to be placed on very steep terrain. // E.g. walls. // TODO: make use of the flags field in the collision data. for (const intersection of intersections) { if (intersection.face!.normal.y > 0.75) { return { intersection, section: (intersection.object.userData as AreaUserData).section, }; } } return {}; }