mirror of
https://github.com/DaanVandenBosch/phantasmal-world.git
synced 2025-04-04 22:58:29 +08:00
478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
import * as THREE from 'three';
|
|
import {
|
|
Color,
|
|
HemisphereLight,
|
|
MOUSE,
|
|
Object3D,
|
|
PerspectiveCamera,
|
|
Plane,
|
|
Raycaster,
|
|
Scene,
|
|
Vector2,
|
|
Vector3,
|
|
WebGLRenderer,
|
|
Intersection,
|
|
Mesh,
|
|
MeshLambertMaterial
|
|
} from 'three';
|
|
import OrbitControlsCreator from 'three-orbit-controls';
|
|
import { Vec3, Area, Quest, VisibleQuestEntity, QuestObject, QuestNpc, Section } from '../domain';
|
|
import { getAreaCollisionGeometry, getAreaRenderGeometry } from '../data/loading/areas';
|
|
import {
|
|
OBJECT_COLOR,
|
|
OBJECT_HOVER_COLOR,
|
|
OBJECT_SELECTED_COLOR,
|
|
NPC_COLOR,
|
|
NPC_HOVER_COLOR,
|
|
NPC_SELECTED_COLOR
|
|
} from './entities';
|
|
import { setSelectedEntity } from '../actions/quest-editor/questEditor';
|
|
import { setPositionOnVisibleQuestEntity as setPositionAndSectionOnVisibleQuestEntity } from '../actions/quest-editor/visibleQuestEntities';
|
|
|
|
const OrbitControls = OrbitControlsCreator(THREE);
|
|
|
|
interface PickEntityResult {
|
|
object: Mesh;
|
|
entity: VisibleQuestEntity;
|
|
grabOffset: Vector3;
|
|
dragAdjust: Vector3;
|
|
dragY: number;
|
|
manipulating: boolean;
|
|
}
|
|
|
|
let renderer: Renderer | undefined;
|
|
|
|
export function getRenderer(): Renderer {
|
|
if (!renderer) {
|
|
renderer = new Renderer();
|
|
}
|
|
|
|
return renderer;
|
|
}
|
|
|
|
/**
|
|
* Renders a quest area or an NJ/XJ model.
|
|
*/
|
|
export class Renderer {
|
|
private renderer = new WebGLRenderer({ antialias: true });
|
|
private camera: PerspectiveCamera;
|
|
private controls: any;
|
|
private raycaster = new Raycaster();
|
|
private scene = new Scene();
|
|
private quest?: Quest;
|
|
private questEntitiesLoaded = false;
|
|
private area?: Area;
|
|
private objs: Map<number, QuestObject[]> = new Map(); // Objs grouped by area id
|
|
private npcs: Map<number, QuestNpc[]> = new Map(); // Npcs grouped by area id
|
|
private collisionGeometry = new Object3D();
|
|
private renderGeometry = new Object3D();
|
|
private objGeometry = new Object3D();
|
|
private npcGeometry = new Object3D();
|
|
private hoveredData?: PickEntityResult;
|
|
private selectedData?: PickEntityResult;
|
|
private model?: Object3D;
|
|
|
|
constructor() {
|
|
this.renderer.domElement.addEventListener(
|
|
'mousedown', this.onMouseDown);
|
|
this.renderer.domElement.addEventListener(
|
|
'mouseup', this.onMouseUp);
|
|
this.renderer.domElement.addEventListener(
|
|
'mousemove', this.onMouseMove);
|
|
|
|
this.camera = new PerspectiveCamera(75, 1, 0.1, 5000);
|
|
this.controls = new OrbitControls(
|
|
this.camera, this.renderer.domElement);
|
|
this.controls.mouseButtons.ORBIT = MOUSE.RIGHT;
|
|
this.controls.mouseButtons.PAN = MOUSE.LEFT;
|
|
|
|
this.scene.background = new Color(0x151C21);
|
|
this.scene.add(new HemisphereLight(0xffffff, 0x505050, 1));
|
|
this.scene.add(this.objGeometry);
|
|
this.scene.add(this.npcGeometry);
|
|
|
|
requestAnimationFrame(this.renderLoop);
|
|
}
|
|
|
|
get domElement(): HTMLElement {
|
|
return this.renderer.domElement;
|
|
}
|
|
|
|
setSize(width: number, height: number) {
|
|
this.renderer.setSize(width, height);
|
|
this.camera.aspect = width / height;
|
|
this.camera.updateProjectionMatrix();
|
|
}
|
|
|
|
setQuestAndArea(quest?: Quest, area?: Area) {
|
|
let update = false;
|
|
|
|
if (this.area !== area) {
|
|
this.area = area;
|
|
update = true;
|
|
}
|
|
|
|
if (this.quest !== quest) {
|
|
this.quest = quest;
|
|
|
|
this.objs.clear();
|
|
this.npcs.clear();
|
|
|
|
if (quest) {
|
|
for (const obj of quest.objects) {
|
|
const array = this.objs.get(obj.areaId) || [];
|
|
array.push(obj);
|
|
this.objs.set(obj.areaId, array);
|
|
}
|
|
|
|
for (const npc of quest.npcs) {
|
|
const array = this.npcs.get(npc.areaId) || [];
|
|
array.push(npc);
|
|
this.npcs.set(npc.areaId, array);
|
|
}
|
|
}
|
|
|
|
update = true;
|
|
}
|
|
|
|
if (update) {
|
|
this.updateGeometry();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renders a generic Object3D.
|
|
*/
|
|
setModel(model?: Object3D) {
|
|
if (this.model !== model) {
|
|
if (this.model) {
|
|
this.scene.remove(this.model);
|
|
}
|
|
|
|
if (model) {
|
|
this.setQuestAndArea(undefined, undefined);
|
|
this.scene.add(model);
|
|
this.resetCamera();
|
|
}
|
|
|
|
this.model = model;
|
|
}
|
|
}
|
|
|
|
private updateGeometry() {
|
|
this.scene.remove(this.objGeometry);
|
|
this.scene.remove(this.npcGeometry);
|
|
this.objGeometry = new Object3D();
|
|
this.npcGeometry = new Object3D();
|
|
this.scene.add(this.objGeometry);
|
|
this.scene.add(this.npcGeometry);
|
|
this.questEntitiesLoaded = false;
|
|
|
|
this.scene.remove(this.collisionGeometry);
|
|
|
|
if (this.quest && this.area) {
|
|
const episode = this.quest.episode;
|
|
const areaId = this.area.id;
|
|
const variant = this.quest.areaVariants.find(v => v.area.id === areaId);
|
|
const variantId = (variant && variant.id) || 0;
|
|
|
|
getAreaCollisionGeometry(episode, areaId, variantId).then(geometry => {
|
|
if (this.quest && this.area) {
|
|
this.setModel(undefined);
|
|
this.scene.remove(this.collisionGeometry);
|
|
|
|
this.resetCamera();
|
|
|
|
this.collisionGeometry = geometry;
|
|
this.scene.add(geometry);
|
|
}
|
|
});
|
|
|
|
getAreaRenderGeometry(episode, areaId, variantId).then(geometry => {
|
|
if (this.quest && this.area) {
|
|
this.renderGeometry = geometry;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private resetCamera() {
|
|
this.controls.reset();
|
|
this.camera.position.set(0, 800, 700);
|
|
this.camera.lookAt(new Vector3(0, 0, 0));
|
|
}
|
|
|
|
private renderLoop = () => {
|
|
this.controls.update();
|
|
this.addLoadedEntities();
|
|
this.renderer.render(this.scene, this.camera);
|
|
requestAnimationFrame(this.renderLoop);
|
|
}
|
|
|
|
private addLoadedEntities() {
|
|
if (this.quest && this.area && !this.questEntitiesLoaded) {
|
|
let loaded = true;
|
|
|
|
for (const object of this.quest.objects) {
|
|
if (object.areaId === this.area.id) {
|
|
if (object.object3d) {
|
|
this.objGeometry.add(object.object3d);
|
|
} else {
|
|
loaded = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const npc of this.quest.npcs) {
|
|
if (npc.areaId === this.area.id) {
|
|
if (npc.object3d) {
|
|
this.npcGeometry.add(npc.object3d);
|
|
} else {
|
|
loaded = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.questEntitiesLoaded = loaded;
|
|
}
|
|
}
|
|
|
|
private onMouseDown = (e: MouseEvent) => {
|
|
const oldSelectedData = this.selectedData;
|
|
const data = this.pickEntity(
|
|
this.pointerPosToDeviceCoords(e));
|
|
|
|
// Did we pick a different object than the previously hovered over 3D object?
|
|
if (this.hoveredData && (!data || data.object !== this.hoveredData.object)) {
|
|
(this.hoveredData.object.material as MeshLambertMaterial).color.set(
|
|
this.getColor(this.hoveredData.entity, 'normal'));
|
|
}
|
|
|
|
// Did we pick a different object than the previously selected 3D object?
|
|
if (this.selectedData && (!data || data.object !== this.selectedData.object)) {
|
|
(this.selectedData.object.material as MeshLambertMaterial).color.set(
|
|
this.getColor(this.selectedData.entity, 'normal'));
|
|
this.selectedData.manipulating = false;
|
|
}
|
|
|
|
if (data) {
|
|
// User selected an entity.
|
|
(data.object.material as MeshLambertMaterial).color.set(this.getColor(data.entity, 'selected'));
|
|
data.manipulating = true;
|
|
this.hoveredData = data;
|
|
this.selectedData = data;
|
|
this.controls.enabled = false;
|
|
} else {
|
|
// User clicked on terrain or outside of area.
|
|
this.hoveredData = undefined;
|
|
this.selectedData = undefined;
|
|
this.controls.enabled = true;
|
|
}
|
|
|
|
const selectionChanged = oldSelectedData && data
|
|
? oldSelectedData.object !== data.object
|
|
: oldSelectedData !== data;
|
|
|
|
if (selectionChanged) {
|
|
setSelectedEntity(data && data.entity);
|
|
}
|
|
}
|
|
|
|
private onMouseUp = () => {
|
|
if (this.selectedData) {
|
|
this.selectedData.manipulating = false;
|
|
this.controls.enabled = true;
|
|
}
|
|
}
|
|
|
|
private onMouseMove = (e: MouseEvent) => {
|
|
const pointerPos = this.pointerPosToDeviceCoords(e);
|
|
|
|
if (this.selectedData && this.selectedData.manipulating) {
|
|
if (e.buttons === 1) {
|
|
// User is dragging a selected entity.
|
|
const data = this.selectedData;
|
|
|
|
if (e.shiftKey) {
|
|
// Vertical movement.
|
|
// 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(pointerPos, this.camera);
|
|
const ray = this.raycaster.ray;
|
|
const negativeWorldDir = this.camera.getWorldDirection(new Vector3()).negate();
|
|
const plane = new Plane().setFromNormalAndCoplanarPoint(
|
|
new Vector3(negativeWorldDir.x, 0, negativeWorldDir.z).normalize(),
|
|
data.object.position.sub(data.grabOffset));
|
|
const intersectionPoint = new Vector3();
|
|
|
|
if (ray.intersectPlane(plane, intersectionPoint)) {
|
|
const y = intersectionPoint.y + data.grabOffset.y;
|
|
const yDelta = y - data.entity.position.y;
|
|
data.dragY += yDelta;
|
|
data.dragAdjust.y -= yDelta;
|
|
setPositionAndSectionOnVisibleQuestEntity(data.entity, new Vec3(
|
|
data.entity.position.x,
|
|
y,
|
|
data.entity.position.z
|
|
));
|
|
}
|
|
} else {
|
|
// Horizontal movement accross terrain.
|
|
// Cast ray adjusted for dragging entities.
|
|
const { intersection, section } = this.pickTerrain(pointerPos, data);
|
|
|
|
if (intersection) {
|
|
setPositionAndSectionOnVisibleQuestEntity(data.entity, new Vec3(
|
|
intersection.point.x,
|
|
intersection.point.y + data.dragY,
|
|
intersection.point.z
|
|
), section);
|
|
} else {
|
|
// If the cursor is not over any terrain, we translate the entity accross the horizontal plane in which the entity's origin lies.
|
|
this.raycaster.setFromCamera(pointerPos, this.camera);
|
|
const ray = this.raycaster.ray;
|
|
// ray.origin.add(data.dragAdjust);
|
|
const plane = new Plane(
|
|
new Vector3(0, 1, 0),
|
|
-data.entity.position.y + data.grabOffset.y);
|
|
const intersectionPoint = new Vector3();
|
|
|
|
if (ray.intersectPlane(plane, intersectionPoint)) {
|
|
setPositionAndSectionOnVisibleQuestEntity(data.entity, new Vec3(
|
|
intersectionPoint.x + data.grabOffset.x,
|
|
data.entity.position.y,
|
|
intersectionPoint.z + data.grabOffset.z
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// User is hovering.
|
|
const oldData = this.hoveredData;
|
|
const data = this.pickEntity(pointerPos);
|
|
|
|
if (oldData && (!data || data.object !== oldData.object)) {
|
|
if (!this.selectedData || oldData.object !== this.selectedData.object) {
|
|
(oldData.object.material as MeshLambertMaterial).color.set(
|
|
this.getColor(oldData.entity, 'normal'));
|
|
}
|
|
|
|
this.hoveredData = undefined;
|
|
}
|
|
|
|
if (data && (!oldData || data.object !== oldData.object)) {
|
|
if (!this.selectedData || data.object !== this.selectedData.object) {
|
|
(data.object.material as MeshLambertMaterial).color.set(
|
|
this.getColor(data.entity, 'hover'));
|
|
}
|
|
|
|
this.hoveredData = data;
|
|
}
|
|
}
|
|
}
|
|
|
|
private pointerPosToDeviceCoords(e: MouseEvent) {
|
|
const coords = new Vector2();
|
|
this.renderer.getSize(coords);
|
|
coords.width = e.offsetX / coords.width * 2 - 1;
|
|
coords.height = e.offsetY / coords.height * -2 + 1;
|
|
return coords;
|
|
}
|
|
|
|
/**
|
|
* @param pointerPos - pointer coordinates in normalized device space
|
|
*/
|
|
private pickEntity(pointerPos: Vector2): PickEntityResult | undefined {
|
|
// Find the nearest object and NPC under the pointer.
|
|
this.raycaster.setFromCamera(pointerPos, this.camera);
|
|
const [nearestObject] = this.raycaster.intersectObjects(
|
|
this.objGeometry.children
|
|
);
|
|
const [nearestNpc] = this.raycaster.intersectObjects(
|
|
this.npcGeometry.children
|
|
);
|
|
|
|
if (!nearestObject && !nearestNpc) {
|
|
return;
|
|
}
|
|
|
|
const objectDist = nearestObject ? nearestObject.distance : Infinity;
|
|
const npcDist = nearestNpc ? nearestNpc.distance : Infinity;
|
|
const intersection = objectDist < npcDist ? nearestObject : nearestNpc;
|
|
|
|
const entity = intersection.object.userData.entity;
|
|
// Vector that points from the grabbing point to the model's origin.
|
|
const grabOffset = intersection.object.position
|
|
.clone()
|
|
.sub(intersection.point);
|
|
// Vector that points from the grabbing point to the terrain point directly under the model's origin.
|
|
const dragAdjust = grabOffset.clone();
|
|
// Distance to terrain.
|
|
let dragY = 0;
|
|
|
|
// Find vertical distance to terrain.
|
|
this.raycaster.set(
|
|
intersection.object.position, new Vector3(0, -1, 0)
|
|
);
|
|
const [terrain] = this.raycaster.intersectObjects(
|
|
this.collisionGeometry.children, true
|
|
);
|
|
|
|
if (terrain) {
|
|
dragAdjust.sub(new Vector3(0, terrain.distance, 0));
|
|
dragY += terrain.distance;
|
|
}
|
|
|
|
return {
|
|
object: intersection.object as Mesh,
|
|
entity,
|
|
grabOffset,
|
|
dragAdjust,
|
|
dragY,
|
|
manipulating: false
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param pointerPos - pointer coordinates in normalized device space
|
|
*/
|
|
private pickTerrain(pointerPos: Vector2, data: PickEntityResult): { intersection?: Intersection, section?: Section } {
|
|
this.raycaster.setFromCamera(pointerPos, this.camera);
|
|
this.raycaster.ray.origin.add(data.dragAdjust);
|
|
const terrains = this.raycaster.intersectObjects(
|
|
this.collisionGeometry.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 terrain of terrains) {
|
|
if (terrain.face!.normal.y > 0.75) {
|
|
// Find section ID.
|
|
this.raycaster.set(
|
|
terrain.point.clone().setY(1000), new Vector3(0, -1, 0));
|
|
const renderTerrains = this.raycaster
|
|
.intersectObjects(this.renderGeometry.children, true)
|
|
.filter(rt => rt.object.userData.section.id >= 0);
|
|
|
|
return {
|
|
intersection: terrain,
|
|
section: renderTerrains[0] && renderTerrains[0].object.userData.section
|
|
};
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
private getColor(entity: VisibleQuestEntity, type: 'normal' | 'hover' | 'selected') {
|
|
const isNpc = entity instanceof QuestNpc;
|
|
|
|
switch (type) {
|
|
default:
|
|
case 'normal': return isNpc ? NPC_COLOR : OBJECT_COLOR;
|
|
case 'hover': return isNpc ? NPC_HOVER_COLOR : OBJECT_HOVER_COLOR;
|
|
case 'selected': return isNpc ? NPC_SELECTED_COLOR : OBJECT_SELECTED_COLOR;
|
|
}
|
|
}
|
|
}
|