Basic entity creation via drag and drop.

This commit is contained in:
Daan Vanden Bosch 2019-09-19 22:20:17 +02:00
parent 4e55f1b9fd
commit 7ae4ad428c
23 changed files with 2232 additions and 652 deletions

View File

@ -24,7 +24,7 @@ export type QuestNpc = {
readonly pso_type_id: number; readonly pso_type_id: number;
readonly npc_id: number; readonly npc_id: number;
readonly script_label: number; readonly script_label: number;
readonly roaming: number; readonly pso_roaming: number;
}; };
export type QuestObject = { export type QuestObject = {
@ -56,6 +56,10 @@ export function entity_type_to_string(type: EntityType): string {
return (NpcType as any)[type] || (ObjectType as any)[type]; return (NpcType as any)[type] || (ObjectType as any)[type];
} }
export function is_npc_type(entity_type: EntityType): entity_type is NpcType {
return NpcType[entity_type] != undefined;
}
export function entity_data(type: EntityType): EntityTypeData { export function entity_data(type: EntityType): EntityTypeData {
return npc_data(type as NpcType) || object_data(type as ObjectType); return npc_data(type as NpcType) || object_data(type as ObjectType);
} }

View File

@ -19,7 +19,7 @@ import { QuestNpc, QuestObject } from "./entities";
import { Episode } from "./Episode"; import { Episode } from "./Episode";
import { object_data, ObjectType, pso_id_to_object_type } from "./object_types"; import { object_data, ObjectType, pso_id_to_object_type } from "./object_types";
import { parse_qst, QstContainedFile, write_qst } from "./qst"; import { parse_qst, QstContainedFile, write_qst } from "./qst";
import { NpcType } from "./npc_types"; import { npc_data, NpcType } from "./npc_types";
const logger = Logger.get("core/data_formats/parsing/quest"); const logger = Logger.get("core/data_formats/parsing/quest");
@ -284,7 +284,7 @@ function parse_npc_data(episode: number, npcs: DatNpc[]): QuestNpc[] {
pso_type_id: npc_data.type_id, pso_type_id: npc_data.type_id,
npc_id: npc_data.npc_id, npc_id: npc_data.npc_id,
script_label: Math.round(npc_data.script_label), script_label: Math.round(npc_data.script_label),
roaming: npc_data.roaming, pso_roaming: npc_data.roaming,
}; };
}); });
} }
@ -583,307 +583,29 @@ function npcs_to_dat_data(npcs: QuestNpc[]): DatNpc[] {
const dv = new DataView(new ArrayBuffer(4)); const dv = new DataView(new ArrayBuffer(4));
return npcs.map(npc => { return npcs.map(npc => {
const type_data = npc_type_to_dat_data(npc.type) || { const type_data = npc_data(npc.type);
type_id: npc.pso_type_id, const type_id =
roaming: npc.roaming, type_data.pso_type_id == undefined ? npc.pso_type_id : type_data.pso_type_id;
regular: true, const roaming = type_data.pso_roaming == undefined ? npc.pso_roaming : type_data.pso_roaming;
}; const regular = type_data.pso_regular == undefined ? true : type_data.pso_regular;
dv.setFloat32(0, npc.scale.y); dv.setFloat32(0, npc.scale.y);
dv.setUint32(0, (dv.getUint32(0) & ~0x800000) | (type_data.regular ? 0 : 0x800000)); dv.setUint32(0, (dv.getUint32(0) & ~0x800000) | (regular ? 0 : 0x800000));
const scale_y = dv.getFloat32(0); const scale_y = dv.getFloat32(0);
let scale = new Vec3(npc.scale.x, scale_y, npc.scale.z); let scale = new Vec3(npc.scale.x, scale_y, npc.scale.z);
return { return {
type_id: type_data.type_id, type_id,
section_id: npc.section_id, section_id: npc.section_id,
position: npc.position, position: npc.position,
rotation: npc.rotation, rotation: npc.rotation,
scale, scale,
npc_id: npc.npc_id, npc_id: npc.npc_id,
script_label: npc.script_label, script_label: npc.script_label,
roaming: type_data.roaming, roaming,
area_id: npc.area_id, area_id: npc.area_id,
unknown: npc.unknown, unknown: npc.unknown,
}; };
}); });
} }
function npc_type_to_dat_data(
type: NpcType,
): { type_id: number; roaming: number; regular: boolean } | undefined {
switch (type) {
default:
throw new Error(`Unexpected type ${NpcType[type]}.`);
case NpcType.Unknown:
return undefined;
case NpcType.FemaleFat:
return { type_id: 0x004, roaming: 0, regular: true };
case NpcType.FemaleMacho:
return { type_id: 0x005, roaming: 0, regular: true };
case NpcType.FemaleTall:
return { type_id: 0x007, roaming: 0, regular: true };
case NpcType.MaleDwarf:
return { type_id: 0x00a, roaming: 0, regular: true };
case NpcType.MaleFat:
return { type_id: 0x00b, roaming: 0, regular: true };
case NpcType.MaleMacho:
return { type_id: 0x00c, roaming: 0, regular: true };
case NpcType.MaleOld:
return { type_id: 0x00d, roaming: 0, regular: true };
case NpcType.BlueSoldier:
return { type_id: 0x019, roaming: 0, regular: true };
case NpcType.RedSoldier:
return { type_id: 0x01a, roaming: 0, regular: true };
case NpcType.Principal:
return { type_id: 0x01b, roaming: 0, regular: true };
case NpcType.Tekker:
return { type_id: 0x01c, roaming: 0, regular: true };
case NpcType.GuildLady:
return { type_id: 0x01d, roaming: 0, regular: true };
case NpcType.Scientist:
return { type_id: 0x01e, roaming: 0, regular: true };
case NpcType.Nurse:
return { type_id: 0x01f, roaming: 0, regular: true };
case NpcType.Irene:
return { type_id: 0x020, roaming: 0, regular: true };
case NpcType.ItemShop:
return { type_id: 0x0f1, roaming: 0, regular: true };
case NpcType.Nurse2:
return { type_id: 0x0fe, roaming: 0, regular: true };
case NpcType.Hildebear:
return { type_id: 0x040, roaming: 0, regular: true };
case NpcType.Hildeblue:
return { type_id: 0x040, roaming: 1, regular: true };
case NpcType.RagRappy:
return { type_id: 0x041, roaming: 0, regular: true };
case NpcType.AlRappy:
return { type_id: 0x041, roaming: 1, regular: true };
case NpcType.Monest:
return { type_id: 0x042, roaming: 0, regular: true };
case NpcType.SavageWolf:
return { type_id: 0x043, roaming: 0, regular: true };
case NpcType.BarbarousWolf:
return { type_id: 0x043, roaming: 0, regular: false };
case NpcType.Booma:
return { type_id: 0x044, roaming: 0, regular: true };
case NpcType.Gobooma:
return { type_id: 0x044, roaming: 1, regular: true };
case NpcType.Gigobooma:
return { type_id: 0x044, roaming: 2, regular: true };
case NpcType.Dragon:
return { type_id: 0x0c0, roaming: 0, regular: true };
case NpcType.GrassAssassin:
return { type_id: 0x060, roaming: 0, regular: true };
case NpcType.PoisonLily:
return { type_id: 0x061, roaming: 0, regular: true };
case NpcType.NarLily:
return { type_id: 0x061, roaming: 1, regular: true };
case NpcType.NanoDragon:
return { type_id: 0x062, roaming: 0, regular: true };
case NpcType.EvilShark:
return { type_id: 0x063, roaming: 0, regular: true };
case NpcType.PalShark:
return { type_id: 0x063, roaming: 1, regular: true };
case NpcType.GuilShark:
return { type_id: 0x063, roaming: 2, regular: true };
case NpcType.PofuillySlime:
return { type_id: 0x064, roaming: 0, regular: true };
case NpcType.PouillySlime:
return { type_id: 0x064, roaming: 0, regular: false };
case NpcType.PanArms:
return { type_id: 0x065, roaming: 0, regular: true };
case NpcType.DeRolLe:
return { type_id: 0x0c1, roaming: 0, regular: true };
case NpcType.Dubchic:
return { type_id: 0x080, roaming: 0, regular: true };
case NpcType.Gilchic:
return { type_id: 0x080, roaming: 1, regular: true };
case NpcType.Garanz:
return { type_id: 0x081, roaming: 0, regular: true };
case NpcType.SinowBeat:
return { type_id: 0x082, roaming: 0, regular: true };
case NpcType.SinowGold:
return { type_id: 0x082, roaming: 0, regular: false };
case NpcType.Canadine:
return { type_id: 0x083, roaming: 0, regular: true };
case NpcType.Canane:
return { type_id: 0x084, roaming: 0, regular: true };
case NpcType.Dubswitch:
return { type_id: 0x085, roaming: 0, regular: true };
case NpcType.VolOpt:
return { type_id: 0x0c5, roaming: 0, regular: true };
case NpcType.Delsaber:
return { type_id: 0x0a0, roaming: 0, regular: true };
case NpcType.ChaosSorcerer:
return { type_id: 0x0a1, roaming: 0, regular: true };
case NpcType.DarkGunner:
return { type_id: 0x0a2, roaming: 0, regular: true };
case NpcType.ChaosBringer:
return { type_id: 0x0a4, roaming: 0, regular: true };
case NpcType.DarkBelra:
return { type_id: 0x0a5, roaming: 0, regular: true };
case NpcType.Dimenian:
return { type_id: 0x0a6, roaming: 0, regular: true };
case NpcType.LaDimenian:
return { type_id: 0x0a6, roaming: 1, regular: true };
case NpcType.SoDimenian:
return { type_id: 0x0a6, roaming: 2, regular: true };
case NpcType.Bulclaw:
return { type_id: 0x0a7, roaming: 0, regular: true };
case NpcType.Claw:
return { type_id: 0x0a8, roaming: 0, regular: true };
case NpcType.DarkFalz:
return { type_id: 0x0c8, roaming: 0, regular: true };
case NpcType.Hildebear2:
return { type_id: 0x040, roaming: 0, regular: true };
case NpcType.Hildeblue2:
return { type_id: 0x040, roaming: 1, regular: true };
case NpcType.RagRappy2:
return { type_id: 0x041, roaming: 0, regular: true };
case NpcType.LoveRappy:
return { type_id: 0x041, roaming: 1, regular: true };
case NpcType.Monest2:
return { type_id: 0x042, roaming: 0, regular: true };
case NpcType.PoisonLily2:
return { type_id: 0x061, roaming: 0, regular: true };
case NpcType.NarLily2:
return { type_id: 0x061, roaming: 1, regular: true };
case NpcType.GrassAssassin2:
return { type_id: 0x060, roaming: 0, regular: true };
case NpcType.Dimenian2:
return { type_id: 0x0a6, roaming: 0, regular: true };
case NpcType.LaDimenian2:
return { type_id: 0x0a6, roaming: 1, regular: true };
case NpcType.SoDimenian2:
return { type_id: 0x0a6, roaming: 2, regular: true };
case NpcType.DarkBelra2:
return { type_id: 0x0a5, roaming: 0, regular: true };
case NpcType.BarbaRay:
return { type_id: 0x0cb, roaming: 0, regular: true };
case NpcType.SavageWolf2:
return { type_id: 0x043, roaming: 0, regular: true };
case NpcType.BarbarousWolf2:
return { type_id: 0x043, roaming: 0, regular: false };
case NpcType.PanArms2:
return { type_id: 0x065, roaming: 0, regular: true };
case NpcType.Dubchic2:
return { type_id: 0x080, roaming: 0, regular: true };
case NpcType.Gilchic2:
return { type_id: 0x080, roaming: 1, regular: true };
case NpcType.Garanz2:
return { type_id: 0x081, roaming: 0, regular: true };
case NpcType.Dubswitch2:
return { type_id: 0x085, roaming: 0, regular: true };
case NpcType.Delsaber2:
return { type_id: 0x0a0, roaming: 0, regular: true };
case NpcType.ChaosSorcerer2:
return { type_id: 0x0a1, roaming: 0, regular: true };
case NpcType.GolDragon:
return { type_id: 0x0cc, roaming: 0, regular: true };
case NpcType.SinowBerill:
return { type_id: 0x0d4, roaming: 0, regular: true };
case NpcType.SinowSpigell:
return { type_id: 0x0d4, roaming: 1, regular: true };
case NpcType.Merillia:
return { type_id: 0x0d5, roaming: 0, regular: true };
case NpcType.Meriltas:
return { type_id: 0x0d5, roaming: 1, regular: true };
case NpcType.Mericarol:
return { type_id: 0x0d6, roaming: 0, regular: true };
case NpcType.Mericus:
return { type_id: 0x0d6, roaming: 1, regular: true };
case NpcType.Merikle:
return { type_id: 0x0d6, roaming: 2, regular: true };
case NpcType.UlGibbon:
return { type_id: 0x0d7, roaming: 0, regular: true };
case NpcType.ZolGibbon:
return { type_id: 0x0d7, roaming: 1, regular: true };
case NpcType.Gibbles:
return { type_id: 0x0d8, roaming: 0, regular: true };
case NpcType.Gee:
return { type_id: 0x0d9, roaming: 0, regular: true };
case NpcType.GiGue:
return { type_id: 0x0da, roaming: 0, regular: true };
case NpcType.GalGryphon:
return { type_id: 0x0c0, roaming: 0, regular: true };
case NpcType.Deldepth:
return { type_id: 0x0db, roaming: 0, regular: true };
case NpcType.Delbiter:
return { type_id: 0x0dc, roaming: 0, regular: true };
case NpcType.Dolmolm:
return { type_id: 0x0dd, roaming: 0, regular: true };
case NpcType.Dolmdarl:
return { type_id: 0x0dd, roaming: 1, regular: true };
case NpcType.Morfos:
return { type_id: 0x0de, roaming: 0, regular: true };
case NpcType.Recobox:
return { type_id: 0x0df, roaming: 0, regular: true };
case NpcType.Epsilon:
return { type_id: 0x0e0, roaming: 0, regular: true };
case NpcType.SinowZoa:
return { type_id: 0x0e0, roaming: 0, regular: true };
case NpcType.SinowZele:
return { type_id: 0x0e0, roaming: 1, regular: true };
case NpcType.IllGill:
return { type_id: 0x0e1, roaming: 0, regular: true };
case NpcType.DelLily:
return { type_id: 0x061, roaming: 0, regular: true };
case NpcType.OlgaFlow:
return { type_id: 0x0ca, roaming: 0, regular: true };
case NpcType.SandRappy:
return { type_id: 0x041, roaming: 0, regular: true };
case NpcType.DelRappy:
return { type_id: 0x041, roaming: 1, regular: true };
case NpcType.Astark:
return { type_id: 0x110, roaming: 0, regular: true };
case NpcType.SatelliteLizard:
return { type_id: 0x111, roaming: 0, regular: true };
case NpcType.Yowie:
return { type_id: 0x111, roaming: 0, regular: false };
case NpcType.MerissaA:
return { type_id: 0x112, roaming: 0, regular: true };
case NpcType.MerissaAA:
return { type_id: 0x112, roaming: 1, regular: true };
case NpcType.Girtablulu:
return { type_id: 0x113, roaming: 0, regular: true };
case NpcType.Zu:
return { type_id: 0x114, roaming: 0, regular: true };
case NpcType.Pazuzu:
return { type_id: 0x114, roaming: 1, regular: true };
case NpcType.Boota:
return { type_id: 0x115, roaming: 0, regular: true };
case NpcType.ZeBoota:
return { type_id: 0x115, roaming: 1, regular: true };
case NpcType.BaBoota:
return { type_id: 0x115, roaming: 2, regular: true };
case NpcType.Dorphon:
return { type_id: 0x116, roaming: 0, regular: true };
case NpcType.DorphonEclair:
return { type_id: 0x116, roaming: 1, regular: true };
case NpcType.Goran:
return { type_id: 0x117, roaming: 0, regular: true };
case NpcType.PyroGoran:
return { type_id: 0x117, roaming: 1, regular: true };
case NpcType.GoranDetonator:
return { type_id: 0x117, roaming: 2, regular: true };
case NpcType.SaintMilion:
return { type_id: 0x119, roaming: 0, regular: true };
case NpcType.Shambertin:
return { type_id: 0x119, roaming: 1, regular: true };
case NpcType.Kondrieu:
return { type_id: 0x119, roaming: 0, regular: false };
}
}

File diff suppressed because it is too large Load Diff

View File

@ -29,10 +29,7 @@ export interface ListProperty<T> extends Property<T[]> {
get(index: number): T; get(index: number): T;
observe_list( observe_list(observer: (change: ListPropertyChangeEvent<T>) => void): Disposable;
observer: (change: ListPropertyChangeEvent<T>) => void,
options?: { call_now?: boolean },
): Disposable;
} }
export function is_list_property<T>(observable: Observable<T[]>): observable is ListProperty<T> { export function is_list_property<T>(observable: Observable<T[]>): observable is ListProperty<T> {

View File

@ -4,7 +4,7 @@ import { WritableProperty } from "../WritableProperty";
import { Observable } from "../../Observable"; import { Observable } from "../../Observable";
import { property } from "../../index"; import { property } from "../../index";
import { AbstractProperty } from "../AbstractProperty"; import { AbstractProperty } from "../AbstractProperty";
import { Property } from "../Property"; import { is_property, Property } from "../Property";
import { is_list_property, ListChangeType, ListPropertyChangeEvent } from "./ListProperty"; import { is_list_property, ListChangeType, ListPropertyChangeEvent } from "./ListProperty";
import Logger from "js-logger"; import Logger from "js-logger";
@ -97,12 +97,18 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
bind_to(observable: Observable<T[]>): Disposable { bind_to(observable: Observable<T[]>): Disposable {
if (is_list_property(observable)) { if (is_list_property(observable)) {
this.val = observable.val;
return observable.observe_list(change => { return observable.observe_list(change => {
if (change.type === ListChangeType.ListChange) { if (change.type === ListChangeType.ListChange) {
this.splice(change.index, change.removed.length, ...change.inserted); this.splice(change.index, change.removed.length, ...change.inserted);
} }
}); });
} else { } else {
if (is_property(observable)) {
this.val = observable.val;
}
return observable.observe(({ value }) => this.set_val(value)); return observable.observe(({ value }) => this.set_val(value));
} }
} }

View File

@ -72,10 +72,10 @@ export abstract class Renderer implements Disposable {
this.schedule_render(); this.schedule_render();
} }
pointer_pos_to_device_coords(e: MouseEvent): Vector2 { pointer_pos_to_device_coords(v: Vector2): Vector2 {
const coords = this.renderer.getSize(new Vector2()); const coords = this.renderer.getSize(new Vector2());
coords.width = (e.offsetX / coords.width) * 2 - 1; coords.width = (v.x / coords.width) * 2 - 1;
coords.height = (e.offsetY / coords.height) * -2 + 1; coords.height = (v.y / coords.height) * -2 + 1;
return coords; return coords;
} }

View File

@ -0,0 +1,22 @@
.quest_editor_EntityListView {
overflow: auto;
}
.quest_editor_EntityListView_entity_list {
display: grid;
grid-template-columns: repeat(auto-fill, 100px);
justify-content: center;
}
.quest_editor_EntityListView_entity {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
height: 100px;
padding: 5px;
border: solid 2px olivedrab;
background-color: darkolivegreen;
color: greenyellow;
}

View File

@ -0,0 +1,161 @@
import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { bind_children_to, el } from "../../core/gui/dom";
import "./EntityListView.css";
import { entity_data, EntityType } from "../../core/data_formats/parsing/quest/entities";
import { ListProperty } from "../../core/observable/property/list/ListProperty";
import { Vec2 } from "../../core/data_formats/vector";
import { Disposable } from "../../core/observable/Disposable";
export abstract class EntityListView<T extends EntityType> extends ResizableWidget {
readonly element: HTMLElement;
protected constructor(private readonly class_name: string, entities: ListProperty<T>) {
super();
const list_element = el.div({ class: "quest_editor_EntityListView_entity_list" });
this.element = el.div({ class: `${class_name} quest_editor_EntityListView` }, list_element);
this.disposables(
bind_children_to(list_element, entities, this.create_entity_element),
make_draggable(list_element, target => {
if (target !== list_element) {
const drag_element = target.cloneNode(true) as HTMLElement;
drag_element.style.width = "100px";
return [drag_element, entities.get(parseInt(target.dataset.index!, 10))];
} else {
return undefined;
}
}),
);
}
private create_entity_element = (entity: T, index: number): HTMLElement => {
const div = el.div({
class: "quest_editor_EntityListView_entity",
text: entity_data(entity).name,
data: { index: index.toString() },
});
div.draggable = true;
return div;
};
}
export type EntityDrag = {
readonly offset_x: number;
readonly offset_y: number;
readonly data_transfer: DataTransfer;
readonly drag_element: HTMLElement;
readonly entity_type: EntityType;
};
function make_draggable(
element: HTMLElement,
start: (target: HTMLElement) => [HTMLElement, any] | undefined,
): Disposable {
let detail: { drag_element: HTMLElement; entity_type: EntityType } | undefined;
const grab_point = new Vec2(0, 0);
function clear(): void {
if (detail) {
detail.drag_element.remove();
detail = undefined;
}
}
function redispatch(e: DragEvent): void {
if (e.target instanceof HTMLElement && detail && e.dataTransfer) {
e.target.dispatchEvent(
new CustomEvent<EntityDrag>(`phantasmal-${e.type}`, {
detail: {
...detail,
data_transfer: e.dataTransfer,
offset_x: e.offsetX,
offset_y: e.offsetY,
},
bubbles: true,
}),
);
}
}
function dragstart(e: DragEvent): void {
if (e.target instanceof HTMLElement) {
clear();
const result = start(e.target);
if (result) {
grab_point.set(e.offsetX + 2, e.offsetY + 2);
detail = {
drag_element: result[0],
entity_type: result[1],
};
detail.drag_element.style.position = "fixed";
detail.drag_element.style.pointerEvents = "none";
detail.drag_element.style.zIndex = "500";
detail.drag_element.style.top = "0";
detail.drag_element.style.left = "0";
detail.drag_element.style.transform = `translate(${e.clientX -
grab_point.x}px, ${e.clientY - grab_point.y}px)`;
document.body.append(detail.drag_element);
e.dataTransfer!.setDragImage(el.div(), 0, 0);
}
}
}
function dragenter(e: DragEvent): void {
redispatch(e);
}
function dragover(e: DragEvent): void {
if (e.target instanceof HTMLElement && detail) {
detail.drag_element.style.transform = `translate(${e.clientX -
grab_point.x}px, ${e.clientY - grab_point.y}px)`;
redispatch(e);
}
}
function dragleave(e: DragEvent): void {
redispatch(e);
}
function dragend(): void {
clear();
}
function drop(e: DragEvent): void {
try {
redispatch(e);
} finally {
clear();
}
}
element.addEventListener("dragstart", dragstart);
document.addEventListener("dragenter", dragenter);
document.addEventListener("dragover", dragover);
document.addEventListener("dragleave", dragleave);
document.addEventListener("dragend", dragend);
document.addEventListener("drop", drop);
return {
dispose(): void {
element.removeEventListener("dragstart", dragstart);
document.removeEventListener("dragenter", dragenter);
document.removeEventListener("dragover", dragover);
document.removeEventListener("dragleave", dragleave);
document.removeEventListener("dragend", dragend);
document.removeEventListener("drop", drop);
clear();
},
};
}

View File

@ -2,9 +2,10 @@ import { ResizableWidget } from "../../core/gui/ResizableWidget";
import { el } from "../../core/gui/dom"; import { el } from "../../core/gui/dom";
import { quest_editor_store } from "../stores/QuestEditorStore"; import { quest_editor_store } from "../stores/QuestEditorStore";
import { npc_data, NpcType } from "../../core/data_formats/parsing/quest/npc_types"; import { npc_data, NpcType } from "../../core/data_formats/parsing/quest/npc_types";
import { QuestModel } from "../model/QuestModel";
import "./NpcCountsView.css"; import "./NpcCountsView.css";
import { DisabledView } from "./DisabledView"; import { DisabledView } from "./DisabledView";
import { property } from "../../core/observable";
import { QuestNpcModel } from "../model/QuestNpcModel";
export class NpcCountsView extends ResizableWidget { export class NpcCountsView extends ResizableWidget {
readonly element = el.div({ class: "quest_editor_NpcCountsView" }); readonly element = el.div({ class: "quest_editor_NpcCountsView" });
@ -26,24 +27,24 @@ export class NpcCountsView extends ResizableWidget {
this.disposables( this.disposables(
this.no_quest_view.visible.bind_to(no_quest), this.no_quest_view.visible.bind_to(no_quest),
quest.observe(({ value }) => this.update_view(value), { quest
call_now: true, .flat_map(quest => (quest ? quest.npcs : property([])))
}), .observe(({ value: npcs }) => this.update_view(npcs), {
call_now: true,
}),
); );
this.finalize_construction(NpcCountsView.prototype); this.finalize_construction(NpcCountsView.prototype);
} }
private update_view(quest?: QuestModel): void { private update_view(npcs: QuestNpcModel[]): void {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
const npc_counts = new Map<NpcType, number>(); const npc_counts = new Map<NpcType, number>();
if (quest) { for (const npc of npcs) {
for (const npc of quest.npcs.val) { const val = npc_counts.get(npc.type) || 0;
const val = npc_counts.get(npc.type) || 0; npc_counts.set(npc.type, val + 1);
npc_counts.set(npc.type, val + 1);
}
} }
const extra_canadines = (npc_counts.get(NpcType.Canane) || 0) * 8; const extra_canadines = (npc_counts.get(NpcType.Canane) || 0) * 8;

View File

@ -0,0 +1,11 @@
import { EntityListView } from "./EntityListView";
import { NPC_TYPES, NpcType } from "../../core/data_formats/parsing/quest/npc_types";
import { list_property } from "../../core/observable";
export class NpcListView extends EntityListView<NpcType> {
constructor() {
super("quest_editor_NpcListView", list_property(undefined, ...NPC_TYPES));
this.finalize_construction(NpcListView.prototype);
}
}

View File

@ -12,6 +12,7 @@ import { AsmEditorView } from "./AsmEditorView";
import { EntityInfoView } from "./EntityInfoView"; import { EntityInfoView } from "./EntityInfoView";
import { gui_store, GuiTool } from "../../core/stores/GuiStore"; import { gui_store, GuiTool } from "../../core/stores/GuiStore";
import { quest_editor_store } from "../stores/QuestEditorStore"; import { quest_editor_store } from "../stores/QuestEditorStore";
import { NpcListView } from "./NpcListView";
import Logger = require("js-logger"); import Logger = require("js-logger");
const logger = Logger.get("quest_editor/gui/QuestEditorView"); const logger = Logger.get("quest_editor/gui/QuestEditorView");
@ -23,7 +24,7 @@ const VIEW_TO_NAME = new Map<new () => ResizableWidget, string>([
[QuestRendererView, "quest_renderer"], [QuestRendererView, "quest_renderer"],
[AsmEditorView, "asm_editor"], [AsmEditorView, "asm_editor"],
[EntityInfoView, "entity_info"], [EntityInfoView, "entity_info"],
// [AddObjectView, "add_object"], [NpcListView, "npc_list_view"],
]); ]);
const DEFAULT_LAYOUT_CONFIG = { const DEFAULT_LAYOUT_CONFIG = {
@ -83,11 +84,22 @@ const DEFAULT_LAYOUT_CONTENT: ItemConfigType[] = [
], ],
}, },
{ {
title: "Entity", type: "stack",
type: "component",
componentName: VIEW_TO_NAME.get(EntityInfoView),
isClosable: false,
width: 2, width: 2,
content: [
{
title: "Entity",
type: "component",
componentName: VIEW_TO_NAME.get(EntityInfoView),
isClosable: false,
},
{
title: "NPCs",
type: "component",
componentName: VIEW_TO_NAME.get(NpcListView),
isClosable: false,
},
],
}, },
], ],
}, },

View File

@ -11,6 +11,7 @@ import {
area_collision_geometry_to_object_3d, area_collision_geometry_to_object_3d,
area_geometry_to_sections_and_object_3d, area_geometry_to_sections_and_object_3d,
} from "../rendering/conversion/areas"; } from "../rendering/conversion/areas";
import { AreaVariantModel } from "../model/AreaVariantModel";
const render_geometry_cache = new LoadingCache< const render_geometry_cache = new LoadingCache<
string, string,
@ -20,46 +21,47 @@ const collision_geometry_cache = new LoadingCache<string, Promise<Object3D>>();
export async function load_area_sections( export async function load_area_sections(
episode: Episode, episode: Episode,
area_id: number, area_variant: AreaVariantModel,
area_variant: number,
): Promise<SectionModel[]> { ): Promise<SectionModel[]> {
return render_geometry_cache.get_or_set(`${episode}-${area_id}-${area_variant}`, () => return render_geometry_cache.get_or_set(
load_area_sections_and_render_geometry(episode, area_id, area_variant), `${episode}-${area_variant.area.id}-${area_variant.id}`,
() => load_area_sections_and_render_geometry(episode, area_variant),
).sections; ).sections;
} }
export async function load_area_render_geometry( export async function load_area_render_geometry(
episode: Episode, episode: Episode,
area_id: number, area_variant: AreaVariantModel,
area_variant: number,
): Promise<Object3D> { ): Promise<Object3D> {
return render_geometry_cache.get_or_set(`${episode}-${area_id}-${area_variant}`, () => return render_geometry_cache.get_or_set(
load_area_sections_and_render_geometry(episode, area_id, area_variant), `${episode}-${area_variant.area.id}-${area_variant.id}`,
() => load_area_sections_and_render_geometry(episode, area_variant),
).geometry; ).geometry;
} }
export async function load_area_collision_geometry( export async function load_area_collision_geometry(
episode: Episode, episode: Episode,
area_id: number, area_variant: AreaVariantModel,
area_variant: number,
): Promise<Object3D> { ): Promise<Object3D> {
return collision_geometry_cache.get_or_set(`${episode}-${area_id}-${area_variant}`, () => return collision_geometry_cache.get_or_set(
get_area_asset(episode, area_id, area_variant, "collision").then(buffer => `${episode}-${area_variant.area.id}-${area_variant.id}`,
area_collision_geometry_to_object_3d( () =>
parse_area_collision_geometry(new ArrayBufferCursor(buffer, Endianness.Little)), get_area_asset(episode, 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( function load_area_sections_and_render_geometry(
episode: Episode, episode: Episode,
area_id: number, area_variant: AreaVariantModel,
area_variant: number,
): { geometry: Promise<Object3D>; sections: Promise<SectionModel[]> } { ): { geometry: Promise<Object3D>; sections: Promise<SectionModel[]> } {
const promise = get_area_asset(episode, area_id, area_variant, "render").then(buffer => const promise = get_area_asset(episode, area_variant, "render").then(buffer =>
area_geometry_to_sections_and_object_3d( area_geometry_to_sections_and_object_3d(
parse_area_geometry(new ArrayBufferCursor(buffer, Endianness.Little)), parse_area_geometry(new ArrayBufferCursor(buffer, Endianness.Little)),
area_variant,
), ),
); );
@ -126,21 +128,23 @@ const area_base_names = [
async function get_area_asset( async function get_area_asset(
episode: Episode, episode: Episode,
area_id: number, area_variant: AreaVariantModel,
area_variant: number,
type: "render" | "collision", type: "render" | "collision",
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const base_url = area_version_to_base_url(episode, area_id, area_variant); const base_url = area_version_to_base_url(episode, area_variant);
const suffix = type === "render" ? "n.rel" : "c.rel"; const suffix = type === "render" ? "n.rel" : "c.rel";
return load_array_buffer(base_url + suffix); return load_array_buffer(base_url + suffix);
} }
function area_version_to_base_url(episode: Episode, area_id: number, area_variant: number): string { function area_version_to_base_url(episode: Episode, area_variant: AreaVariantModel): string {
// Exception for Seaside area at night variant 1. let area_id = area_variant.area.id;
let area_variant_id = area_variant.id;
// Exception for Seaside area at night, variant 1.
// Phantasmal World 4 and Lost heart breaker use this to have two tower maps. // Phantasmal World 4 and Lost heart breaker use this to have two tower maps.
if (area_id === 16 && area_variant === 1) { if (area_id === 16 && area_variant_id === 1) {
area_id = 17; area_id = 17;
area_variant = 1; area_variant_id = 1;
} }
const episode_base_names = area_base_names[episode - 1]; const episode_base_names = area_base_names[episode - 1];
@ -148,20 +152,20 @@ function area_version_to_base_url(episode: Episode, area_id: number, area_varian
if (0 <= area_id && area_id < episode_base_names.length) { if (0 <= area_id && area_id < episode_base_names.length) {
const [base_name, variants] = episode_base_names[area_id]; const [base_name, variants] = episode_base_names[area_id];
if (0 <= area_variant && area_variant < variants) { if (0 <= area_variant_id && area_variant_id < variants) {
let variant: string; let variant: string;
if (variants === 1) { if (variants === 1) {
variant = ""; variant = "";
} else { } else {
variant = String(area_variant); variant = String(area_variant_id);
while (variant.length < 2) variant = "0" + variant; variant = variant.padStart(2, "0");
} }
return `/maps/map_${base_name}${variant}`; return `/maps/map_${base_name}${variant}`;
} else { } else {
throw new Error( throw new Error(
`Unknown variant ${area_variant} of area ${area_id} in episode ${episode}.`, `Unknown variant ${area_variant_id} of area ${area_id} in episode ${episode}.`,
); );
} }
} else { } else {

View File

@ -5,11 +5,10 @@ import { AreaModel } from "./AreaModel";
import { SectionModel } from "./SectionModel"; import { SectionModel } from "./SectionModel";
export class AreaVariantModel { export class AreaVariantModel {
readonly id: number;
readonly area: AreaModel;
private readonly _sections: WritableListProperty<SectionModel> = list_property(); private readonly _sections: WritableListProperty<SectionModel> = list_property();
readonly id: number;
readonly area: AreaModel;
readonly sections: ListProperty<SectionModel> = this._sections; readonly sections: ListProperty<SectionModel> = this._sections;
constructor(id: number, area: AreaModel) { constructor(id: number, area: AreaModel) {

View File

@ -73,6 +73,12 @@ export abstract class QuestEntityModel<Type extends EntityType = EntityType> {
position: Vec3, position: Vec3,
rotation: Vec3, rotation: Vec3,
) { ) {
if (type == undefined) throw new Error("type is required.");
if (!Number.isInteger(area_id)) throw new Error("area_id should be an integer.");
if (!Number.isInteger(section_id)) throw new Error("section_id should be an integer.");
if (!position) throw new Error("position is required.");
if (!rotation) throw new Error("rotation is required.");
this.type = type; this.type = type;
this.area_id = area_id; this.area_id = area_id;
this.section = this._section; this.section = this._section;

View File

@ -11,6 +11,7 @@ import { AreaVariantModel } from "./AreaVariantModel";
import { area_store } from "../stores/AreaStore"; import { area_store } from "../stores/AreaStore";
import { ListProperty } from "../../core/observable/property/list/ListProperty"; import { ListProperty } from "../../core/observable/property/list/ListProperty";
import { WritableListProperty } from "../../core/observable/property/list/WritableListProperty"; import { WritableListProperty } from "../../core/observable/property/list/WritableListProperty";
import { QuestEntityModel } from "./QuestEntityModel";
const logger = Logger.get("quest_editor/model/QuestModel"); const logger = Logger.get("quest_editor/model/QuestModel");
@ -110,6 +111,7 @@ export class QuestModel {
private readonly _long_description: WritableProperty<string> = property(""); private readonly _long_description: WritableProperty<string> = property("");
private readonly _map_designations: WritableProperty<Map<number, number>>; private readonly _map_designations: WritableProperty<Map<number, number>>;
private readonly _area_variants: WritableListProperty<AreaVariantModel> = list_property(); private readonly _area_variants: WritableListProperty<AreaVariantModel> = list_property();
private readonly _npcs: WritableListProperty<QuestNpcModel>;
constructor( constructor(
id: number, id: number,
@ -149,7 +151,8 @@ export class QuestModel {
this._map_designations = property(map_designations); this._map_designations = property(map_designations);
this.map_designations = this._map_designations; this.map_designations = this._map_designations;
this.objects = list_property(undefined, ...objects); this.objects = list_property(undefined, ...objects);
this.npcs = list_property(undefined, ...npcs); this._npcs = list_property(undefined, ...npcs);
this.npcs = this._npcs;
this.dat_unknowns = dat_unknowns; this.dat_unknowns = dat_unknowns;
this.object_code = object_code; this.object_code = object_code;
this.shop_items = shop_items; this.shop_items = shop_items;
@ -176,6 +179,18 @@ export class QuestModel {
this.map_designations.observe(this.update_area_variants); this.map_designations.observe(this.update_area_variants);
} }
add_npc(npc: QuestNpcModel): void {
this._npcs.push(npc);
}
remove_entity(entity: QuestEntityModel): void {
if (entity instanceof QuestNpcModel) {
this._npcs.remove(entity);
} else {
// TODO: objects
}
}
private update_area_variants = (): void => { private update_area_variants = (): void => {
const variants = new Map<number, AreaVariantModel>(); const variants = new Map<number, AreaVariantModel>();

View File

@ -6,7 +6,7 @@ export class QuestNpcModel extends QuestEntityModel<NpcType> {
readonly pso_type_id: number; readonly pso_type_id: number;
readonly npc_id: number; readonly npc_id: number;
readonly script_label: number; readonly script_label: number;
readonly roaming: number; readonly pso_roaming: number;
readonly scale: Vec3; readonly scale: Vec3;
/** /**
* Data of which the purpose hasn't been discovered yet. * Data of which the purpose hasn't been discovered yet.
@ -18,7 +18,7 @@ export class QuestNpcModel extends QuestEntityModel<NpcType> {
pso_type_id: number, pso_type_id: number,
npc_id: number, npc_id: number,
script_label: number, script_label: number,
roaming: number, pso_roaming: number,
area_id: number, area_id: number,
section_id: number, section_id: number,
position: Vec3, position: Vec3,
@ -26,12 +26,27 @@ export class QuestNpcModel extends QuestEntityModel<NpcType> {
scale: Vec3, scale: Vec3,
unknown: number[][], unknown: number[][],
) { ) {
if (!Number.isInteger(pso_type_id)) throw new Error("pso_type_id should be an integer.");
if (!Number.isFinite(npc_id)) throw new Error("npc_id should be a number.");
if (!Number.isInteger(script_label)) throw new Error("script_label should be an integer.");
if (!Number.isInteger(pso_roaming)) throw new Error("pso_roaming should be an integer.");
if (!scale) throw new Error("scale is required.");
if (!unknown) throw new Error("unknown is required.");
if (unknown.length !== 3)
throw new Error(`unknown should be of length 3, was ${unknown.length}.`);
if (unknown[0].length !== 10)
throw new Error(`unknown[0] should be of length 10, was ${unknown[0].length}`);
if (unknown[1].length !== 6)
throw new Error(`unknown[1] should be of length 6, was ${unknown[1].length}`);
if (unknown[2].length !== 4)
throw new Error(`unknown[2] should be of length 4, was ${unknown[2].length}`);
super(type, area_id, section_id, position, rotation); super(type, area_id, section_id, position, rotation);
this.pso_type_id = pso_type_id; this.pso_type_id = pso_type_id;
this.npc_id = npc_id; this.npc_id = npc_id;
this.script_label = script_label; this.script_label = script_label;
this.roaming = roaming; this.pso_roaming = pso_roaming;
this.unknown = unknown; this.unknown = unknown;
this.scale = scale; this.scale = scale;
} }

View File

@ -1,4 +1,5 @@
import { Vec3 } from "../../core/data_formats/vector"; import { Vec3 } from "../../core/data_formats/vector";
import { AreaVariantModel } from "./AreaVariantModel";
export class SectionModel { export class SectionModel {
readonly id: number; readonly id: number;
@ -6,17 +7,25 @@ export class SectionModel {
readonly y_axis_rotation: number; readonly y_axis_rotation: number;
readonly sin_y_axis_rotation: number; readonly sin_y_axis_rotation: number;
readonly cos_y_axis_rotation: number; readonly cos_y_axis_rotation: number;
readonly area_variant: AreaVariantModel;
constructor(id: number, position: Vec3, y_axis_rotation: number) { constructor(
id: number,
position: Vec3,
y_axis_rotation: number,
area_variant: AreaVariantModel,
) {
if (!Number.isInteger(id) || id < -1) if (!Number.isInteger(id) || id < -1)
throw new Error(`Expected id to be an integer greater than or equal to -1, got ${id}.`); throw new Error(`Expected id to be an integer greater than or equal to -1, got ${id}.`);
if (!position) throw new Error("position is required."); if (!position) throw new Error("position is required.");
if (!Number.isFinite(y_axis_rotation)) throw new Error("y_axis_rotation is required."); if (!Number.isFinite(y_axis_rotation)) throw new Error("y_axis_rotation is required.");
if (!area_variant) throw new Error("area_variant is required.");
this.id = id; this.id = id;
this.position = position; this.position = position;
this.y_axis_rotation = y_axis_rotation; this.y_axis_rotation = y_axis_rotation;
this.sin_y_axis_rotation = Math.sin(this.y_axis_rotation); this.sin_y_axis_rotation = Math.sin(this.y_axis_rotation);
this.cos_y_axis_rotation = Math.cos(this.y_axis_rotation); this.cos_y_axis_rotation = Math.cos(this.y_axis_rotation);
this.area_variant = area_variant;
} }
} }

View File

@ -9,6 +9,9 @@ import { AreaUserData } from "./conversion/areas";
import { SectionModel } from "../model/SectionModel"; import { SectionModel } from "../model/SectionModel";
import { Disposable } from "../../core/observable/Disposable"; import { Disposable } from "../../core/observable/Disposable";
import { Disposer } from "../../core/observable/Disposer"; import { Disposer } from "../../core/observable/Disposer";
import { EntityDrag } from "../gui/EntityListView";
import { is_npc_type } from "../../core/data_formats/parsing/quest/entities";
import { npc_data } from "../../core/data_formats/parsing/quest/npc_types";
const DOWN_VECTOR = new Vector3(0, -1, 0); const DOWN_VECTOR = new Vector3(0, -1, 0);
@ -18,10 +21,28 @@ type Highlighted = {
}; };
type Pick = { type Pick = {
/**
* Whether we picked an entity that is being created or one that has existed before.
*/
creating: boolean;
initial_section?: SectionModel; initial_section?: SectionModel;
initial_position: Vec3; initial_position: Vec3;
/**
* Vector that points from the grabbing point to the model's origin.
*/
grab_offset: Vector3; grab_offset: Vector3;
/**
* Vector that points from the grabbing point to the terrain point directly under the model's origin.
*/
drag_adjust: Vector3; drag_adjust: Vector3;
/**
* Distance to terrain.
*/
drag_y: number; drag_y: number;
}; };
@ -57,6 +78,13 @@ export class QuestEntityControls implements Disposable {
} }
}), }),
); );
renderer.dom_element.addEventListener("mousedown", this.mousedown);
renderer.dom_element.addEventListener("mousemove", this.mousemove);
renderer.dom_element.addEventListener("mouseup", this.mouseup);
renderer.dom_element.addEventListener("phantasmal-dragenter", this.dragenter);
renderer.dom_element.addEventListener("phantasmal-dragover", this.dragover);
renderer.dom_element.addEventListener("phantasmal-dragleave", this.dragleave);
} }
dispose(): void { dispose(): void {
@ -71,14 +99,22 @@ export class QuestEntityControls implements Disposable {
if (mesh) { if (mesh) {
this.select({ entity, mesh }); this.select({ entity, mesh });
} else {
if (this.selected) {
set_color(this.selected, ColorType.Normal);
}
this.selected = undefined;
} }
}; };
on_mouse_down = (e: MouseEvent) => { private mousedown = (e: MouseEvent) => {
this.process_event(e); this.process_event(e);
this.stop_transforming(); this.stop_transforming();
const new_pick = this.pick_entity(this.renderer.pointer_pos_to_device_coords(e)); const new_pick = this.pick_entity(
this.renderer.pointer_pos_to_device_coords(new Vector2(e.offsetX, e.offsetY)),
);
if (new_pick) { if (new_pick) {
// Disable camera controls while the user is transforming an entity. // Disable camera controls while the user is transforming an entity.
@ -93,25 +129,12 @@ export class QuestEntityControls implements Disposable {
this.renderer.schedule_render(); this.renderer.schedule_render();
}; };
on_mouse_up = (e: MouseEvent) => { private mousemove = (e: MouseEvent) => {
this.process_event(e); this.process_event(e);
if (!this.moved_since_last_mouse_down && !this.pick) { const pointer_device_pos = this.renderer.pointer_pos_to_device_coords(
// If the user clicks on nothing, deselect the currently selected entity. new Vector2(e.offsetX, e.offsetY),
this.deselect(); );
}
this.stop_transforming();
// Enable camera controls again after transforming an entity.
this.renderer.controls.enabled = true;
this.renderer.schedule_render();
};
on_mouse_move = (e: MouseEvent) => {
this.process_event(e);
const pointer_device_pos = this.renderer.pointer_pos_to_device_coords(e);
if (this.selected && this.pick) { if (this.selected && this.pick) {
if (this.moved_since_last_mouse_down) { if (this.moved_since_last_mouse_down) {
@ -139,19 +162,168 @@ export class QuestEntityControls implements Disposable {
} }
}; };
private process_event(e: MouseEvent): void { private mouseup = (e: MouseEvent) => {
if (e.type === "mousedown") { this.process_event(e);
if (!this.moved_since_last_mouse_down && !this.pick) {
// If the user clicks on nothing, deselect the currently selected entity.
this.deselect();
}
this.stop_transforming();
// Enable camera controls again after transforming an entity.
this.renderer.controls.enabled = true;
this.renderer.schedule_render();
};
private dragenter = (e: Event) => {
const area = quest_editor_store.current_area.val;
if (!area) return;
const detail = (e as CustomEvent<EntityDrag>).detail;
const pointer_position = this.renderer.pointer_pos_to_device_coords(
new Vector2(detail.offset_x, detail.offset_y),
);
const { intersection, section } = this.pick_terrain(pointer_position, new Vector3());
let position: Vec3 | undefined;
if (intersection) {
position = new Vec3(intersection.point.x, intersection.point.y, intersection.point.z);
} else {
// If the cursor is not over any terrain, we translate the entity across the horizontal plane in which the origin lies.
this.raycaster.setFromCamera(pointer_position, this.renderer.camera);
const ray = this.raycaster.ray;
const plane = new Plane(new Vector3(0, 1, 0), 0);
const intersection_point = new Vector3();
if (ray.intersectPlane(plane, intersection_point)) {
position = new Vec3(intersection_point.x, 0, intersection_point.z);
}
}
const quest = quest_editor_store.current_quest.val;
if (quest && position) {
if (is_npc_type(detail.entity_type)) {
const data = npc_data(detail.entity_type);
if (data.pso_type_id !== undefined && data.pso_roaming != undefined) {
detail.drag_element.style.display = "none";
detail.data_transfer.dropEffect = "copy";
const npc = new QuestNpcModel(
detail.entity_type,
data.pso_type_id,
0,
0,
data.pso_roaming,
section ? section.area_variant.area.id : area.id,
section ? section.id : 0,
new Vec3(0, 0, 0),
new Vec3(0, 0, 0),
new Vec3(1, 1, 1),
// TODO: do the following values make sense?
[[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [0, 0, 0, 0]],
);
npc.set_world_position(position);
quest.add_npc(npc);
quest_editor_store.set_selected_entity(npc);
this.pick = {
creating: true,
initial_section: section,
initial_position: position,
grab_offset: new Vector3(0, 0, 0),
drag_adjust: new Vector3(0, 0, 0),
drag_y: 0,
};
}
}
}
};
private dragover = (e: Event) => {
if (!quest_editor_store.current_area.val) return;
const detail = (e as CustomEvent<EntityDrag>).detail;
detail.data_transfer.dropEffect = "copy";
if (this.selected && this.pick) {
const pointer_device_pos = this.renderer.pointer_pos_to_device_coords(
new Vector2(detail.offset_x, detail.offset_y),
);
// TODO:
// if (e.buttons === 1) {
// User is transforming selected entity.
// User is dragging selected entity.
// if (e.shiftKey) {
// Vertical movement.
// this.translate_vertically(this.selected, this.pick, pointer_device_pos);
// } else {
// Horizontal movement across terrain.
this.translate_horizontally(this.selected, this.pick, pointer_device_pos);
// }
// }
this.renderer.schedule_render();
}
};
private dragleave = (e: Event) => {
if (!quest_editor_store.current_area.val) return;
const detail = (e as CustomEvent<EntityDrag>).detail;
if (detail.drag_element) detail.drag_element.style.display = "flex";
const quest = quest_editor_store.current_quest.val;
if (quest && this.selected && this.selected.entity.type == detail.entity_type) {
quest.remove_entity(this.selected.entity);
}
};
private drop = () => {
// TODO: push onto undo stack.
};
private process_event(e: Event): void {
let offset_x: number;
let offset_y: number;
if (e instanceof MouseEvent) {
offset_x = e.offsetX;
offset_y = e.offsetY;
} else if (e instanceof CustomEvent) {
const detail = (e as CustomEvent<EntityDrag>).detail;
if (!("offset_x" in detail && "offset_y" in detail)) {
return;
}
offset_x = detail.offset_x;
offset_y = detail.offset_y;
} else {
return;
}
if (e.type === "mousedown" || e.type === "phantasmal-dragenter") {
this.moved_since_last_mouse_down = false; this.moved_since_last_mouse_down = false;
} else { } else {
if ( if (
e.offsetX !== this.last_pointer_position.x || offset_x !== this.last_pointer_position.x ||
e.offsetY !== this.last_pointer_position.y offset_y !== this.last_pointer_position.y
) { ) {
this.moved_since_last_mouse_down = true; this.moved_since_last_mouse_down = true;
} }
} }
this.last_pointer_position.set(e.offsetX, e.offsetY); this.last_pointer_position.set(offset_x, offset_y);
} }
/** /**
@ -246,7 +418,7 @@ export class QuestEntityControls implements Disposable {
pointer_position: Vector2, pointer_position: Vector2,
): void { ): void {
// Cast ray adjusted for dragging entities. // Cast ray adjusted for dragging entities.
const { intersection, section } = this.pick_terrain(pointer_position, pick); const { intersection, section } = this.pick_terrain(pointer_position, pick.drag_adjust);
if (intersection) { if (intersection) {
selection.entity.set_world_position( selection.entity.set_world_position(
@ -284,7 +456,7 @@ export class QuestEntityControls implements Disposable {
} }
private stop_transforming = () => { private stop_transforming = () => {
if (this.moved_since_last_mouse_down && this.selected && this.pick) { if (this.moved_since_last_mouse_down && this.selected && this.pick && !this.pick.creating) {
const entity = this.selected.entity; const entity = this.selected.entity;
quest_editor_store.push_translate_entity_action( quest_editor_store.push_translate_entity_action(
entity, entity,
@ -314,11 +486,8 @@ export class QuestEntityControls implements Disposable {
} }
const entity = (intersection.object.userData as EntityUserData).entity; 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); 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(); const drag_adjust = grab_offset.clone();
// Distance to terrain.
let drag_y = 0; let drag_y = 0;
// Find vertical distance to terrain. // Find vertical distance to terrain.
@ -334,6 +503,7 @@ export class QuestEntityControls implements Disposable {
} }
return { return {
creating: false,
mesh: intersection.object as Mesh, mesh: intersection.object as Mesh,
entity, entity,
initial_section: entity.section.val, initial_section: entity.section.val,
@ -346,17 +516,17 @@ export class QuestEntityControls implements Disposable {
/** /**
* @param pointer_pos - pointer coordinates in normalized device space * @param pointer_pos - pointer coordinates in normalized device space
* @param data - entity picking data * @param drag_adjust - vector from origin of entity to grabbing point
*/ */
private pick_terrain( private pick_terrain(
pointer_pos: Vector2, pointer_pos: Vector2,
data: Pick, drag_adjust: Vector3,
): { ): {
intersection?: Intersection; intersection?: Intersection;
section?: SectionModel; section?: SectionModel;
} { } {
this.raycaster.setFromCamera(pointer_pos, this.renderer.camera); this.raycaster.setFromCamera(pointer_pos, this.renderer.camera);
this.raycaster.ray.origin.add(data.drag_adjust); this.raycaster.ray.origin.add(drag_adjust);
const intersections = this.raycaster.intersectObjects( const intersections = this.raycaster.intersectObjects(
this.renderer.collision_geometry.children, this.renderer.collision_geometry.children,
true, true,

View File

@ -13,10 +13,16 @@ import { QuestEntityModel } from "../model/QuestEntityModel";
import { Disposer } from "../../core/observable/Disposer"; import { Disposer } from "../../core/observable/Disposer";
import { Disposable } from "../../core/observable/Disposable"; import { Disposable } from "../../core/observable/Disposable";
import { AreaModel } from "../model/AreaModel"; import { AreaModel } from "../model/AreaModel";
import { AreaVariantModel } from "../model/AreaVariantModel";
import { area_store } from "../stores/AreaStore";
import { create_npc_mesh, create_object_mesh } from "./conversion/entities"; import { create_npc_mesh, create_object_mesh } from "./conversion/entities";
import { AreaUserData } from "./conversion/areas"; 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 logger = Logger.get("quest_editor/rendering/QuestModelManager");
@ -25,122 +31,153 @@ const CAMERA_LOOK_AT = new Vector3(0, 0, 0);
const DUMMY_OBJECT = new Object3D(); const DUMMY_OBJECT = new Object3D();
export class QuestModelManager implements Disposable { export class QuestModelManager implements Disposable {
private quest?: QuestModel; private readonly disposer = new Disposer();
private area?: AreaModel; private readonly quest_disposer = this.disposer.add(new Disposer());
private area_variant?: AreaVariantModel; private readonly area_model_manager: AreaModelManager;
private disposer = new Disposer(); private readonly npc_model_manager: EntityModelManager;
private readonly object_model_manager: EntityModelManager;
constructor(private renderer: QuestRenderer) {} 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);
async load_models(quest?: QuestModel, area?: AreaModel): Promise<void> { this.disposer.add_all(
let area_variant: AreaVariantModel | undefined; quest_editor_store.current_quest.observe(this.quest_or_area_changed),
if (quest && area) { quest_editor_store.current_area.observe(this.quest_or_area_changed),
area_variant = );
quest.area_variants.val.find(v => v.area.id === area.id) ||
area_store.get_variant(quest.episode, area.id, 0);
}
if (this.quest === quest && this.area_variant === area_variant) {
return;
}
this.quest = quest;
this.area = area;
this.area_variant = area_variant;
this.disposer.dispose_all();
if (quest && area) {
try {
// Load necessary area geometry.
const episode = quest.episode;
const area_id = area.id;
const variant_id = area_variant ? area_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,
);
this.add_sections_to_collision_geometry(collision_geometry, render_geometry);
if (this.quest !== quest || this.area_variant !== area_variant) return;
this.renderer.collision_geometry = collision_geometry;
this.renderer.render_geometry = render_geometry;
this.renderer.reset_camera(CAMERA_POSITION, CAMERA_LOOK_AT);
// Load entity models.
this.renderer.reset_entity_models();
for (const npc of quest.npcs.val) {
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_variant !== area_variant) return;
const model = create_npc_mesh(npc, npc_geom, npc_tex);
this.update_entity_geometry(npc, model);
}
}
for (const object of quest.objects.val) {
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_variant !== area_variant) return;
const model = create_object_mesh(object, object_geom, object_tex);
this.update_entity_geometry(object, 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.reset_entity_models();
}
} else {
this.renderer.collision_geometry = DUMMY_OBJECT;
this.renderer.render_geometry = DUMMY_OBJECT;
this.renderer.reset_entity_models();
}
} }
dispose(): void { dispose(): void {
this.disposer.dispose(); this.disposer.dispose();
} }
private quest_or_area_changed = async () => {
this.quest_disposer.dispose_all();
this.npc_model_manager.remove_all();
this.object_model_manager.remove_all();
this.renderer.reset_entity_models();
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;
}
// 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( private add_sections_to_collision_geometry(
collision_geom: Object3D, collision_geom: Object3D,
render_geom: Object3D, render_geom: Object3D,
): void { ): void {
const raycaster = new Raycaster();
const origin = new Vector3();
const down = new Vector3(0, -1, 0);
const up = new Vector3(0, 1, 0);
for (const collision_area of collision_geom.children) { for (const collision_area of collision_geom.children) {
(collision_area as Mesh).geometry.boundingBox.getCenter(origin); (collision_area as Mesh).geometry.boundingBox.getCenter(this.origin);
raycaster.set(origin, down); this.raycaster.set(this.origin, this.down);
const intersection1 = raycaster const intersection1 = this.raycaster
.intersectObject(render_geom, true) .intersectObject(render_geom, true)
.find(i => (i.object.userData as AreaUserData).section != undefined); .find(i => (i.object.userData as AreaUserData).section != undefined);
raycaster.set(origin, up); this.raycaster.set(this.origin, this.up);
const intersection2 = raycaster const intersection2 = this.raycaster
.intersectObject(render_geom, true) .intersectObject(render_geom, true)
.find(i => (i.object.userData as AreaUserData).section != undefined); .find(i => (i.object.userData as AreaUserData).section != undefined);
@ -162,20 +199,114 @@ export class QuestModelManager implements Disposable {
} }
} }
} }
}
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> {
let model: Mesh;
if (entity instanceof QuestNpcModel) {
const npc_geom = await load_npc_geometry(entity.type);
const npc_tex = await load_npc_textures(entity.type);
model = create_npc_mesh(entity, npc_geom, npc_tex);
} else if (entity instanceof QuestObjectModel) {
const object_geom = await load_object_geometry(entity.type);
const object_tex = await load_object_textures(entity.type);
model = create_object_mesh(entity, object_geom, object_tex);
} else {
throw new Error(`Unknown entity type ${entity.type}.`);
}
// 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 { private update_entity_geometry(entity: QuestEntityModel, model: Mesh): void {
this.renderer.add_entity_model(model); this.renderer.add_entity_model(model);
this.disposer.add_all( this.loaded_entities.push({
entity.world_position.observe(({ value: { x, y, z } }) => { entity,
model.position.set(x, y, z); disposer: new Disposer(
this.renderer.schedule_render(); 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 } }) => { entity.rotation.observe(({ value: { x, y, z } }) => {
model.rotation.set(x, y, z); model.rotation.set(x, y, z);
this.renderer.schedule_render(); this.renderer.schedule_render();
}), }),
); ),
});
} }
} }

View File

@ -34,10 +34,6 @@ export class QuestRenderer extends Renderer {
private _render_geometry = new Object3D(); private _render_geometry = new Object3D();
get render_geometry(): Object3D {
return this._render_geometry;
}
set render_geometry(render_geometry: Object3D) { set render_geometry(render_geometry: Object3D) {
this.scene.remove(this._render_geometry); this.scene.remove(this._render_geometry);
this._render_geometry = render_geometry; this._render_geometry = render_geometry;
@ -54,7 +50,6 @@ export class QuestRenderer extends Renderer {
private readonly disposer = new Disposer(); private readonly disposer = new Disposer();
private readonly perspective_camera: PerspectiveCamera; private readonly perspective_camera: PerspectiveCamera;
private readonly entity_to_mesh = new Map<QuestEntityModel, Mesh>(); private readonly entity_to_mesh = new Map<QuestEntityModel, Mesh>();
private readonly model_manager = this.disposer.add(new QuestModelManager(this));
private readonly entity_controls = this.disposer.add(new QuestEntityControls(this)); private readonly entity_controls = this.disposer.add(new QuestEntityControls(this));
constructor() { constructor() {
@ -63,14 +58,10 @@ export class QuestRenderer extends Renderer {
this.perspective_camera = this.camera as PerspectiveCamera; this.perspective_camera = this.camera as PerspectiveCamera;
this.disposer.add_all( this.disposer.add_all(
quest_editor_store.current_quest.observe(this.load_models), new QuestModelManager(this),
quest_editor_store.current_area.observe(this.load_models),
quest_editor_store.debug.observe(({ value }) => (this.debug = value)), quest_editor_store.debug.observe(({ value }) => (this.debug = value)),
); );
this.dom_element.addEventListener("mousedown", this.entity_controls.on_mouse_down);
this.dom_element.addEventListener("mouseup", this.entity_controls.on_mouse_up);
this.dom_element.addEventListener("mousemove", this.entity_controls.on_mouse_move);
} }
dispose(): void { dispose(): void {
@ -89,6 +80,7 @@ export class QuestRenderer extends Renderer {
this._entity_models = new Group(); this._entity_models = new Group();
this.scene.add(this._entity_models); this.scene.add(this._entity_models);
this.entity_to_mesh.clear(); this.entity_to_mesh.clear();
this.schedule_render();
} }
add_entity_model(model: Mesh): void { add_entity_model(model: Mesh): void {
@ -103,14 +95,16 @@ export class QuestRenderer extends Renderer {
this.schedule_render(); this.schedule_render();
} }
remove_entity_model(entity: QuestEntityModel): void {
const mesh = this.entity_to_mesh.get(entity);
if (mesh) {
this._entity_models.remove(mesh);
this.schedule_render();
}
}
get_entity_mesh(entity: QuestEntityModel): Mesh | undefined { get_entity_mesh(entity: QuestEntityModel): Mesh | undefined {
return this.entity_to_mesh.get(entity); return this.entity_to_mesh.get(entity);
} }
private load_models = () => {
this.model_manager.load_models(
quest_editor_store.current_quest.val,
quest_editor_store.current_area.val,
);
};
} }

View File

@ -15,6 +15,7 @@ import { RenderObject } from "../../../core/data_formats/parsing/area_geometry";
import { GeometryBuilder } from "../../../core/rendering/conversion/GeometryBuilder"; import { GeometryBuilder } from "../../../core/rendering/conversion/GeometryBuilder";
import { ninja_object_to_geometry_builder } from "../../../core/rendering/conversion/ninja_geometry"; import { ninja_object_to_geometry_builder } from "../../../core/rendering/conversion/ninja_geometry";
import { SectionModel } from "../../model/SectionModel"; import { SectionModel } from "../../model/SectionModel";
import { AreaVariantModel } from "../../model/AreaVariantModel";
const materials = [ const materials = [
// Wall // Wall
@ -116,6 +117,7 @@ export function area_collision_geometry_to_object_3d(object: CollisionObject): O
export function area_geometry_to_sections_and_object_3d( export function area_geometry_to_sections_and_object_3d(
object: RenderObject, object: RenderObject,
area_variant: AreaVariantModel,
): [SectionModel[], Object3D] { ): [SectionModel[], Object3D] {
const sections: SectionModel[] = []; const sections: SectionModel[] = [];
const group = new Group(); const group = new Group();
@ -144,7 +146,12 @@ export function area_geometry_to_sections_and_object_3d(
mesh.updateMatrixWorld(); mesh.updateMatrixWorld();
if (section.id >= 0) { if (section.id >= 0) {
const sec = new SectionModel(section.id, section.position, section.rotation.y); const sec = new SectionModel(
section.id,
section.position,
section.rotation.y,
area_variant,
);
sections.push(sec); sections.push(sec);
(mesh.userData as AreaUserData).section = sec; (mesh.userData as AreaUserData).section = sec;
} }

View File

@ -46,12 +46,8 @@ class AreaStore {
return area_variant; return area_variant;
}; };
get_area_sections = ( get_area_sections = (episode: Episode, variant: AreaVariantModel): Promise<SectionModel[]> => {
episode: Episode, return load_area_sections(episode, variant);
area_id: number,
variant_id: number,
): Promise<SectionModel[]> => {
return load_area_sections(episode, area_id, variant_id);
}; };
} }

View File

@ -29,26 +29,21 @@ import Logger = require("js-logger");
const logger = Logger.get("quest_editor/gui/QuestEditorStore"); const logger = Logger.get("quest_editor/gui/QuestEditorStore");
export class QuestEditorStore implements Disposable { export class QuestEditorStore implements Disposable {
readonly debug: WritableProperty<boolean> = property(false);
readonly undo = new UndoStack();
readonly current_quest_filename: Property<string | undefined>;
readonly current_quest: Property<QuestModel | undefined>;
readonly current_area: Property<AreaModel | undefined>;
readonly selected_entity: Property<QuestEntityModel | undefined>;
private readonly disposer = new Disposer(); private readonly disposer = new Disposer();
private readonly _current_quest_filename = property<string | undefined>(undefined); private readonly _current_quest_filename = property<string | undefined>(undefined);
private readonly _current_quest = property<QuestModel | undefined>(undefined); private readonly _current_quest = property<QuestModel | undefined>(undefined);
private readonly _current_area = property<AreaModel | undefined>(undefined); private readonly _current_area = property<AreaModel | undefined>(undefined);
private readonly _selected_entity = property<QuestEntityModel | undefined>(undefined); private readonly _selected_entity = property<QuestEntityModel | undefined>(undefined);
constructor() { readonly debug: WritableProperty<boolean> = property(false);
this.current_quest_filename = this._current_quest_filename; readonly undo = new UndoStack();
this.current_quest = this._current_quest; readonly current_quest_filename: Property<string | undefined> = this._current_quest_filename;
this.current_area = this._current_area; readonly current_quest: Property<QuestModel | undefined> = this._current_quest;
this.selected_entity = this._selected_entity; readonly current_area: Property<AreaModel | undefined> = this._current_area;
readonly selected_entity: Property<QuestEntityModel | undefined> = this._selected_entity;
this.disposer.add( constructor() {
this.disposer.add_all(
gui_store.tool.observe( gui_store.tool.observe(
({ value: tool }) => { ({ value: tool }) => {
if (tool === GuiTool.QuestEditor) { if (tool === GuiTool.QuestEditor) {
@ -57,6 +52,26 @@ export class QuestEditorStore implements Disposable {
}, },
{ call_now: true }, { call_now: true },
), ),
this.current_quest
.flat_map(quest => (quest ? quest.npcs : property([])))
.observe(({ value: npcs }) => {
const selected = this.selected_entity.val;
if (selected instanceof QuestNpcModel && !npcs.includes(selected)) {
this.set_selected_entity(undefined);
}
}),
this.current_quest
.flat_map(quest => (quest ? quest.objects : property([])))
.observe(({ value: objects }) => {
const selected = this.selected_entity.val;
if (selected instanceof QuestObjectModel && !objects.includes(selected)) {
this.set_selected_entity(undefined);
}
}),
); );
} }
@ -121,7 +136,7 @@ export class QuestEditorStore implements Disposable {
npc.pso_type_id, npc.pso_type_id,
npc.npc_id, npc.npc_id,
npc.script_label, npc.script_label,
npc.roaming, npc.pso_roaming,
npc.area_id, npc.area_id,
npc.section_id, npc.section_id,
npc.position, npc.position,
@ -145,7 +160,14 @@ export class QuestEditorStore implements Disposable {
const quest = this.current_quest.val; const quest = this.current_quest.val;
if (!quest) return; if (!quest) return;
let file_name = prompt("File name:"); let default_file_name = this.current_quest_filename.val;
if (default_file_name) {
const ext_start = default_file_name.lastIndexOf(".");
if (ext_start !== -1) default_file_name = default_file_name.slice(0, ext_start);
}
let file_name = prompt("File name:", default_file_name);
if (!file_name) return; if (!file_name) return;
const buffer = write_quest_qst( const buffer = write_quest_qst(
@ -178,7 +200,7 @@ export class QuestEditorStore implements Disposable {
pso_type_id: npc.pso_type_id, pso_type_id: npc.pso_type_id,
npc_id: npc.npc_id, npc_id: npc.npc_id,
script_label: npc.script_label, script_label: npc.script_label,
roaming: npc.roaming, pso_roaming: npc.pso_roaming,
})), })),
dat_unknowns: quest.dat_unknowns, dat_unknowns: quest.dat_unknowns,
object_code: quest.object_code, object_code: quest.object_code,
@ -261,11 +283,7 @@ export class QuestEditorStore implements Disposable {
// Load section data. // Load section data.
for (const variant of quest.area_variants.val) { for (const variant of quest.area_variants.val) {
const sections = await area_store.get_area_sections( const sections = await area_store.get_area_sections(quest.episode, variant);
quest.episode,
variant.area.id,
variant.id,
);
variant.sections.val.splice(0, Infinity, ...sections); variant.sections.val.splice(0, Infinity, ...sections);
for (const object of quest.objects.val.filter(o => o.area_id === variant.area.id)) { for (const object of quest.objects.val.filter(o => o.area_id === variant.area.id)) {