diff --git a/src/quest_editor/gui/QuestEditorRendererView.ts b/src/quest_editor/gui/QuestEditorRendererView.ts index 80a6f39e..41bed2a4 100644 --- a/src/quest_editor/gui/QuestEditorRendererView.ts +++ b/src/quest_editor/gui/QuestEditorRendererView.ts @@ -35,6 +35,7 @@ export class QuestEditorRendererView extends QuestRendererView { this.element.addEventListener("focus", () => quest_editor_store.undo.make_current(), true); + // Must be initialized before camera controls. this.entity_controls = this.disposable( new QuestEntityControls(quest_editor_store, this.renderer), ); @@ -50,6 +51,7 @@ export class QuestEditorRendererView extends QuestRendererView { ), ); + // Must be initialized after QuestEntityControls. this.renderer.init_camera_controls(); this.finalize_construction(QuestEditorRendererView); diff --git a/src/quest_editor/rendering/QuestEntityControls.ts b/src/quest_editor/rendering/QuestEntityControls.ts index 09602f7c..f337cb17 100644 --- a/src/quest_editor/rendering/QuestEntityControls.ts +++ b/src/quest_editor/rendering/QuestEntityControls.ts @@ -1,9 +1,18 @@ import { QuestEntityModel } from "../model/QuestEntityModel"; -import { Euler, Intersection, Mesh, Plane, Quaternion, Raycaster, Vector2, Vector3 } from "three"; +import { + Euler, + Intersection, + Mesh, + Object3D, + Plane, + Quaternion, + Raycaster, + Vector2, + Vector3, +} from "three"; import { QuestRenderer } from "./QuestRenderer"; import { EntityUserData } 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"; @@ -21,10 +30,10 @@ import { CreateEntityAction } from "../actions/CreateEntityAction"; import { RotateEntityAction } from "../actions/RotateEntityAction"; import { RemoveEntityAction } from "../actions/RemoveEntityAction"; import { TranslateEntityAction } from "../actions/TranslateEntityAction"; -import { Object3D } from "three/src/core/Object3D"; import { create_quest_npc } from "../../core/data_formats/parsing/quest/QuestNpc"; import { create_quest_object } from "../../core/data_formats/parsing/quest/QuestObject"; import { Episode } from "../../core/data_formats/parsing/quest/Episode"; +import { pick_ground } from "./pick_ground"; const ZERO_VECTOR = Object.freeze(new Vector3(0, 0, 0)); const UP_VECTOR = Object.freeze(new Vector3(0, 1, 0)); @@ -917,38 +926,6 @@ const rotate_entity = (() => { }; })(); -/** - * @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 {}; -} - const pick_nearest_visible_object = (() => { const intersections: Intersection[] = []; diff --git a/src/quest_editor/rendering/QuestRenderer.ts b/src/quest_editor/rendering/QuestRenderer.ts index e18c3047..2973b53c 100644 --- a/src/quest_editor/rendering/QuestRenderer.ts +++ b/src/quest_editor/rendering/QuestRenderer.ts @@ -1,10 +1,22 @@ import { DisposableThreeRenderer, Renderer } from "../../core/rendering/Renderer"; -import { Group, Mesh, MeshLambertMaterial, Object3D, PerspectiveCamera } from "three"; +import { + Group, + Mesh, + MeshLambertMaterial, + Object3D, + PerspectiveCamera, + Vector2, + Vector3, +} from "three"; import { QuestEntityModel } from "../model/QuestEntityModel"; import { Quest3DModelManager } from "./Quest3DModelManager"; import { Disposer } from "../../core/observable/Disposer"; import { ColorType, EntityUserData, NPC_COLORS, OBJECT_COLORS } from "./conversion/entities"; import { QuestNpcModel } from "../model/QuestNpcModel"; +import { pick_ground } from "./pick_ground"; + +const ZERO_VECTOR_2 = Object.freeze(new Vector2(0, 0)); +const ZERO_VECTOR_3 = Object.freeze(new Vector3(0, 0, 0)); export class QuestRenderer extends Renderer { private _collision_geometry = new Object3D(); @@ -14,6 +26,8 @@ export class QuestRenderer extends Renderer { private readonly entity_to_mesh = new Map(); private hovered_mesh?: Mesh; private selected_mesh?: Mesh; + private camera_target_timeout?: number; + private old_camera_target = new Vector3(); get debug(): boolean { return super.debug; @@ -27,7 +41,7 @@ export class QuestRenderer extends Renderer { } } - readonly camera = new PerspectiveCamera(60, 1, 10, 10000); + readonly camera = new PerspectiveCamera(60, 1, 10, 5_000); get collision_geometry(): Object3D { return this._collision_geometry; @@ -68,6 +82,11 @@ export class QuestRenderer extends Renderer { */ init_camera_controls(): void { super.init_camera_controls(); + + this.controls.verticalDragToForward = true; + this.controls.truckSpeed = 2.5; + + this.controls.addEventListener("update", this.camera_controls_updated); } dispose(): void { @@ -159,6 +178,40 @@ export class QuestRenderer extends Renderer { this.selected_mesh = undefined; } + + protected render(): void { + const distance = this.controls.distance; + this.camera.near = distance / 100; + this.camera.far = Math.max(1_000, distance * 5); + this.camera.updateProjectionMatrix(); + super.render(); + } + + private camera_controls_updated = (): void => { + window.clearTimeout(this.camera_target_timeout); + // If we call update_camera_target directly here, the camera will + // randomly rotate when panning and releasing the mouse button quickly. + // No idea why, but wrapping this call in a timeout makes it work. + this.camera_target_timeout = window.setTimeout(this.update_camera_target, 100); + }; + + private update_camera_target = (): void => { + // If the user moved the camera, try setting the camera + // target to a better point. + this.controls.updateCameraUp(); + const { intersection } = pick_ground(this, ZERO_VECTOR_2, ZERO_VECTOR_3); + + if (intersection) { + this.controls.getTarget(this.old_camera_target); + const new_target = intersection.point; + + if (new_target.distanceTo(this.old_camera_target) > 10) { + this.controls.setTarget(new_target.x, new_target.y, new_target.z); + } + } + + this.camera_target_timeout = undefined; + }; } function set_color(mesh: Mesh, type: ColorType): void { diff --git a/src/quest_editor/rendering/pick_ground.ts b/src/quest_editor/rendering/pick_ground.ts new file mode 100644 index 00000000..7007f4d4 --- /dev/null +++ b/src/quest_editor/rendering/pick_ground.ts @@ -0,0 +1,38 @@ +import { QuestRenderer } from "./QuestRenderer"; +import { Intersection, Raycaster, Vector2, Vector3 } from "three"; +import { SectionModel } from "../model/SectionModel"; +import { AreaUserData } from "./conversion/areas"; + +const raycaster = new Raycaster(); + +/** + * @param renderer + * @param pointer_pos - pointer coordinates in normalized device space + * @param drag_adjust - vector from origin of entity to grabbing point + */ +export 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 {}; +}