diff --git a/src/core/gui/index.css b/src/core/gui/index.css index 3722eb8a..2ebe7db7 100644 --- a/src/core/gui/index.css +++ b/src/core/gui/index.css @@ -5,7 +5,8 @@ --text-color: hsl(0, 0%, 80%); --text-color-disabled: hsl(0, 0%, 55%); --font-family: Verdana, Geneva, sans-serif; - --border: solid 1px hsl(0, 0%, 25%); + --border-color: hsl(0, 0%, 25%); + --border: solid 1px var(--border-color); /* Scrollbars */ @@ -17,7 +18,7 @@ --control-bg-color: hsl(0, 0%, 20%); --control-bg-color-hover: hsl(0, 0%, 25%); --control-text-color: hsl(0, 0%, 80%); - --control-text-color-hover: hsl(0, 0%, 90%); + --control-text-color-hover: hsl(0, 0%, 90%); --control-border: solid 1px hsl(0, 0%, 10%); --control-inner-border: solid 1px hsl(0, 0%, 35%); diff --git a/src/quest_editor/gui/EventsView.css b/src/quest_editor/gui/EventsView.css index e756e7f5..9ceff7ca 100644 --- a/src/quest_editor/gui/EventsView.css +++ b/src/quest_editor/gui/EventsView.css @@ -9,7 +9,14 @@ padding: 4px; } -.quest_editor_EventsView_chain { +.quest_editor_EventsView_dag { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.quest_editor_EventsView_event { display: flex; flex-direction: column; align-items: center; @@ -19,22 +26,14 @@ background-color: hsl(0, 0%, 17%); } -.quest_editor_EventsView_chain_arrow { - font-size: 18px; /* For icon */ - margin: 3px; -} - -.quest_editor_EventsView_event:last-of-type .quest_editor_EventsView_chain_arrow { - color: var(--text-color-disabled); -} - -.quest_editor_EventsView_event { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; -} - .quest_editor_EventsView_event th { text-align: left; } + +.quest_editor_EventsView_edge { + box-sizing: border-box; + position: absolute; + border-left: solid 2px var(--border-color); + border-top: solid 2px var(--border-color); + border-bottom: solid 2px var(--border-color); +} diff --git a/src/quest_editor/gui/EventsView.ts b/src/quest_editor/gui/EventsView.ts index 82146906..86657ea6 100644 --- a/src/quest_editor/gui/EventsView.ts +++ b/src/quest_editor/gui/EventsView.ts @@ -1,12 +1,12 @@ import { ResizableWidget } from "../../core/gui/ResizableWidget"; -import { bind_children_to, el, icon, Icon } from "../../core/gui/dom"; +import { bind_children_to, el } from "../../core/gui/dom"; import { quest_editor_store } from "../stores/QuestEditorStore"; import { QuestEventDagModel } from "../model/QuestEventDagModel"; import { Disposer } from "../../core/observable/Disposer"; import { NumberInput } from "../../core/gui/NumberInput"; import "./EventsView.css"; -import { Button } from "../../core/gui/Button"; import { Disposable } from "../../core/observable/Disposable"; +import { defer } from "lodash"; export class EventsView extends ResizableWidget { private readonly quest_disposer = this.disposable(new Disposer()); @@ -34,43 +34,108 @@ export class EventsView extends ResizableWidget { this.quest_disposer.add( bind_children_to( this.element, - quest.event_dags.filtered(dag => dag.root_events.get(0).area_id === area.id), - this.create_chain_element, + quest.event_dags.filtered(dag => dag.area_id === area.id), + this.create_dag_element, ), ); } }; - private create_chain_element = (dag: QuestEventDagModel): [HTMLElement, Disposable] => { + private create_dag_element = (dag: QuestEventDagModel): [HTMLElement, Disposable] => { const disposer = new Disposer(); - const element = el.div( - { class: "quest_editor_EventsView_chain" }, - ...dag.events.map(event => - el.div( - { class: "quest_editor_EventsView_event" }, - el.table( - el.tr(el.th({ text: "ID:" }), el.td({ text: event.id.toString() })), - el.tr( - el.th({ text: "Section:" }), - el.td( - disposer.add(new NumberInput(event.section_id, { enabled: false })) - .element, - ), - ), - el.tr(el.th({ text: "Wave:" }), el.td({ text: event.wave.toString() })), - el.tr( - el.th({ text: "Delay:" }), - el.td( - disposer.add(new NumberInput(event.delay, { enabled: false })) - .element, - ), + const element = el.div({ class: "quest_editor_EventsView_dag" }); + const event_elements = new Map(); + + // Render events. + dag.events.forEach((event, i) => { + const event_element = el.div( + { class: "quest_editor_EventsView_event" }, + el.table( + el.tr(el.th({ text: "ID:" }), el.td({ text: event.id.toString() })), + el.tr( + el.th({ text: "Section:" }), + el.td( + disposer.add(new NumberInput(event.section_id, { enabled: false })) + .element, + ), + ), + el.tr(el.th({ text: "Wave:" }), el.td({ text: event.wave.toString() })), + el.tr( + el.th({ text: "Delay:" }), + el.td( + disposer.add(new NumberInput(event.delay, { enabled: false })).element, ), ), - el.div({ class: "quest_editor_EventsView_chain_arrow" }, icon(Icon.ArrowDown)), ), - ), - disposer.add(new Button("Add event", { icon_left: Icon.Plus, enabled: false })).element, - ); + ); + + element.append(event_element); + event_elements.set(event.id, { element: event_element, position: i }); + }); + + // Render edges. + defer(() => { + const SPACING = 8; + const used_depths: boolean[][] = Array(dag.events.length - 1); + + for (let i = 0; i < used_depths.length; i++) { + used_depths[i] = []; + } + + let max_depth = 0; + + for (const event of dag.events) { + const { element: event_element, position } = event_elements.get(event.id)!; + + const y_offset = event_element.offsetTop + event_element.offsetHeight; + + for (const child of dag.get_children(event)) { + const { element: child_element, position: child_position } = event_elements.get( + child.id, + )!; + const child_y_offset = child_element.offsetTop; + + const edge_element = el.div({ class: "quest_editor_EventsView_edge" }); + + const top = Math.min(y_offset, child_y_offset) - 20; + const height = Math.max(y_offset, child_y_offset) - top + 20; + + let depth = 1; + const low_pos = Math.min(position, child_position); + const high_pos = Math.max(position, child_position); + + outer: while (true) { + for (let i = low_pos; i < high_pos; i++) { + if (used_depths[i][depth]) { + depth++; + continue outer; + } + } + + break; + } + console.log(`${event.id} -> ${child.id}`, low_pos, high_pos, `depth: ${depth}`); + + for (let i = low_pos; i < high_pos; i++) { + used_depths[i][depth] = true; + } + + max_depth = Math.max(depth, max_depth); + + const width = SPACING * depth; + + edge_element.style.left = `${4 - width}px`; + edge_element.style.top = `${top}px`; + edge_element.style.width = `${width}px`; + edge_element.style.height = `${height}px`; + + element.append(edge_element); + } + } + + console.log(used_depths); + element.style.marginLeft = `${SPACING * max_depth}px`; + }); return [element, disposer]; }; diff --git a/src/quest_editor/model/QuestEventDagModel.ts b/src/quest_editor/model/QuestEventDagModel.ts index abadef8e..b830ccac 100644 --- a/src/quest_editor/model/QuestEventDagModel.ts +++ b/src/quest_editor/model/QuestEventDagModel.ts @@ -15,6 +15,8 @@ export class QuestEventDagModel { private readonly _root_events: WritableListProperty; private meta: Map; + readonly area_id: number; + readonly events: QuestEventModel[]; /** @@ -23,21 +25,23 @@ export class QuestEventDagModel { readonly root_events: ListProperty; constructor( + area_id: number, events: QuestEventModel[], root_events: QuestEventModel[], meta: Map, ) { + if (!Number.isInteger(area_id)) throw new Error("area_id should be an integer."); + if (!Array.isArray(events)) throw new Error("events should be an array."); + if (!Array.isArray(root_events)) throw new Error("root_events should be an array."); + if (!meta) throw new Error("meta is required."); + + this.area_id = area_id; this.events = events; this._root_events = list_property(undefined, ...root_events); this.root_events = this._root_events; this.meta = meta; } - get_parents(event: QuestEventModel): readonly QuestEventModel[] { - const meta = this.meta.get(event); - return meta ? meta.parents : []; - } - get_children(event: QuestEventModel): readonly QuestEventModel[] { const meta = this.meta.get(event); return meta ? meta.children : []; diff --git a/src/quest_editor/model/QuestEventModel.ts b/src/quest_editor/model/QuestEventModel.ts index 1c73df32..be15b2c7 100644 --- a/src/quest_editor/model/QuestEventModel.ts +++ b/src/quest_editor/model/QuestEventModel.ts @@ -11,29 +11,19 @@ export class QuestEventModel { readonly wave: number; readonly delay: number; readonly actions: ListProperty = this._actions; - readonly area_id: number; readonly unknown: number; - constructor( - id: number, - section_id: number, - wave: number, - delay: number, - area_id: number, - unknown: number, - ) { + constructor(id: number, section_id: number, wave: number, delay: number, unknown: number) { if (!Number.isInteger(id)) throw new Error("id should be an integer."); if (!Number.isInteger(section_id)) throw new Error("section_id should be an integer."); if (!Number.isInteger(wave)) throw new Error("wave should be an integer."); if (!Number.isInteger(delay)) throw new Error("delay should be an integer."); - if (!Number.isInteger(area_id)) throw new Error("area_id should be an integer."); if (!Number.isInteger(unknown)) throw new Error("unknown should be an integer."); this.id = id; this.section_id = section_id; this.wave = wave; this.delay = delay; - this.area_id = area_id; this.unknown = unknown; } diff --git a/src/quest_editor/stores/model_conversion.ts b/src/quest_editor/stores/model_conversion.ts index 70aca870..4481585b 100644 --- a/src/quest_editor/stores/model_conversion.ts +++ b/src/quest_editor/stores/model_conversion.ts @@ -70,47 +70,42 @@ export function convert_quest_to_model(quest: Quest): QuestModel { function build_event_dags(dat_events: readonly DatEvent[]): QuestEventDagModel[] { // Build up a temporary data structure with partial data. - // Maps id, section id and area id to data. - const data_map = new Map< - string, + // Maps event id and area id to data. + const data_map = new Map(); + }>(); for (const event of dat_events) { - const key = `${event.id}-${event.section_id}-${event.area_id}`; + const key = `${event.id}-${event.area_id}`; let data = data_map.get(key); - let event_model: QuestEventModel; - if (data && data.event) { - event_model = data.event; - logger.warn( - `Ignored duplicate event #${data.event.id} for section ${data.event.section_id} of area ${data.event.area_id}.`, - ); - } else { - event_model = new QuestEventModel( - event.id, - event.section_id, - event.wave, - event.delay, - event.area_id, - event.unknown, - ); + logger.warn(`Ignored duplicate event #${event.id} for area ${event.area_id}.`); + continue; + } - if (data) { - data.event = event_model; - } else { - data = { - event: event_model, - parents: [], - child_ids: [], - }; - data_map.set(key, data); - } + const event_model = new QuestEventModel( + event.id, + event.section_id, + event.wave, + event.delay, + event.unknown, + ); + + if (data) { + data.event = event_model; + } else { + data = { + event: event_model, + area_id: event.area_id, + parents: [], + child_ids: [], + }; + data_map.set(key, data); } for (const action of event.actions) { @@ -126,22 +121,22 @@ function build_event_dags(dat_events: readonly DatEvent[]): QuestEventDagModel[] case DatEventActionType.Lock: event_model.add_action(new QuestEventActionLockModel(action.door_id)); break; - case DatEventActionType.TriggerEvent: - { - data.child_ids.push(action.event_id); + case DatEventActionType.TriggerEvent: { + data.child_ids.push(action.event_id); - const child_key = `${action.event_id}-${event.section_id}-${event.area_id}`; - const child_data = data_map.get(child_key); + const child_key = `${action.event_id}-${event.area_id}`; + const child_data = data_map.get(child_key); - if (child_data) { - child_data.parents.push(event_model); - } else { - data_map.set(child_key, { - parents: [event_model], - child_ids: [], - }); - } + if (child_data) { + child_data.parents.push(event_model); + } else { + data_map.set(child_key, { + area_id: event.area_id, + parents: [event_model], + child_ids: [], + }); } + } break; default: logger.warn(`Unknown event action type: ${(action as any).type}.`); @@ -153,16 +148,14 @@ function build_event_dags(dat_events: readonly DatEvent[]): QuestEventDagModel[] // Convert temporary structure to complete data structure used to build DAGs. Events that call // nonexistent events are filtered out. This final structure is completely sound. const event_to_full_data = new Map(); - const root_events: QuestEventModel[] = []; + const root_events: { event: QuestEventModel; area_id: number }[] = []; for (const data of data_map.values()) { if (data.event) { const children: QuestEventModel[] = []; for (const child_id of data.child_ids) { - const child = data_map.get( - `${child_id}-${data.event.section_id}-${data.event.area_id}`, - )!; + const child = data_map.get(`${child_id}-${data.area_id}`)!; if (child.event) { children.push(child.event); @@ -177,7 +170,7 @@ function build_event_dags(dat_events: readonly DatEvent[]): QuestEventDagModel[] }); if (data.parents.length === 0) { - root_events.push(data.event); + root_events.push({ event: data.event, area_id: data.area_id }); } } } @@ -186,7 +179,7 @@ function build_event_dags(dat_events: readonly DatEvent[]): QuestEventDagModel[] const event_dags: QuestEventDagModel[] = []; while (root_events.length) { - const event = root_events.shift()!; + const { event, area_id } = root_events.shift()!; const dag_events: QuestEventModel[] = []; const dag_root_events: QuestEventModel[] = []; @@ -203,14 +196,14 @@ function build_event_dags(dat_events: readonly DatEvent[]): QuestEventDagModel[] ); for (const event of dag_root_events) { - const i = root_events.indexOf(event); + const i = root_events.findIndex(r => r.event === event); if (i !== -1) { root_events.splice(i, 1); } } - event_dags.push(new QuestEventDagModel(dag_events, dag_root_events, dag_meta)); + event_dags.push(new QuestEventDagModel(area_id, dag_events, dag_root_events, dag_meta)); } return event_dags; @@ -328,7 +321,7 @@ function convert_quest_events_from_model(event_dags: readonly QuestEventDagModel wave: event.wave, delay: event.delay, actions, - area_id: event.area_id, + area_id: event_dag.area_id, unknown: event.unknown, }); }