Refactored model loading code.

This commit is contained in:
Daan Vanden Bosch 2019-07-17 19:37:48 +02:00
parent 3122c256fb
commit 8223107921
24 changed files with 987 additions and 1011 deletions

View File

@ -1,5 +1,4 @@
import { computed, observable } from "mobx";
import { Object3D } from "three";
import { DatNpc, DatObject, DatUnknown } from "../data_formats/parsing/quest/dat";
import { Vec3 } from "../data_formats/vector";
import { enum_values } from "../enums";
@ -186,8 +185,6 @@ export class QuestEntity {
}
}
@observable object_3d?: Object3D;
constructor(area_id: number, section_id: number, position: Vec3, rotation: Vec3) {
if (Object.getPrototypeOf(this) === Object.getPrototypeOf(QuestEntity))
throw new Error("Abstract class should not be instantiated directly.");

View File

@ -0,0 +1,18 @@
export class LoadingCache<K, V> {
private map = new Map<K, V>();
set(key: K, value: V): void {
this.map.set(key, value);
}
get_or_set(key: K, new_value: () => V): V {
let v = this.map.get(key);
if (v === undefined) {
v = new_value();
this.map.set(key, v);
}
return v;
}
}

162
src/loading/areas.ts Normal file
View File

@ -0,0 +1,162 @@
import { Object3D } from "three";
import { Endianness } from "../data_formats";
import { ArrayBufferCursor } from "../data_formats/cursor/ArrayBufferCursor";
import { parse_area_collision_geometry } from "../data_formats/parsing/area_collision_geometry";
import { parse_area_geometry } from "../data_formats/parsing/area_geometry";
import { Section } from "../domain";
import {
area_collision_geometry_to_object_3d,
area_geometry_to_sections_and_object_3d,
} from "../rendering/conversion/areas";
import { load_array_buffer } from "./load_array_buffer";
import { LoadingCache } from "./LoadingCache";
const render_geometry_cache = new LoadingCache<
string,
{ geometry: Promise<Object3D>; sections: Promise<Section[]> }
>();
const collision_geometry_cache = new LoadingCache<string, Promise<Object3D>>();
export async function load_area_sections(
episode: number,
area_id: number,
area_variant: number
): Promise<Section[]> {
return render_geometry_cache.get_or_set(`${episode}-${area_id}-${area_variant}`, () =>
load_area_sections_and_render_geometry(episode, area_id, area_variant)
).sections;
}
export async function load_area_render_geometry(
episode: number,
area_id: number,
area_variant: number
): Promise<Object3D> {
return render_geometry_cache.get_or_set(`${episode}-${area_id}-${area_variant}`, () =>
load_area_sections_and_render_geometry(episode, area_id, area_variant)
).geometry;
}
export async function load_area_collision_geometry(
episode: number,
area_id: number,
area_variant: number
): Promise<Object3D> {
return collision_geometry_cache.get_or_set(`${episode}-${area_id}-${area_variant}`, () =>
get_area_asset(episode, area_id, area_variant, "collision").then(buffer =>
area_collision_geometry_to_object_3d(
parse_area_collision_geometry(new ArrayBufferCursor(buffer, Endianness.Little))
)
)
);
}
function load_area_sections_and_render_geometry(
episode: number,
area_id: number,
area_variant: number
): { geometry: Promise<Object3D>; sections: Promise<Section[]> } {
const promise = get_area_asset(episode, area_id, area_variant, "render").then(buffer =>
area_geometry_to_sections_and_object_3d(
parse_area_geometry(new ArrayBufferCursor(buffer, Endianness.Little))
)
);
return {
geometry: promise.then(([, object_3d]) => object_3d),
sections: promise.then(([sections]) => sections),
};
}
const area_base_names = [
[
["city00_00", 1],
["forest01", 1],
["forest02", 1],
["cave01_", 6],
["cave02_", 5],
["cave03_", 6],
["machine01_", 6],
["machine02_", 6],
["ancient01_", 5],
["ancient02_", 5],
["ancient03_", 5],
["boss01", 1],
["boss02", 1],
["boss03", 1],
["darkfalz00", 1],
],
[
["labo00_00", 1],
["ruins01_", 3],
["ruins02_", 3],
["space01_", 3],
["space02_", 3],
["jungle01_00", 1],
["jungle02_00", 1],
["jungle03_00", 1],
["jungle04_", 3],
["jungle05_00", 1],
["seabed01_", 3],
["seabed02_", 3],
["boss05", 1],
["boss06", 1],
["boss07", 1],
["boss08", 1],
["jungle06_00", 1],
["jungle07_", 5],
],
[
// Don't remove this empty array, see usage of area_base_names in area_version_to_base_url.
],
[
["city02_00", 1],
["wilds01_00", 1],
["wilds01_01", 1],
["wilds01_02", 1],
["wilds01_03", 1],
["crater01_00", 1],
["desert01_", 3],
["desert02_", 3],
["desert03_", 3],
["boss09_00", 1],
],
];
async function get_area_asset(
episode: number,
area_id: number,
area_variant: number,
type: "render" | "collision"
): Promise<ArrayBuffer> {
const base_url = area_version_to_base_url(episode, area_id, area_variant);
const suffix = type === "render" ? "n.rel" : "c.rel";
return load_array_buffer(base_url + suffix);
}
function area_version_to_base_url(episode: number, area_id: number, area_variant: number): string {
const episode_base_names = area_base_names[episode - 1];
if (0 <= area_id && area_id < episode_base_names.length) {
const [base_name, variants] = episode_base_names[area_id];
if (0 <= area_variant && area_variant < variants) {
let variant: string;
if (variants === 1) {
variant = "";
} else {
variant = String(area_variant);
while (variant.length < 2) variant = "0" + variant;
}
return `/maps/map_${base_name}${variant}`;
} else {
throw new Error(
`Unknown variant ${area_variant} of area ${area_id} in episode ${episode}.`
);
}
} else {
throw new Error(`Unknown episode ${episode} area ${area_id}.`);
}
}

237
src/loading/entities.ts Normal file
View File

@ -0,0 +1,237 @@
import { Texture, CylinderBufferGeometry, BufferGeometry } from "three";
import { ObjectType, NpcType } from "../domain";
import Logger from "js-logger";
import { LoadingCache } from "./LoadingCache";
import { Endianness } from "../data_formats";
import { ArrayBufferCursor } from "../data_formats/cursor/ArrayBufferCursor";
import { ninja_object_to_buffer_geometry } from "../rendering/conversion/ninja_geometry";
import { parse_nj, parse_xj } from "../data_formats/parsing/ninja";
import { parse_xvm } from "../data_formats/parsing/ninja/texture";
import { xvm_to_textures } from "../rendering/conversion/ninja_textures";
import { load_array_buffer } from "./load_array_buffer";
const logger = Logger.get("loading/entities");
const DEFAULT_ENTITY = new CylinderBufferGeometry(3, 3, 20);
DEFAULT_ENTITY.translate(0, 10, 0);
const DEFAULT_ENTITY_PROMISE: Promise<BufferGeometry> = new Promise(resolve =>
resolve(DEFAULT_ENTITY)
);
const DEFAULT_ENTITY_TEX: Texture[] = [];
const DEFAULT_ENTITY_TEX_PROMISE: Promise<Texture[]> = new Promise(resolve =>
resolve(DEFAULT_ENTITY_TEX)
);
const npc_cache = new LoadingCache<NpcType, Promise<BufferGeometry>>();
npc_cache.set(NpcType.Unknown, DEFAULT_ENTITY_PROMISE);
const npc_tex_cache = new LoadingCache<NpcType, Promise<Texture[]>>();
npc_tex_cache.set(NpcType.Unknown, DEFAULT_ENTITY_TEX_PROMISE);
const object_cache = new LoadingCache<ObjectType, Promise<BufferGeometry>>();
const object_tex_cache = new LoadingCache<ObjectType, Promise<Texture[]>>();
for (const type of [
ObjectType.Unknown,
ObjectType.PlayerSet,
ObjectType.FogCollision,
ObjectType.EventCollision,
ObjectType.ObjRoomID,
ObjectType.ScriptCollision,
ObjectType.ItemLight,
ObjectType.FogCollisionSW,
ObjectType.MenuActivation,
ObjectType.BoxDetectObject,
ObjectType.SymbolChatObject,
ObjectType.TouchPlateObject,
ObjectType.TargetableObject,
ObjectType.EffectObject,
ObjectType.CountDownObject,
ObjectType.TelepipeLocation,
ObjectType.Pioneer2InvisibleTouchplate,
ObjectType.TempleMapDetect,
ObjectType.LabInvisibleObject,
]) {
object_cache.set(type, DEFAULT_ENTITY_PROMISE);
object_tex_cache.set(type, DEFAULT_ENTITY_TEX_PROMISE);
}
export async function load_npc_geometry(npc_type: NpcType): Promise<BufferGeometry> {
return npc_cache.get_or_set(npc_type, async () => {
try {
const { url, data } = await load_npc_data(npc_type, AssetType.Geometry);
const cursor = new ArrayBufferCursor(data, Endianness.Little);
const nj_objects = url.endsWith(".nj") ? parse_nj(cursor) : parse_xj(cursor);
if (nj_objects.length) {
return ninja_object_to_buffer_geometry(nj_objects[0]);
} else {
logger.warn(`Couldn't parse ${url} for ${npc_type.code}.`);
return DEFAULT_ENTITY;
}
} catch (e) {
logger.warn(`Couldn't load geometry file for ${npc_type.code}.`, e);
return DEFAULT_ENTITY;
}
});
}
export async function load_npc_tex(npc_type: NpcType): Promise<Texture[]> {
return npc_tex_cache.get_or_set(npc_type, async () => {
try {
const { data } = await load_npc_data(npc_type, AssetType.Texture);
const cursor = new ArrayBufferCursor(data, Endianness.Little);
const xvm = parse_xvm(cursor);
return xvm_to_textures(xvm);
} catch (e) {
logger.warn(`Couldn't load texture file for ${npc_type.code}.`, e);
return DEFAULT_ENTITY_TEX;
}
});
}
export async function load_object_geometry(object_type: ObjectType): Promise<BufferGeometry> {
return object_cache.get_or_set(object_type, async () => {
try {
const { url, data } = await load_object_data(object_type, AssetType.Geometry);
const cursor = new ArrayBufferCursor(data, Endianness.Little);
const nj_objects = url.endsWith(".nj") ? parse_nj(cursor) : parse_xj(cursor);
if (nj_objects.length) {
return ninja_object_to_buffer_geometry(nj_objects[0]);
} else {
logger.warn(`Couldn't parse ${url} for ${object_type.name}.`);
return DEFAULT_ENTITY;
}
} catch (e) {
logger.warn(`Couldn't load geometry file for ${object_type.name}.`, e);
return DEFAULT_ENTITY;
}
});
}
export async function load_object_tex(object_type: ObjectType): Promise<Texture[]> {
return object_tex_cache.get_or_set(object_type, async () => {
try {
const { data } = await load_object_data(object_type, AssetType.Texture);
const cursor = new ArrayBufferCursor(data, Endianness.Little);
const xvm = parse_xvm(cursor);
return xvm_to_textures(xvm);
} catch (e) {
logger.warn(`Couldn't load texture file for ${object_type.name}.`, e);
return DEFAULT_ENTITY_TEX;
}
});
}
export async function load_npc_data(
npc_type: NpcType,
type: AssetType
): Promise<{ url: string; data: ArrayBuffer }> {
const url = npc_type_to_url(npc_type, type);
const data = await load_array_buffer(url);
return { url, data };
}
export async function load_object_data(
object_type: ObjectType,
type: AssetType
): Promise<{ url: string; data: ArrayBuffer }> {
const url = object_type_to_url(object_type, type);
const data = await load_array_buffer(url);
return { url, data };
}
enum AssetType {
Geometry,
Texture,
}
function npc_type_to_url(npc_type: NpcType, type: AssetType): string {
switch (npc_type) {
// The dubswitch model is in XJ format.
case NpcType.Dubswitch:
return `/npcs/${npc_type.code}.${type === AssetType.Geometry ? "xj" : "xvm"}`;
// Episode II VR Temple
case NpcType.Hildebear2:
return npc_type_to_url(NpcType.Hildebear, type);
case NpcType.Hildeblue2:
return npc_type_to_url(NpcType.Hildeblue, type);
case NpcType.RagRappy2:
return npc_type_to_url(NpcType.RagRappy, type);
case NpcType.Monest2:
return npc_type_to_url(NpcType.Monest, type);
case NpcType.PoisonLily2:
return npc_type_to_url(NpcType.PoisonLily, type);
case NpcType.NarLily2:
return npc_type_to_url(NpcType.NarLily, type);
case NpcType.GrassAssassin2:
return npc_type_to_url(NpcType.GrassAssassin, type);
case NpcType.Dimenian2:
return npc_type_to_url(NpcType.Dimenian, type);
case NpcType.LaDimenian2:
return npc_type_to_url(NpcType.LaDimenian, type);
case NpcType.SoDimenian2:
return npc_type_to_url(NpcType.SoDimenian, type);
case NpcType.DarkBelra2:
return npc_type_to_url(NpcType.DarkBelra, type);
// Episode II VR Spaceship
case NpcType.SavageWolf2:
return npc_type_to_url(NpcType.SavageWolf, type);
case NpcType.BarbarousWolf2:
return npc_type_to_url(NpcType.BarbarousWolf, type);
case NpcType.PanArms2:
return npc_type_to_url(NpcType.PanArms, type);
case NpcType.Dubchic2:
return npc_type_to_url(NpcType.Dubchic, type);
case NpcType.Gilchic2:
return npc_type_to_url(NpcType.Gilchic, type);
case NpcType.Garanz2:
return npc_type_to_url(NpcType.Garanz, type);
case NpcType.Dubswitch2:
return npc_type_to_url(NpcType.Dubswitch, type);
case NpcType.Delsaber2:
return npc_type_to_url(NpcType.Delsaber, type);
case NpcType.ChaosSorcerer2:
return npc_type_to_url(NpcType.ChaosSorcerer, type);
default:
return `/npcs/${npc_type.code}.${type === AssetType.Geometry ? "nj" : "xvm"}`;
}
}
function object_type_to_url(object_type: ObjectType, type: AssetType): string {
if (type === AssetType.Geometry) {
switch (object_type) {
case ObjectType.EasterEgg:
case ObjectType.ChristmasTree:
case ObjectType.ChristmasWreath:
case ObjectType.TwentyFirstCentury:
case ObjectType.Sonic:
case ObjectType.WelcomeBoard:
case ObjectType.FloatingJelifish:
case ObjectType.RuinsSeal:
case ObjectType.Dolphin:
case ObjectType.Cacti:
case ObjectType.BigBrownRock:
case ObjectType.PoisonPlant:
case ObjectType.BigBlackRocks:
case ObjectType.FallingRock:
case ObjectType.DesertFixedTypeBoxBreakableCrystals:
case ObjectType.BeeHive:
return `/objects/${object_type.pso_id}.nj`;
default:
return `/objects/${object_type.pso_id}.xj`;
}
} else {
return `/objects/${object_type.pso_id}.xvm`;
}
}

View File

@ -0,0 +1,5 @@
export async function load_array_buffer(url: string): Promise<ArrayBuffer> {
const base_url = process.env.PUBLIC_URL;
const response = await fetch(base_url + url);
return response.arrayBuffer();
}

19
src/loading/player.ts Normal file
View File

@ -0,0 +1,19 @@
import { load_array_buffer } from "./load_array_buffer";
export async function get_player_data(
player_class: string,
body_part: string,
no?: number
): Promise<ArrayBuffer> {
return await load_array_buffer(player_class_to_url(player_class, body_part, no));
}
export async function get_player_animation_data(animation_id: number): Promise<ArrayBuffer> {
return await load_array_buffer(
`/player/animation/animation_${animation_id.toString().padStart(3, "0")}.njm`
);
}
function player_class_to_url(player_class: string, body_part: string, no?: number): string {
return `/player/${player_class}${body_part}${no == null ? "" : no}.nj`;
}

View File

@ -0,0 +1,311 @@
import { runInAction } from "mobx";
import { Intersection, Mesh, MeshLambertMaterial, Plane, Raycaster, Vector2, Vector3 } from "three";
import { Vec3 } from "../data_formats/vector";
import { QuestEntity, QuestNpc, Section } from "../domain";
import { quest_editor_store } from "../stores/QuestEditorStore";
import {
NPC_COLOR,
NPC_HOVER_COLOR,
NPC_SELECTED_COLOR,
OBJECT_COLOR,
OBJECT_HOVER_COLOR,
OBJECT_SELECTED_COLOR,
} from "./conversion/entities";
import { QuestRenderer } from "./QuestRenderer";
type PickEntityResult = {
object: Mesh;
entity: QuestEntity;
grab_offset: Vector3;
drag_adjust: Vector3;
drag_y: number;
manipulating: boolean;
};
type EntityUserData = {
entity: QuestEntity;
};
export class EntityControls {
private raycaster = new Raycaster();
private hovered_data?: PickEntityResult;
private selected_data?: PickEntityResult;
private pointer_pos = new Vector2(0, 0);
constructor(private renderer: QuestRenderer) {}
on_mouse_down = (e: MouseEvent) => {
const old_selected_data = this.selected_data;
this.renderer.pointer_pos_to_device_coords(e, this.pointer_pos);
const data = this.pick_entity(this.pointer_pos);
// Did we pick a different object than the previously hovered over 3D object?
if (this.hovered_data && (!data || data.object !== this.hovered_data.object)) {
const color = this.get_color(this.hovered_data.entity, "hover");
for (const material of this.hovered_data.object.material as MeshLambertMaterial[]) {
material.color.set(color);
}
}
// Did we pick a different object than the previously selected 3D object?
if (this.selected_data && (!data || data.object !== this.selected_data.object)) {
const color = this.get_color(this.selected_data.entity, "normal");
for (const material of this.selected_data.object.material as MeshLambertMaterial[]) {
if (material.map) {
material.color.set(0xffffff);
} else {
material.color.set(color);
}
}
this.selected_data.manipulating = false;
}
if (data) {
// User selected an entity.
const color = this.get_color(data.entity, "selected");
for (const material of data.object.material as MeshLambertMaterial[]) {
material.color.set(color);
}
data.manipulating = true;
this.hovered_data = data;
this.selected_data = data;
this.renderer.controls.enabled = false;
} else {
// User clicked on terrain or outside of area.
this.hovered_data = undefined;
this.selected_data = undefined;
this.renderer.controls.enabled = true;
}
const selection_changed =
old_selected_data && data
? old_selected_data.object !== data.object
: old_selected_data !== data;
if (selection_changed) {
quest_editor_store.set_selected_entity(data && data.entity);
this.renderer.schedule_render();
}
};
on_mouse_up = () => {
if (this.selected_data) {
this.selected_data.manipulating = false;
this.renderer.controls.enabled = true;
this.renderer.schedule_render();
}
};
on_mouse_move = (e: MouseEvent) => {
this.renderer.pointer_pos_to_device_coords(e, this.pointer_pos);
if (this.selected_data && this.selected_data.manipulating) {
if (e.buttons === 1) {
// User is dragging a selected entity.
const data = this.selected_data;
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(this.pointer_pos, this.renderer.camera);
const ray = this.raycaster.ray;
const negative_world_dir = this.renderer.camera
.getWorldDirection(new Vector3())
.negate();
const plane = new Plane().setFromNormalAndCoplanarPoint(
new Vector3(negative_world_dir.x, 0, negative_world_dir.z).normalize(),
data.object.position.sub(data.grab_offset)
);
const intersection_point = new Vector3();
if (ray.intersectPlane(plane, intersection_point)) {
const y = intersection_point.y + data.grab_offset.y;
const y_delta = y - data.entity.position.y;
data.drag_y += y_delta;
data.drag_adjust.y -= y_delta;
data.entity.position = 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.pick_terrain(this.pointer_pos, data);
if (intersection) {
runInAction(() => {
data.entity.position = new Vec3(
intersection.point.x,
intersection.point.y + data.drag_y,
intersection.point.z
);
data.entity.section = 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(this.pointer_pos, this.renderer.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.grab_offset.y
);
const intersection_point = new Vector3();
if (ray.intersectPlane(plane, intersection_point)) {
data.entity.position = new Vec3(
intersection_point.x + data.grab_offset.x,
data.entity.position.y,
intersection_point.z + data.grab_offset.z
);
}
}
}
}
this.renderer.schedule_render();
} else {
// User is hovering.
const old_data = this.hovered_data;
const data = this.pick_entity(this.pointer_pos);
if (old_data && (!data || data.object !== old_data.object)) {
if (!this.selected_data || old_data.object !== this.selected_data.object) {
const color = this.get_color(old_data.entity, "normal");
for (const material of old_data.object.material as MeshLambertMaterial[]) {
if (material.map) {
material.color.set(0xffffff);
} else {
material.color.set(color);
}
}
}
this.hovered_data = undefined;
this.renderer.schedule_render();
}
if (data && (!old_data || data.object !== old_data.object)) {
if (!this.selected_data || data.object !== this.selected_data.object) {
const color = this.get_color(data.entity, "hover");
for (const material of data.object.material as MeshLambertMaterial[]) {
material.color.set(color);
}
}
this.hovered_data = data;
this.renderer.schedule_render();
}
}
};
/**
* @param pointer_pos - pointer coordinates in normalized device space
*/
private pick_entity(pointer_pos: Vector2): PickEntityResult | undefined {
// Find the nearest object and NPC under the pointer.
this.raycaster.setFromCamera(pointer_pos, this.renderer.camera);
const [nearest_object] = this.raycaster.intersectObjects(
this.renderer.obj_geometry.children
);
const [nearest_npc] = this.raycaster.intersectObjects(this.renderer.npc_geometry.children);
if (!nearest_object && !nearest_npc) {
return;
}
const object_dist = nearest_object ? nearest_object.distance : Infinity;
const npc_dist = nearest_npc ? nearest_npc.distance : Infinity;
const intersection = object_dist < npc_dist ? nearest_object : nearest_npc;
const entity = (intersection.object.userData as EntityUserData).entity;
// Vector that points from the grabbing point to the model's origin.
const grab_offset = 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 drag_adjust = grab_offset.clone();
// Distance to terrain.
let drag_y = 0;
// Find vertical distance to terrain.
this.raycaster.set(intersection.object.position, new Vector3(0, -1, 0));
const [terrain] = this.raycaster.intersectObjects(
this.renderer.collision_geometry.children,
true
);
if (terrain) {
drag_adjust.sub(new Vector3(0, terrain.distance, 0));
drag_y += terrain.distance;
}
return {
object: intersection.object as Mesh,
entity,
grab_offset,
drag_adjust,
drag_y,
manipulating: false,
};
}
/**
* @param pointer_pos - pointer coordinates in normalized device space
*/
private pick_terrain(
pointer_pos: Vector2,
data: PickEntityResult
): {
intersection?: Intersection;
section?: Section;
} {
this.raycaster.setFromCamera(pointer_pos, this.renderer.camera);
this.raycaster.ray.origin.add(data.drag_adjust);
const terrains = this.raycaster.intersectObjects(
this.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 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 render_terrains = this.raycaster
.intersectObjects(this.renderer.render_geometry.children, true)
.filter(rt => rt.object.userData.section.id >= 0);
return {
intersection: terrain,
section: render_terrains[0] && render_terrains[0].object.userData.section,
};
}
}
return {};
}
private get_color(entity: QuestEntity, type: "normal" | "hover" | "selected"): number {
const is_npc = entity instanceof QuestNpc;
switch (type) {
default:
case "normal":
return is_npc ? NPC_COLOR : OBJECT_COLOR;
case "hover":
return is_npc ? NPC_HOVER_COLOR : OBJECT_HOVER_COLOR;
case "selected":
return is_npc ? NPC_SELECTED_COLOR : OBJECT_SELECTED_COLOR;
}
}
}

View File

@ -0,0 +1,128 @@
import { QuestRenderer } from "./QuestRenderer";
import { Quest, Area, QuestEntity } from "../domain";
import { IReactionDisposer, autorun } from "mobx";
import { Object3D, Group, Vector3 } from "three";
import { load_area_collision_geometry, load_area_render_geometry } from "../loading/areas";
import {
load_object_geometry,
load_object_tex as load_object_textures,
load_npc_geometry,
load_npc_tex as load_npc_textures,
} from "../loading/entities";
import { create_object_mesh, create_npc_mesh } from "./conversion/entities";
import Logger from "js-logger";
const logger = Logger.get("rendering/QuestModelManager");
const CAMERA_POSITION = new Vector3(0, 800, 700);
const CAMERA_LOOKAT = new Vector3(0, 0, 0);
const DUMMY_OBJECT = new Object3D();
export class QuestModelManager {
private quest?: Quest;
private area?: Area;
private entity_reaction_disposers: IReactionDisposer[] = [];
constructor(private renderer: QuestRenderer) {}
async load_models(quest?: Quest, area?: Area): Promise<void> {
if (this.quest === quest && this.area === area) {
return;
}
this.quest = quest;
this.area = area;
this.dispose_entity_reactions();
if (quest && area) {
try {
// Load necessary area geometry.
const episode = quest.episode;
const area_id = area.id;
const variant = quest.area_variants.find(v => v.area.id === area_id);
const variant_id = (variant && variant.id) || 0;
const collision_geometry = await load_area_collision_geometry(
episode,
area_id,
variant_id
);
const render_geometry = await load_area_render_geometry(
episode,
area_id,
variant_id
);
if (this.quest !== quest || this.area !== area) return;
this.renderer.collision_geometry = collision_geometry;
this.renderer.render_geometry = render_geometry;
this.renderer.reset_camera(CAMERA_POSITION, CAMERA_LOOKAT);
// Load entity models.
const npc_group = new Group();
const obj_group = new Group();
this.renderer.npc_geometry = npc_group;
this.renderer.obj_geometry = obj_group;
for (const npc of quest.npcs) {
if (npc.area_id === area.id) {
const npc_geom = await load_npc_geometry(npc.type);
const npc_tex = await load_npc_textures(npc.type);
if (this.quest !== quest || this.area !== area) return;
const model = create_npc_mesh(npc, npc_geom, npc_tex);
this.update_entity_geometry(npc, npc_group, model);
}
}
for (const object of quest.objects) {
if (object.area_id === area.id) {
const object_geom = await load_object_geometry(object.type);
const object_tex = await load_object_textures(object.type);
if (this.quest !== quest || this.area !== area) return;
const model = create_object_mesh(object, object_geom, object_tex);
this.update_entity_geometry(object, obj_group, model);
}
}
} catch (e) {
logger.error(`Couldn't load models for quest ${quest.id}, ${area.name}.`, e);
this.renderer.collision_geometry = DUMMY_OBJECT;
this.renderer.render_geometry = DUMMY_OBJECT;
this.renderer.obj_geometry = DUMMY_OBJECT;
this.renderer.npc_geometry = DUMMY_OBJECT;
}
} else {
this.renderer.collision_geometry = DUMMY_OBJECT;
this.renderer.render_geometry = DUMMY_OBJECT;
this.renderer.obj_geometry = DUMMY_OBJECT;
this.renderer.npc_geometry = DUMMY_OBJECT;
}
}
private update_entity_geometry(entity: QuestEntity, group: Group, model: Object3D): void {
group.add(model);
this.entity_reaction_disposers.push(
autorun(() => {
const { x, y, z } = entity.position;
model.position.set(x, y, z);
const rot = entity.rotation;
model.rotation.set(rot.x, rot.y, rot.z);
this.renderer.schedule_render();
})
);
}
private dispose_entity_reactions(): void {
for (const disposer of this.entity_reaction_disposers) {
disposer();
}
}
}

View File

@ -1,27 +1,8 @@
import { autorun, IReactionDisposer, when, runInAction } from "mobx";
import {
Intersection,
Mesh,
MeshLambertMaterial,
Object3D,
Plane,
Raycaster,
Vector2,
Vector3,
PerspectiveCamera,
} from "three";
import { Vec3 } from "../data_formats/vector";
import { Area, Quest, QuestEntity, QuestNpc, Section } from "../domain";
import { area_store } from "../stores/AreaStore";
import { autorun } from "mobx";
import { Object3D, PerspectiveCamera } from "three";
import { quest_editor_store } from "../stores/QuestEditorStore";
import {
NPC_COLOR,
NPC_HOVER_COLOR,
NPC_SELECTED_COLOR,
OBJECT_COLOR,
OBJECT_HOVER_COLOR,
OBJECT_SELECTED_COLOR,
} from "./entities";
import { EntityControls } from "./EntityControls";
import { QuestModelManager } from "./QuestModelManager";
import { Renderer } from "./Renderer";
let renderer: QuestRenderer | undefined;
@ -31,56 +12,72 @@ export function get_quest_renderer(): QuestRenderer {
return renderer;
}
type PickEntityResult = {
object: Mesh;
entity: QuestEntity;
grab_offset: Vector3;
drag_adjust: Vector3;
drag_y: number;
manipulating: boolean;
};
type EntityUserData = {
entity: QuestEntity;
};
export class QuestRenderer extends Renderer<PerspectiveCamera> {
private raycaster = new Raycaster();
private _collision_geometry = new Object3D();
private quest?: Quest;
private area?: Area;
private entity_reaction_disposers: IReactionDisposer[] = [];
get collision_geometry(): Object3D {
return this._collision_geometry;
}
private collision_geometry = new Object3D();
private render_geometry = new Object3D();
private obj_geometry = new Object3D();
private npc_geometry = new Object3D();
set collision_geometry(collision_geometry: Object3D) {
this.scene.remove(this.collision_geometry);
this._collision_geometry = collision_geometry;
this.scene.add(collision_geometry);
}
private hovered_data?: PickEntityResult;
private selected_data?: PickEntityResult;
private _render_geometry = new Object3D();
get render_geometry(): Object3D {
return this._render_geometry;
}
set render_geometry(render_geometry: Object3D) {
// this.scene.remove(this._render_geometry);
this._render_geometry = render_geometry;
// this.scene.add(render_geometry);
}
private _obj_geometry = new Object3D();
get obj_geometry(): Object3D {
return this._obj_geometry;
}
set obj_geometry(obj_geometry: Object3D) {
this.scene.remove(this._obj_geometry);
this._obj_geometry = obj_geometry;
this.scene.add(obj_geometry);
}
private _npc_geometry = new Object3D();
get npc_geometry(): Object3D {
return this._npc_geometry;
}
set npc_geometry(npc_geometry: Object3D) {
this.scene.remove(this._npc_geometry);
this._npc_geometry = npc_geometry;
this.scene.add(npc_geometry);
}
constructor() {
super(new PerspectiveCamera(60, 1, 10, 10000));
this.dom_element.addEventListener("mousedown", this.on_mouse_down);
this.dom_element.addEventListener("mouseup", this.on_mouse_up);
this.dom_element.addEventListener("mousemove", this.on_mouse_move);
this.scene.add(this.obj_geometry);
this.scene.add(this.npc_geometry);
const model_manager = new QuestModelManager(this);
autorun(() => {
this.set_quest_and_area(
model_manager.load_models(
quest_editor_store.current_quest,
quest_editor_store.current_area
);
});
}
set_quest_and_area(quest?: Quest, area?: Area): void {
this.area = area;
this.quest = quest;
this.update_geometry();
const entity_controls = new EntityControls(this);
this.dom_element.addEventListener("mousedown", entity_controls.on_mouse_down);
this.dom_element.addEventListener("mouseup", entity_controls.on_mouse_up);
this.dom_element.addEventListener("mousemove", entity_controls.on_mouse_move);
}
set_size(width: number, height: number): void {
@ -88,356 +85,4 @@ export class QuestRenderer extends Renderer<PerspectiveCamera> {
this.camera.updateProjectionMatrix();
super.set_size(width, height);
}
private async update_geometry(): Promise<void> {
this.dispose_entity_reactions();
this.scene.remove(this.obj_geometry);
this.scene.remove(this.npc_geometry);
this.obj_geometry = new Object3D();
this.npc_geometry = new Object3D();
this.scene.add(this.obj_geometry);
this.scene.add(this.npc_geometry);
this.scene.remove(this.collision_geometry);
// this.scene.remove(this.render_geometry);
if (this.quest && this.area) {
// Add necessary entity geometry when it arrives.
for (const obj of this.quest.objects) {
this.update_entity_geometry(obj, this.obj_geometry);
}
for (const npc of this.quest.npcs) {
this.update_entity_geometry(npc, this.npc_geometry);
}
// Load necessary area geometry.
const episode = this.quest.episode;
const area_id = this.area.id;
const variant = this.quest.area_variants.find(v => v.area.id === area_id);
const variant_id = (variant && variant.id) || 0;
const collision_geometry = await area_store.get_area_collision_geometry(
episode,
area_id,
variant_id
);
this.scene.remove(this.collision_geometry);
// this.scene.remove(this.render_geometry);
this.reset_camera(new Vector3(0, 800, 700), new Vector3(0, 0, 0));
this.collision_geometry = collision_geometry;
this.scene.add(collision_geometry);
const render_geometry = await area_store.get_area_render_geometry(
episode,
area_id,
variant_id
);
this.render_geometry = render_geometry;
// this.scene.add(render_geometry);
}
}
private update_entity_geometry(entity: QuestEntity, entity_geometry: Object3D): void {
if (this.area && entity.area_id === this.area.id) {
this.entity_reaction_disposers.push(
when(
() => entity.object_3d != undefined,
() => {
const object_3d = entity.object_3d!;
entity_geometry.add(object_3d);
this.entity_reaction_disposers.push(
autorun(() => {
const { x, y, z } = entity.position;
object_3d.position.set(x, y, z);
const rot = entity.rotation;
object_3d.rotation.set(rot.x, rot.y, rot.z);
this.schedule_render();
})
);
this.schedule_render();
}
)
);
}
}
private dispose_entity_reactions(): void {
for (const disposer of this.entity_reaction_disposers) {
disposer();
}
}
private on_mouse_down = (e: MouseEvent) => {
const old_selected_data = this.selected_data;
const data = this.pick_entity(this.pointer_pos_to_device_coords(e));
// Did we pick a different object than the previously hovered over 3D object?
if (this.hovered_data && (!data || data.object !== this.hovered_data.object)) {
const color = this.get_color(this.hovered_data.entity, "hover");
for (const material of this.hovered_data.object.material as MeshLambertMaterial[]) {
material.color.set(color);
}
}
// Did we pick a different object than the previously selected 3D object?
if (this.selected_data && (!data || data.object !== this.selected_data.object)) {
const color = this.get_color(this.selected_data.entity, "normal");
for (const material of this.selected_data.object.material as MeshLambertMaterial[]) {
if (material.map) {
material.color.set(0xffffff);
} else {
material.color.set(color);
}
}
this.selected_data.manipulating = false;
}
if (data) {
// User selected an entity.
const color = this.get_color(data.entity, "selected");
for (const material of data.object.material as MeshLambertMaterial[]) {
material.color.set(color);
}
data.manipulating = true;
this.hovered_data = data;
this.selected_data = data;
this.controls.enabled = false;
} else {
// User clicked on terrain or outside of area.
this.hovered_data = undefined;
this.selected_data = undefined;
this.controls.enabled = true;
}
const selection_changed =
old_selected_data && data
? old_selected_data.object !== data.object
: old_selected_data !== data;
if (selection_changed) {
quest_editor_store.set_selected_entity(data && data.entity);
this.schedule_render();
}
};
private on_mouse_up = () => {
if (this.selected_data) {
this.selected_data.manipulating = false;
this.controls.enabled = true;
this.schedule_render();
}
};
private on_mouse_move = (e: MouseEvent) => {
const pointer_pos = this.pointer_pos_to_device_coords(e);
if (this.selected_data && this.selected_data.manipulating) {
if (e.buttons === 1) {
// User is dragging a selected entity.
const data = this.selected_data;
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(pointer_pos, this.camera);
const ray = this.raycaster.ray;
const negative_world_dir = this.camera
.getWorldDirection(new Vector3())
.negate();
const plane = new Plane().setFromNormalAndCoplanarPoint(
new Vector3(negative_world_dir.x, 0, negative_world_dir.z).normalize(),
data.object.position.sub(data.grab_offset)
);
const intersection_point = new Vector3();
if (ray.intersectPlane(plane, intersection_point)) {
const y = intersection_point.y + data.grab_offset.y;
const y_delta = y - data.entity.position.y;
data.drag_y += y_delta;
data.drag_adjust.y -= y_delta;
data.entity.position = 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.pick_terrain(pointer_pos, data);
if (intersection) {
runInAction(() => {
data.entity.position = new Vec3(
intersection.point.x,
intersection.point.y + data.drag_y,
intersection.point.z
);
data.entity.section = 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(pointer_pos, 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.grab_offset.y
);
const intersection_point = new Vector3();
if (ray.intersectPlane(plane, intersection_point)) {
data.entity.position = new Vec3(
intersection_point.x + data.grab_offset.x,
data.entity.position.y,
intersection_point.z + data.grab_offset.z
);
}
}
}
}
this.schedule_render();
} else {
// User is hovering.
const old_data = this.hovered_data;
const data = this.pick_entity(pointer_pos);
if (old_data && (!data || data.object !== old_data.object)) {
if (!this.selected_data || old_data.object !== this.selected_data.object) {
const color = this.get_color(old_data.entity, "normal");
for (const material of old_data.object.material as MeshLambertMaterial[]) {
if (material.map) {
material.color.set(0xffffff);
} else {
material.color.set(color);
}
}
}
this.hovered_data = undefined;
this.schedule_render();
}
if (data && (!old_data || data.object !== old_data.object)) {
if (!this.selected_data || data.object !== this.selected_data.object) {
const color = this.get_color(data.entity, "hover");
for (const material of data.object.material as MeshLambertMaterial[]) {
material.color.set(color);
}
}
this.hovered_data = data;
this.schedule_render();
}
}
};
/**
* @param pointer_pos - pointer coordinates in normalized device space
*/
private pick_entity(pointer_pos: Vector2): PickEntityResult | undefined {
// Find the nearest object and NPC under the pointer.
this.raycaster.setFromCamera(pointer_pos, this.camera);
const [nearest_object] = this.raycaster.intersectObjects(this.obj_geometry.children);
const [nearest_npc] = this.raycaster.intersectObjects(this.npc_geometry.children);
if (!nearest_object && !nearest_npc) {
return;
}
const object_dist = nearest_object ? nearest_object.distance : Infinity;
const npc_dist = nearest_npc ? nearest_npc.distance : Infinity;
const intersection = object_dist < npc_dist ? nearest_object : nearest_npc;
const entity = (intersection.object.userData as EntityUserData).entity;
// Vector that points from the grabbing point to the model's origin.
const grab_offset = 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 drag_adjust = grab_offset.clone();
// Distance to terrain.
let drag_y = 0;
// Find vertical distance to terrain.
this.raycaster.set(intersection.object.position, new Vector3(0, -1, 0));
const [terrain] = this.raycaster.intersectObjects(this.collision_geometry.children, true);
if (terrain) {
drag_adjust.sub(new Vector3(0, terrain.distance, 0));
drag_y += terrain.distance;
}
return {
object: intersection.object as Mesh,
entity,
grab_offset,
drag_adjust,
drag_y,
manipulating: false,
};
}
/**
* @param pointer_pos - pointer coordinates in normalized device space
*/
private pick_terrain(
pointer_pos: Vector2,
data: PickEntityResult
): {
intersection?: Intersection;
section?: Section;
} {
this.raycaster.setFromCamera(pointer_pos, this.camera);
this.raycaster.ray.origin.add(data.drag_adjust);
const terrains = this.raycaster.intersectObjects(this.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 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 render_terrains = this.raycaster
.intersectObjects(this.render_geometry.children, true)
.filter(rt => rt.object.userData.section.id >= 0);
return {
intersection: terrain,
section: render_terrains[0] && render_terrains[0].object.userData.section,
};
}
}
return {};
}
private get_color(entity: QuestEntity, type: "normal" | "hover" | "selected"): number {
const is_npc = entity instanceof QuestNpc;
switch (type) {
default:
case "normal":
return is_npc ? NPC_COLOR : OBJECT_COLOR;
case "hover":
return is_npc ? NPC_HOVER_COLOR : OBJECT_HOVER_COLOR;
case "selected":
return is_npc ? NPC_SELECTED_COLOR : OBJECT_SELECTED_COLOR;
}
}
}

View File

@ -15,29 +15,28 @@ import OrbitControlsCreator from "three-orbit-controls";
const OrbitControls = OrbitControlsCreator(THREE);
export class Renderer<C extends Camera> {
protected camera: C;
protected controls: any;
protected scene = new Scene();
protected light_holder = new Group();
readonly camera: C;
readonly controls: any;
readonly scene = new Scene();
readonly light_holder = new Group();
private renderer = new WebGLRenderer({ antialias: true });
private render_scheduled = false;
private light = new HemisphereLight(0xffffff, 0x505050, 1.2);
constructor(camera: C) {
this.renderer.setPixelRatio(window.devicePixelRatio);
this.camera = camera;
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls = new OrbitControls(camera, this.dom_element);
this.controls.mouseButtons.ORBIT = MOUSE.RIGHT;
this.controls.mouseButtons.PAN = MOUSE.LEFT;
this.controls.addEventListener("change", this.schedule_render);
this.scene.background = new Color(0x151c21);
this.light_holder.add(this.light);
this.scene.add(this.light_holder);
this.renderer.setPixelRatio(window.devicePixelRatio);
}
get dom_element(): HTMLElement {
@ -49,29 +48,27 @@ export class Renderer<C extends Camera> {
this.schedule_render();
}
protected schedule_render = () => {
pointer_pos_to_device_coords(e: MouseEvent, coords: Vector2): void {
this.renderer.getSize(coords);
coords.width = (e.offsetX / coords.width) * 2 - 1;
coords.height = (e.offsetY / coords.height) * -2 + 1;
}
schedule_render = () => {
if (!this.render_scheduled) {
this.render_scheduled = true;
requestAnimationFrame(this.call_render);
}
};
protected render(): void {
this.renderer.render(this.scene, this.camera);
}
protected reset_camera(position: Vector3, look_at: Vector3): void {
reset_camera(position: Vector3, look_at: Vector3): void {
this.controls.reset();
this.camera.position.copy(position);
this.camera.lookAt(look_at);
}
protected pointer_pos_to_device_coords(e: MouseEvent): Vector2 {
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;
protected render(): void {
this.renderer.render(this.scene, this.camera);
}
private call_render = () => {

View File

@ -12,7 +12,7 @@ import {
import { Xvm } from "../data_formats/parsing/ninja/texture";
import { texture_viewer_store } from "../stores/TextureViewerStore";
import { Renderer } from "./Renderer";
import { xvm_texture_to_texture } from "./textures";
import { xvm_texture_to_texture } from "./conversion/ninja_textures";
const logger = Logger.get("rendering/TextureRenderer");

View File

@ -13,10 +13,10 @@ import {
Uint16BufferAttribute,
Vector3,
} from "three";
import { CollisionObject } from "../data_formats/parsing/area_collision_geometry";
import { RenderObject } from "../data_formats/parsing/area_geometry";
import { Section } from "../domain";
import { xj_model_to_geometry } from "./xj_model_to_geometry";
import { CollisionObject } from "../../data_formats/parsing/area_collision_geometry";
import { RenderObject } from "../../data_formats/parsing/area_geometry";
import { Section } from "../../domain";
import { xj_model_to_geometry } from "./xj_models";
const materials = [
// Wall

View File

@ -7,7 +7,7 @@ import {
Texture,
Material,
} from "three";
import { QuestEntity, QuestNpc, QuestObject } from "../domain";
import { QuestEntity, QuestNpc, QuestObject } from "../../domain";
export const OBJECT_COLOR = 0xffff00;
export const OBJECT_HOVER_COLOR = 0xffdf3f;

View File

@ -1,4 +1,4 @@
import { Vec3 } from "../data_formats/vector";
import { Vec3 } from "../../data_formats/vector";
import { Vector3 } from "three";
export function vec3_to_threejs(v: Vec3): Vector3 {

View File

@ -8,12 +8,12 @@ import {
QuaternionKeyframeTrack,
VectorKeyframeTrack,
} from "three";
import { NjModel, NjObject } from "../data_formats/parsing/ninja";
import { NjModel, NjObject } from "../../data_formats/parsing/ninja";
import {
NjInterpolation,
NjKeyframeTrackType,
NjMotion,
} from "../data_formats/parsing/ninja/motion";
} from "../../data_formats/parsing/ninja/motion";
export const PSO_FRAME_RATE = 30;

View File

@ -17,9 +17,9 @@ import {
Mesh,
} from "three";
import { vec3_to_threejs } from ".";
import { is_njcm_model, NjModel, NjObject } from "../data_formats/parsing/ninja";
import { NjcmModel } from "../data_formats/parsing/ninja/njcm";
import { xj_model_to_geometry } from "./xj_model_to_geometry";
import { is_njcm_model, NjModel, NjObject } from "../../data_formats/parsing/ninja";
import { NjcmModel } from "../../data_formats/parsing/ninja/njcm";
import { xj_model_to_geometry } from "./xj_models";
const DUMMY_MATERIAL = new MeshBasicMaterial({
color: 0x00ff00,

View File

@ -7,7 +7,7 @@ import {
Texture,
CompressedPixelFormat,
} from "three";
import { Xvm, XvmTexture } from "../data_formats/parsing/ninja/texture";
import { Xvm, XvmTexture } from "../../data_formats/parsing/ninja/texture";
export function xvm_to_textures(xvm: Xvm): Texture[] {
return xvm.textures.map(xvm_texture_to_texture);

View File

@ -1,8 +1,8 @@
import { Matrix3, Matrix4, Vector3 } from "three";
import { vec3_to_threejs } from ".";
import { XjModel } from "../data_formats/parsing/ninja/xj";
import { Vec2 } from "../data_formats/vector";
import { VertexGroup } from "./models";
import { XjModel } from "../../data_formats/parsing/ninja/xj";
import { Vec2 } from "../../data_formats/vector";
import { VertexGroup } from "./ninja_geometry";
const DEFAULT_NORMAL = new Vector3(0, 1, 0);
const DEFAULT_UV = new Vec2(0, 0);

View File

@ -1,47 +0,0 @@
import { CylinderBufferGeometry, MeshLambertMaterial, Object3D } from "three";
import { DatNpc, DatObject } from "../data_formats/parsing/quest/dat";
import { Vec3 } from "../data_formats/vector";
import { NpcType, ObjectType, QuestNpc, QuestObject } from "../domain";
import { create_npc_mesh, create_object_mesh, NPC_COLOR, OBJECT_COLOR } from "./entities";
const cylinder = new CylinderBufferGeometry(3, 3, 20).translate(0, 10, 0);
test("create geometry for quest objects", () => {
const object = new QuestObject(
7,
13,
new Vec3(17, 19, 23),
new Vec3(0, 0, 0),
ObjectType.PrincipalWarp,
{} as DatObject
);
const geometry = create_object_mesh(object, cylinder);
expect(geometry).toBeInstanceOf(Object3D);
expect(geometry.name).toBe("Object");
expect(geometry.userData.entity).toBe(object);
expect(geometry.position.x).toBe(17);
expect(geometry.position.y).toBe(19);
expect(geometry.position.z).toBe(23);
expect((geometry.material as MeshLambertMaterial).color.getHex()).toBe(OBJECT_COLOR);
});
test("create geometry for quest NPCs", () => {
const npc = new QuestNpc(
7,
13,
new Vec3(17, 19, 23),
new Vec3(0, 0, 0),
NpcType.Booma,
{} as DatNpc
);
const geometry = create_npc_mesh(npc, cylinder);
expect(geometry).toBeInstanceOf(Object3D);
expect(geometry.name).toBe("NPC");
expect(geometry.userData.entity).toBe(npc);
expect(geometry.position.x).toBe(17);
expect(geometry.position.y).toBe(19);
expect(geometry.position.z).toBe(23);
expect((geometry.material as MeshLambertMaterial).color.getHex()).toBe(NPC_COLOR);
});

View File

@ -1,14 +1,5 @@
import { Object3D } from "three";
import { Endianness } from "../data_formats";
import { ArrayBufferCursor } from "../data_formats/cursor/ArrayBufferCursor";
import { parse_area_collision_geometry } from "../data_formats/parsing/area_collision_geometry";
import { parse_area_geometry } from "../data_formats/parsing/area_geometry";
import { Area, AreaVariant, Section } from "../domain";
import {
area_collision_geometry_to_object_3d,
area_geometry_to_sections_and_object_3d,
} from "../rendering/areas";
import { get_area_collision_data, get_area_render_data } from "./binary_assets";
import { load_area_sections } from "../loading/areas";
function area(id: number, name: string, order: number, variants: number): Area {
const area = new Area(id, name, order, []);
@ -19,16 +10,11 @@ function area(id: number, name: string, order: number, variants: number): Area {
return area;
}
const sections_cache: Map<string, Promise<Section[]>> = new Map();
const render_geometry_cache: Map<string, Promise<Object3D>> = new Map();
const collision_geometry_cache: Map<string, Promise<Object3D>> = new Map();
class AreaStore {
areas: Area[][];
readonly areas: Area[][] = [];
constructor() {
// The IDs match the PSO IDs for areas.
this.areas = [];
let order = 0;
this.areas[1] = [
area(0, "Pioneer II", order++, 1),
@ -86,7 +72,7 @@ class AreaStore {
];
}
get_variant(episode: number, area_id: number, variant_id: number): AreaVariant {
get_variant = (episode: number, area_id: number, variant_id: number): AreaVariant => {
if (episode !== 1 && episode !== 2 && episode !== 4)
throw new Error(`Expected episode to be 1, 2 or 4, got ${episode}.`);
@ -100,80 +86,15 @@ class AreaStore {
);
return area_variant;
}
};
async get_area_sections(
get_area_sections = (
episode: number,
area_id: number,
area_variant: number
): Promise<Section[]> {
const key = `${episode}-${area_id}-${area_variant}`;
let sections = sections_cache.get(key);
if (!sections) {
this.load_area_sections_and_render_geometry(episode, area_id, area_variant);
sections = sections_cache.get(key)!;
}
return sections;
}
async get_area_render_geometry(
episode: number,
area_id: number,
area_variant: number
): Promise<Object3D> {
const key = `${episode}-${area_id}-${area_variant}`;
let object_3d = render_geometry_cache.get(key);
if (!object_3d) {
this.load_area_sections_and_render_geometry(episode, area_id, area_variant);
object_3d = render_geometry_cache.get(key)!;
}
return object_3d;
}
async get_area_collision_geometry(
episode: number,
area_id: number,
area_variant: number
): Promise<Object3D> {
const object_3d = collision_geometry_cache.get(`${episode}-${area_id}-${area_variant}`);
if (object_3d) {
return object_3d;
} else {
const object_3d = get_area_collision_data(episode, area_id, area_variant).then(buffer =>
area_collision_geometry_to_object_3d(
parse_area_collision_geometry(new ArrayBufferCursor(buffer, Endianness.Little))
)
);
collision_geometry_cache.set(`${area_id}-${area_variant}`, object_3d);
return object_3d;
}
}
private load_area_sections_and_render_geometry(
episode: number,
area_id: number,
area_variant: number
): void {
const promise = get_area_render_data(episode, area_id, area_variant).then(buffer =>
area_geometry_to_sections_and_object_3d(
parse_area_geometry(new ArrayBufferCursor(buffer, Endianness.Little))
)
);
sections_cache.set(
`${episode}-${area_id}-${area_variant}`,
promise.then(([sections]) => sections)
);
render_geometry_cache.set(
`${episode}-${area_id}-${area_variant}`,
promise.then(([, object_3d]) => object_3d)
);
}
variant_id: number
): Promise<Section[]> => {
return load_area_sections(episode, area_id, variant_id);
};
}
export const area_store = new AreaStore();

View File

@ -1,163 +0,0 @@
import Logger from "js-logger";
import { BufferGeometry, CylinderBufferGeometry, Texture } from "three";
import { parse_nj, parse_xj } from "../data_formats/parsing/ninja";
import { NpcType, ObjectType } from "../domain";
import { ninja_object_to_buffer_geometry } from "../rendering/models";
import { get_npc_data, get_object_data, AssetType } from "./binary_assets";
import { Endianness } from "../data_formats";
import { ArrayBufferCursor } from "../data_formats/cursor/ArrayBufferCursor";
import { parse_xvm } from "../data_formats/parsing/ninja/texture";
import { xvm_to_textures } from "../rendering/textures";
const logger = Logger.get("stores/EntityStore");
const DEFAULT_ENTITY = new CylinderBufferGeometry(3, 3, 20);
DEFAULT_ENTITY.translate(0, 10, 0);
const DEFAULT_ENTITY_PROMISE: Promise<BufferGeometry> = new Promise(resolve =>
resolve(DEFAULT_ENTITY)
);
const DEFAULT_ENTITY_TEX: Texture[] = [];
const DEFAULT_ENTITY_TEX_PROMISE: Promise<Texture[]> = new Promise(resolve =>
resolve(DEFAULT_ENTITY_TEX)
);
const npc_cache: Map<NpcType, Promise<BufferGeometry>> = new Map();
npc_cache.set(NpcType.Unknown, DEFAULT_ENTITY_PROMISE);
const npc_tex_cache: Map<NpcType, Promise<Texture[]>> = new Map();
npc_tex_cache.set(NpcType.Unknown, DEFAULT_ENTITY_TEX_PROMISE);
const object_cache: Map<ObjectType, Promise<BufferGeometry>> = new Map();
const object_tex_cache: Map<ObjectType, Promise<Texture[]>> = new Map();
for (const type of [
ObjectType.Unknown,
ObjectType.PlayerSet,
ObjectType.FogCollision,
ObjectType.EventCollision,
ObjectType.ObjRoomID,
ObjectType.ScriptCollision,
ObjectType.ItemLight,
ObjectType.FogCollisionSW,
ObjectType.MenuActivation,
ObjectType.BoxDetectObject,
ObjectType.SymbolChatObject,
ObjectType.TouchPlateObject,
ObjectType.TargetableObject,
ObjectType.EffectObject,
ObjectType.CountDownObject,
ObjectType.TelepipeLocation,
ObjectType.Pioneer2InvisibleTouchplate,
ObjectType.TempleMapDetect,
ObjectType.LabInvisibleObject,
]) {
object_cache.set(type, DEFAULT_ENTITY_PROMISE);
object_tex_cache.set(type, DEFAULT_ENTITY_TEX_PROMISE);
}
class EntityStore {
async get_npc_geometry(npc_type: NpcType): Promise<BufferGeometry> {
let mesh = npc_cache.get(npc_type);
if (mesh) {
return mesh;
} else {
mesh = get_npc_data(npc_type, AssetType.Geometry)
.then(({ url, data }) => {
const cursor = new ArrayBufferCursor(data, Endianness.Little);
const nj_objects = url.endsWith(".nj") ? parse_nj(cursor) : parse_xj(cursor);
if (nj_objects.length) {
return ninja_object_to_buffer_geometry(nj_objects[0]);
} else {
logger.warn(`Could not parse ${url}.`);
return DEFAULT_ENTITY;
}
})
.catch(e => {
logger.warn(`Could load geometry file for ${npc_type.code}.`, e);
return DEFAULT_ENTITY;
});
npc_cache.set(npc_type, mesh);
return mesh;
}
}
async get_npc_tex(npc_type: NpcType): Promise<Texture[]> {
let tex = npc_tex_cache.get(npc_type);
if (tex) {
return tex;
} else {
tex = get_npc_data(npc_type, AssetType.Texture)
.then(({ data }) => {
const cursor = new ArrayBufferCursor(data, Endianness.Little);
const xvm = parse_xvm(cursor);
return xvm_to_textures(xvm);
})
.catch(e => {
logger.warn(`Could load texture file for ${npc_type.code}.`, e);
return DEFAULT_ENTITY_TEX;
});
npc_tex_cache.set(npc_type, tex);
return tex;
}
}
async get_object_geometry(object_type: ObjectType): Promise<BufferGeometry> {
let geometry = object_cache.get(object_type);
if (geometry) {
return geometry;
} else {
geometry = get_object_data(object_type, AssetType.Geometry)
.then(({ url, data }) => {
const cursor = new ArrayBufferCursor(data, Endianness.Little);
const nj_objects = url.endsWith(".nj") ? parse_nj(cursor) : parse_xj(cursor);
if (nj_objects.length) {
return ninja_object_to_buffer_geometry(nj_objects[0]);
} else {
logger.warn(`Could not parse ${url} for ${object_type.name}.`);
return DEFAULT_ENTITY;
}
})
.catch(e => {
logger.warn(`Could load geometry file for ${object_type.name}.`, e);
return DEFAULT_ENTITY;
});
object_cache.set(object_type, geometry);
return geometry;
}
}
async get_object_tex(object_type: ObjectType): Promise<Texture[]> {
let tex = object_tex_cache.get(object_type);
if (tex) {
return tex;
} else {
tex = get_object_data(object_type, AssetType.Texture)
.then(({ data }) => {
const cursor = new ArrayBufferCursor(data, Endianness.Little);
const xvm = parse_xvm(cursor);
return xvm_to_textures(xvm);
})
.catch(e => {
logger.warn(`Could load texture file for ${object_type.name}.`, e);
return DEFAULT_ENTITY_TEX;
});
object_tex_cache.set(object_type, tex);
return tex;
}
}
}
export const entity_store = new EntityStore();

View File

@ -19,10 +19,13 @@ import { NjMotion, parse_njm } from "../data_formats/parsing/ninja/motion";
import { parse_xvm } from "../data_formats/parsing/ninja/texture";
import { PlayerAnimation, PlayerModel } from "../domain";
import { read_file } from "../read_file";
import { create_animation_clip, PSO_FRAME_RATE } from "../rendering/animation";
import { ninja_object_to_mesh, ninja_object_to_skinned_mesh } from "../rendering/models";
import { xvm_to_textures } from "../rendering/textures";
import { get_player_animation_data, get_player_data } from "./binary_assets";
import { create_animation_clip, PSO_FRAME_RATE } from "../rendering/conversion/ninja_animation";
import {
ninja_object_to_mesh,
ninja_object_to_skinned_mesh,
} from "../rendering/conversion/ninja_geometry";
import { xvm_to_textures } from "../rendering/conversion/ninja_textures";
import { get_player_animation_data, get_player_data } from "../loading/player";
const logger = Logger.get("stores/ModelViewerStore");
const nj_object_cache: Map<string, Promise<NjObject<NjModel>>> = new Map();

View File

@ -1,14 +1,12 @@
import Logger from "js-logger";
import { action, observable, runInAction } from "mobx";
import { Endianness } from "../data_formats";
import { ArrayBufferCursor } from "../data_formats/cursor/ArrayBufferCursor";
import { parse_quest, write_quest_qst } from "../data_formats/parsing/quest";
import { Vec3 } from "../data_formats/vector";
import { Area, Quest, QuestEntity, Section } from "../domain";
import { create_npc_mesh, create_object_mesh } from "../rendering/entities";
import { area_store } from "./AreaStore";
import { entity_store } from "./EntityStore";
import { ArrayBufferCursor } from "../data_formats/cursor/ArrayBufferCursor";
import { Endianness } from "../data_formats";
import { read_file } from "../read_file";
import { area_store } from "./AreaStore";
const logger = Logger.get("stores/QuestEditorStore");
@ -69,10 +67,7 @@ class QuestEditorStore {
// Generate object geometry.
for (const object of quest.objects.filter(o => o.area_id === variant.area.id)) {
try {
const object_geom = await entity_store.get_object_geometry(object.type);
const object_tex = await entity_store.get_object_tex(object.type);
this.set_section_on_visible_quest_entity(object, sections);
object.object_3d = create_object_mesh(object, object_geom, object_tex);
} catch (e) {
logger.error(e);
}
@ -81,10 +76,7 @@ class QuestEditorStore {
// Generate NPC geometry.
for (const npc of quest.npcs.filter(npc => npc.area_id === variant.area.id)) {
try {
const npc_geom = await entity_store.get_npc_geometry(npc.type);
const npc_tex = await entity_store.get_npc_tex(npc.type);
this.set_section_on_visible_quest_entity(npc, sections);
npc.object_3d = create_npc_mesh(npc, npc_geom, npc_tex);
} catch (e) {
logger.error(e);
}

View File

@ -1,249 +0,0 @@
import { NpcType, ObjectType } from "../domain";
export enum AssetType {
Geometry,
Texture,
}
export function get_area_render_data(
episode: number,
area_id: number,
area_version: number
): Promise<ArrayBuffer> {
return get_area_asset(episode, area_id, area_version, "render");
}
export function get_area_collision_data(
episode: number,
area_id: number,
area_version: number
): Promise<ArrayBuffer> {
return get_area_asset(episode, area_id, area_version, "collision");
}
export async function get_npc_data(
npc_type: NpcType,
type: AssetType
): Promise<{ url: string; data: ArrayBuffer }> {
const url = npc_type_to_url(npc_type, type);
const data = await get_asset(url);
return { url, data };
}
export async function get_object_data(
object_type: ObjectType,
type: AssetType
): Promise<{ url: string; data: ArrayBuffer }> {
const url = object_type_to_url(object_type, type);
const data = await get_asset(url);
return { url, data };
}
export async function get_player_data(
player_class: string,
body_part: string,
no?: number
): Promise<ArrayBuffer> {
return await get_asset(player_class_to_url(player_class, body_part, no));
}
export async function get_player_animation_data(animation_id: number): Promise<ArrayBuffer> {
return await get_asset(
`/player/animation/animation_${animation_id.toString().padStart(3, "0")}.njm`
);
}
const area_base_names = [
[
["city00_00", 1],
["forest01", 1],
["forest02", 1],
["cave01_", 6],
["cave02_", 5],
["cave03_", 6],
["machine01_", 6],
["machine02_", 6],
["ancient01_", 5],
["ancient02_", 5],
["ancient03_", 5],
["boss01", 1],
["boss02", 1],
["boss03", 1],
["darkfalz00", 1],
],
[
["labo00_00", 1],
["ruins01_", 3],
["ruins02_", 3],
["space01_", 3],
["space02_", 3],
["jungle01_00", 1],
["jungle02_00", 1],
["jungle03_00", 1],
["jungle04_", 3],
["jungle05_00", 1],
["seabed01_", 3],
["seabed02_", 3],
["boss05", 1],
["boss06", 1],
["boss07", 1],
["boss08", 1],
["jungle06_00", 1],
["jungle07_", 5],
],
[
// Don't remove this empty array, see usage of areaBaseNames in areaVersionToBaseUrl.
],
[
["city02_00", 1],
["wilds01_00", 1],
["wilds01_01", 1],
["wilds01_02", 1],
["wilds01_03", 1],
["crater01_00", 1],
["desert01_", 3],
["desert02_", 3],
["desert03_", 3],
["boss09_00", 1],
],
];
function area_version_to_base_url(episode: number, area_id: number, area_variant: number): string {
const episode_base_names = area_base_names[episode - 1];
if (0 <= area_id && area_id < episode_base_names.length) {
const [base_name, variants] = episode_base_names[area_id];
if (0 <= area_variant && area_variant < variants) {
let variant: string;
if (variants === 1) {
variant = "";
} else {
variant = String(area_variant);
while (variant.length < 2) variant = "0" + variant;
}
return `/maps/map_${base_name}${variant}`;
} else {
throw new Error(
`Unknown variant ${area_variant} of area ${area_id} in episode ${episode}.`
);
}
} else {
throw new Error(`Unknown episode ${episode} area ${area_id}.`);
}
}
type AreaAssetType = "render" | "collision";
function get_area_asset(
episode: number,
area_id: number,
area_variant: number,
type: AreaAssetType
): Promise<ArrayBuffer> {
try {
const base_url = area_version_to_base_url(episode, area_id, area_variant);
const suffix = type === "render" ? "n.rel" : "c.rel";
return get_asset(base_url + suffix);
} catch (e) {
return Promise.reject(e);
}
}
function npc_type_to_url(npc_type: NpcType, type: AssetType): string {
switch (npc_type) {
// The dubswitch model is in XJ format.
case NpcType.Dubswitch:
return `/npcs/${npc_type.code}.${type === AssetType.Geometry ? "xj" : "xvm"}`;
// Episode II VR Temple
case NpcType.Hildebear2:
return npc_type_to_url(NpcType.Hildebear, type);
case NpcType.Hildeblue2:
return npc_type_to_url(NpcType.Hildeblue, type);
case NpcType.RagRappy2:
return npc_type_to_url(NpcType.RagRappy, type);
case NpcType.Monest2:
return npc_type_to_url(NpcType.Monest, type);
case NpcType.PoisonLily2:
return npc_type_to_url(NpcType.PoisonLily, type);
case NpcType.NarLily2:
return npc_type_to_url(NpcType.NarLily, type);
case NpcType.GrassAssassin2:
return npc_type_to_url(NpcType.GrassAssassin, type);
case NpcType.Dimenian2:
return npc_type_to_url(NpcType.Dimenian, type);
case NpcType.LaDimenian2:
return npc_type_to_url(NpcType.LaDimenian, type);
case NpcType.SoDimenian2:
return npc_type_to_url(NpcType.SoDimenian, type);
case NpcType.DarkBelra2:
return npc_type_to_url(NpcType.DarkBelra, type);
// Episode II VR Spaceship
case NpcType.SavageWolf2:
return npc_type_to_url(NpcType.SavageWolf, type);
case NpcType.BarbarousWolf2:
return npc_type_to_url(NpcType.BarbarousWolf, type);
case NpcType.PanArms2:
return npc_type_to_url(NpcType.PanArms, type);
case NpcType.Dubchic2:
return npc_type_to_url(NpcType.Dubchic, type);
case NpcType.Gilchic2:
return npc_type_to_url(NpcType.Gilchic, type);
case NpcType.Garanz2:
return npc_type_to_url(NpcType.Garanz, type);
case NpcType.Dubswitch2:
return npc_type_to_url(NpcType.Dubswitch, type);
case NpcType.Delsaber2:
return npc_type_to_url(NpcType.Delsaber, type);
case NpcType.ChaosSorcerer2:
return npc_type_to_url(NpcType.ChaosSorcerer, type);
default:
return `/npcs/${npc_type.code}.${type === AssetType.Geometry ? "nj" : "xvm"}`;
}
}
function object_type_to_url(object_type: ObjectType, type: AssetType): string {
if (type === AssetType.Geometry) {
switch (object_type) {
case ObjectType.EasterEgg:
case ObjectType.ChristmasTree:
case ObjectType.ChristmasWreath:
case ObjectType.TwentyFirstCentury:
case ObjectType.Sonic:
case ObjectType.WelcomeBoard:
case ObjectType.FloatingJelifish:
case ObjectType.RuinsSeal:
case ObjectType.Dolphin:
case ObjectType.Cacti:
case ObjectType.BigBrownRock:
case ObjectType.PoisonPlant:
case ObjectType.BigBlackRocks:
case ObjectType.FallingRock:
case ObjectType.DesertFixedTypeBoxBreakableCrystals:
case ObjectType.BeeHive:
return `/objects/${object_type.pso_id}.nj`;
default:
return `/objects/${object_type.pso_id}.xj`;
}
} else {
return `/objects/${object_type.pso_id}.xvm`;
}
}
function player_class_to_url(player_class: string, body_part: string, no?: number): string {
return `/player/${player_class}${body_part}${no == null ? "" : no}.nj`;
}
function get_asset(url: string): Promise<ArrayBuffer> {
const base_url = process.env.PUBLIC_URL;
const promise = fetch(base_url + url).then(r => r.arrayBuffer());
return promise;
}