phantasmal-world/src/quest_editor/rendering/QuestModelManager.ts

298 lines
10 KiB
TypeScript

import Logger from "js-logger";
import { Intersection, Mesh, Object3D, Raycaster, Vector3 } from "three";
import { QuestRenderer } from "./QuestRenderer";
import { QuestModel } from "../model/QuestModel";
import { load_entity_geometry, load_entity_textures } from "../loading/entities";
import { load_area_collision_geometry, load_area_render_geometry } from "../loading/areas";
import { QuestEntityModel } from "../model/QuestEntityModel";
import { Disposer } from "../../core/observable/Disposer";
import { Disposable } from "../../core/observable/Disposable";
import { AreaModel } from "../model/AreaModel";
import { create_entity_mesh } from "./conversion/entities";
import { AreaUserData } from "./conversion/areas";
import { quest_editor_store } from "../stores/QuestEditorStore";
import {
ListChangeType,
ListPropertyChangeEvent,
} from "../../core/observable/property/list/ListProperty";
import { QuestNpcModel } from "../model/QuestNpcModel";
import { QuestObjectModel } from "../model/QuestObjectModel";
import { entity_type_to_string } from "../../core/data_formats/parsing/quest/entities";
const logger = Logger.get("quest_editor/rendering/QuestModelManager");
const CAMERA_POSITION = new Vector3(0, 800, 700);
const CAMERA_LOOK_AT = new Vector3(0, 0, 0);
const DUMMY_OBJECT = new Object3D();
export class QuestModelManager implements Disposable {
private readonly disposer = new Disposer();
private readonly quest_disposer = this.disposer.add(new Disposer());
private readonly area_model_manager: AreaModelManager;
private readonly npc_model_manager: EntityModelManager;
private readonly object_model_manager: EntityModelManager;
constructor(private readonly renderer: QuestRenderer) {
this.area_model_manager = new AreaModelManager(this.renderer);
this.npc_model_manager = new EntityModelManager(this.renderer);
this.object_model_manager = new EntityModelManager(this.renderer);
this.disposer.add_all(
quest_editor_store.current_quest.observe(this.quest_or_area_changed),
quest_editor_store.current_area.observe(this.quest_or_area_changed),
);
}
dispose(): void {
this.disposer.dispose();
}
private quest_or_area_changed = async () => {
const quest = quest_editor_store.current_quest.val;
const area = quest_editor_store.current_area.val;
// Load area model.
await this.area_model_manager.load(quest, area);
if (
quest !== quest_editor_store.current_quest.val ||
area !== quest_editor_store.current_area.val
) {
return;
}
this.quest_disposer.dispose_all();
this.npc_model_manager.remove_all();
this.object_model_manager.remove_all();
this.renderer.reset_entity_models();
// Load entity models.
if (quest && area) {
this.npc_model_manager.add(quest.npcs.val.filter(entity => entity.area_id === area.id));
this.object_model_manager.add(
quest.objects.val.filter(entity => entity.area_id === area.id),
);
this.quest_disposer.add_all(
quest.npcs.observe_list(this.npcs_changed),
quest.objects.observe_list(this.objects_changed),
);
}
};
private npcs_changed = (change: ListPropertyChangeEvent<QuestNpcModel>): void => {
const area = quest_editor_store.current_area.val;
if (change.type === ListChangeType.ListChange && area) {
this.npc_model_manager.remove(change.removed);
this.npc_model_manager.add(
change.inserted.filter(entity => entity.area_id === area.id),
);
}
};
private objects_changed = (change: ListPropertyChangeEvent<QuestObjectModel>): void => {
const area = quest_editor_store.current_area.val;
if (change.type === ListChangeType.ListChange && area) {
this.object_model_manager.remove(change.removed);
this.object_model_manager.add(
change.inserted.filter(entity => entity.area_id === area.id),
);
}
};
}
class AreaModelManager {
private readonly raycaster = new Raycaster();
private readonly origin = new Vector3();
private readonly down = new Vector3(0, -1, 0);
private readonly up = new Vector3(0, 1, 0);
private quest?: QuestModel;
private area?: AreaModel;
constructor(private readonly renderer: QuestRenderer) {}
async load(quest?: QuestModel, area?: AreaModel): Promise<void> {
this.quest = quest;
this.area = area;
if (!quest || !area) {
this.renderer.collision_geometry = DUMMY_OBJECT;
this.renderer.render_geometry = DUMMY_OBJECT;
return;
}
try {
const area_variant =
quest.area_variants.val.find(v => v.area.id === area.id) || area.area_variants[0];
// Load necessary area geometry.
const episode = quest.episode;
const collision_geometry = await load_area_collision_geometry(episode, area_variant);
if (this.should_cancel(quest, area)) return;
const render_geometry = await load_area_render_geometry(episode, area_variant);
if (this.should_cancel(quest, area)) return;
this.add_sections_to_collision_geometry(collision_geometry, render_geometry);
this.renderer.collision_geometry = collision_geometry;
this.renderer.render_geometry = render_geometry;
this.renderer.reset_camera(CAMERA_POSITION, CAMERA_LOOK_AT);
} catch (e) {
logger.error(`Couldn't load area models for quest ${quest.id}, ${area.name}.`, e);
this.renderer.collision_geometry = DUMMY_OBJECT;
this.renderer.render_geometry = DUMMY_OBJECT;
}
}
/**
* Ensures that {@link load} is reentrant.
*/
private should_cancel(quest: QuestModel, area: AreaModel): boolean {
return this.quest !== quest || this.area !== area;
}
private add_sections_to_collision_geometry(
collision_geom: Object3D,
render_geom: Object3D,
): void {
for (const collision_area of collision_geom.children) {
(collision_area as Mesh).geometry.boundingBox.getCenter(this.origin);
this.raycaster.set(this.origin, this.down);
const intersection1 = this.raycaster
.intersectObject(render_geom, true)
.find(i => (i.object.userData as AreaUserData).section != undefined);
this.raycaster.set(this.origin, this.up);
const intersection2 = this.raycaster
.intersectObject(render_geom, true)
.find(i => (i.object.userData as AreaUserData).section != undefined);
let intersection: Intersection | undefined;
if (intersection1 && intersection2) {
intersection =
intersection1.distance <= intersection2.distance
? intersection1
: intersection2;
} else {
intersection = intersection1 || intersection2;
}
if (intersection) {
const cud = collision_area.userData as AreaUserData;
const rud = intersection.object.userData as AreaUserData;
cud.section = rud.section;
}
}
}
}
class EntityModelManager {
private readonly queue: QuestEntityModel[] = [];
private readonly loaded_entities: {
entity: QuestEntityModel;
disposer: Disposer;
}[] = [];
private loading = false;
constructor(private readonly renderer: QuestRenderer) {}
async add(entities: QuestEntityModel[]): Promise<void> {
this.queue.push(...entities);
if (!this.loading) {
try {
this.loading = true;
while (this.queue.length) {
const entity = this.queue[0];
try {
await this.load(entity);
} catch (e) {
logger.error(
`Couldn't load model for entity ${entity_type_to_string(entity.type)}.`,
e,
);
} finally {
const index = this.queue.indexOf(entity);
if (index !== -1) {
this.queue.splice(index, 1);
}
}
}
} finally {
this.loading = false;
}
}
}
remove(entities: QuestEntityModel[]): void {
for (const entity of entities) {
const queue_index = this.queue.indexOf(entity);
if (queue_index !== -1) {
this.queue.splice(queue_index, 1);
}
const loaded_index = this.loaded_entities.findIndex(loaded => loaded.entity === entity);
if (loaded_index !== -1) {
const loaded = this.loaded_entities.splice(loaded_index, 1)[0];
this.renderer.remove_entity_model(loaded.entity);
loaded.disposer.dispose();
}
}
}
remove_all(): void {
for (const { disposer } of this.loaded_entities) {
disposer.dispose();
}
this.loaded_entities.splice(0, Infinity);
this.queue.splice(0, Infinity);
}
private async load(entity: QuestEntityModel): Promise<void> {
const geom = await load_entity_geometry(entity.type);
const tex = await load_entity_textures(entity.type);
const model = create_entity_mesh(entity, geom, tex);
// The model load might be cancelled by now.
if (this.queue.includes(entity)) {
this.update_entity_geometry(entity, model);
}
}
private update_entity_geometry(entity: QuestEntityModel, model: Mesh): void {
this.renderer.add_entity_model(model);
this.loaded_entities.push({
entity,
disposer: new Disposer(
entity.world_position.observe(({ value: { x, y, z } }) => {
model.position.set(x, y, z);
this.renderer.schedule_render();
}),
entity.rotation.observe(({ value: { x, y, z } }) => {
model.rotation.set(x, y, z);
this.renderer.schedule_render();
}),
),
});
}
}