Entity creation is now undoable. Fixed a bug that occurred when you started to translate an entity and then moved the cursor outside of the 3D-view.

This commit is contained in:
Daan Vanden Bosch 2019-09-21 14:39:04 +02:00
parent a97b56cecc
commit 79b85fc859
8 changed files with 161 additions and 96 deletions

View File

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

View File

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

View File

@ -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<string> = property("");
private readonly _map_designations: WritableProperty<Map<number, number>>;
private readonly _area_variants: WritableListProperty<AreaVariantModel> = list_property();
private readonly _objects: WritableListProperty<QuestObjectModel>;
private readonly _npcs: WritableListProperty<QuestNpcModel>;
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.`);
}
}

View File

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

View File

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

View File

@ -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<void> {
this.undo.reset();

View File

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

View File

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