From 734ed1016d3846ec937a15d006ba8d85931953e7 Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Mon, 3 Jun 2019 21:41:18 +0200 Subject: [PATCH] Added Loadable and PerServer. Refactored code to make data loading and per-server data simpler. --- antd.customize.less | 14 +- package.json | 1 + src/Loadable.ts | 133 ++++++++++++++++ src/actions/huntMethods.ts | 9 -- src/actions/items.ts | 9 -- src/actions/quest-editor/loadFile.ts | 87 ----------- src/actions/quest-editor/questEditor.ts | 66 -------- .../ArrayBufferCursor.test.ts | 0 src/{data => bin-data}/ArrayBufferCursor.ts | 0 .../compression/prs/compress.ts | 0 .../compression/prs/decompress.ts | 0 .../compression/prs/index.test.ts | 2 +- .../compression/prs/index.ts | 0 src/{data => bin-data}/loading/areas.ts | 0 .../loading/binaryAssets.ts | 0 src/{data => bin-data}/loading/entities.ts | 0 src/{data => bin-data}/parsing/bin.test.ts | 0 src/{data => bin-data}/parsing/bin.ts | 0 src/{data => bin-data}/parsing/dat.test.ts | 0 src/{data => bin-data}/parsing/dat.ts | 0 src/{data => bin-data}/parsing/geometry.ts | 0 src/{data => bin-data}/parsing/ninja/index.ts | 0 src/{data => bin-data}/parsing/ninja/nj.ts | 0 src/{data => bin-data}/parsing/ninja/xj.ts | 0 src/{data => bin-data}/parsing/qst.test.ts | 0 src/{data => bin-data}/parsing/qst.ts | 0 src/{data => bin-data}/parsing/quest.test.ts | 0 src/{data => bin-data}/parsing/quest.ts | 0 src/data/loading/huntMethods.ts | 35 ----- src/data/loading/items.ts | 10 -- src/domain/index.ts | 23 ++- src/javascript-lp-solver.d.ts | 1 + src/rendering/Renderer.ts | 34 +---- src/rendering/entities.test.ts | 2 +- src/stores/ApplicationStore.ts | 8 + src/stores/HuntMethodStore.ts | 44 +++++- src/stores/HuntOptimizerStore.ts | 55 +++++++ src/stores/ItemStore.ts | 21 ++- src/stores/PerServer.ts | 30 ++++ src/stores/QuestEditorStore.ts | 142 +++++++++++++++++- src/ui/ApplicationComponent.css | 3 +- .../hunt-optimizer/HuntOptimizerComponent.tsx | 21 +-- .../hunt-optimizer/WantedItemsComponent.tsx | 65 ++++---- src/ui/quest-editor/QuestEditorComponent.tsx | 10 +- src/ui/quest-editor/QuestInfoComponent.css | 2 +- yarn.lock | 5 + 46 files changed, 511 insertions(+), 321 deletions(-) create mode 100644 src/Loadable.ts delete mode 100644 src/actions/huntMethods.ts delete mode 100644 src/actions/items.ts delete mode 100644 src/actions/quest-editor/loadFile.ts delete mode 100644 src/actions/quest-editor/questEditor.ts rename src/{data => bin-data}/ArrayBufferCursor.test.ts (100%) rename src/{data => bin-data}/ArrayBufferCursor.ts (100%) rename src/{data => bin-data}/compression/prs/compress.ts (100%) rename src/{data => bin-data}/compression/prs/decompress.ts (100%) rename src/{data => bin-data}/compression/prs/index.test.ts (97%) rename src/{data => bin-data}/compression/prs/index.ts (100%) rename src/{data => bin-data}/loading/areas.ts (100%) rename src/{data => bin-data}/loading/binaryAssets.ts (100%) rename src/{data => bin-data}/loading/entities.ts (100%) rename src/{data => bin-data}/parsing/bin.test.ts (100%) rename src/{data => bin-data}/parsing/bin.ts (100%) rename src/{data => bin-data}/parsing/dat.test.ts (100%) rename src/{data => bin-data}/parsing/dat.ts (100%) rename src/{data => bin-data}/parsing/geometry.ts (100%) rename src/{data => bin-data}/parsing/ninja/index.ts (100%) rename src/{data => bin-data}/parsing/ninja/nj.ts (100%) rename src/{data => bin-data}/parsing/ninja/xj.ts (100%) rename src/{data => bin-data}/parsing/qst.test.ts (100%) rename src/{data => bin-data}/parsing/qst.ts (100%) rename src/{data => bin-data}/parsing/quest.test.ts (100%) rename src/{data => bin-data}/parsing/quest.ts (100%) delete mode 100644 src/data/loading/huntMethods.ts delete mode 100644 src/data/loading/items.ts create mode 100644 src/javascript-lp-solver.d.ts create mode 100644 src/stores/ApplicationStore.ts create mode 100644 src/stores/HuntOptimizerStore.ts create mode 100644 src/stores/PerServer.ts diff --git a/antd.customize.less b/antd.customize.less index 55a9e034..72732ea0 100644 --- a/antd.customize.less +++ b/antd.customize.less @@ -10,6 +10,8 @@ @component-background: @body-background; @text-color: hsl(200, 10%, 90%); @text-color-secondary: hsl(200, 20%, 80%); +@text-color-dark: fade(white, 85%); +@text-color-secondary-dark: fade(white, 65%); @heading-color: fade(@black, 85%); @@ -28,8 +30,13 @@ // Disabled states @disabled-color: fade(#fff, 50%); +// Animation +@animation-duration-slow: 0.1s; // Modal +@animation-duration-base: 0.066s; +@animation-duration-fast: 0.033s; // Tooltip + // Input -@input-bg: darken(@body-background, 10%); +@input-bg: darken(@body-background, 5%); // Buttons @btn-default-bg: lighten(@body-background, 10%); @@ -39,4 +46,7 @@ // Table @table-selected-row-bg: @item-active-bg; -@table-row-hover-bg: @item-hover-bg; \ No newline at end of file +@table-row-hover-bg: @item-hover-bg; + +// Menu +@menu-dark-bg: @body-background; \ No newline at end of file diff --git a/package.json b/package.json index bdd1f954..ca6ae1c7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@types/text-encoding": "^0.0.35", "antd": "^3.19.1", "craco-antd": "^1.11.0", + "javascript-lp-solver": "^0.4.5", "lodash": "^4.17.11", "mobx": "^5.9.4", "mobx-react": "^5.4.4", diff --git a/src/Loadable.ts b/src/Loadable.ts new file mode 100644 index 00000000..2d17b3e9 --- /dev/null +++ b/src/Loadable.ts @@ -0,0 +1,133 @@ +import { observable, computed } from "mobx"; +import { defer } from "lodash"; + +export enum LoadableState { + /** + * No attempt has been made to load data. + */ + Uninitialized, + + /** + * The first data load is underway. + */ + Initializing, + + /** + * Data was loaded at least once. The most recent load was successful. + */ + Nominal, + + /** + * Data was loaded at least once. The most recent load failed. + */ + Error, + + /** + * Data was loaded at least once. Another data load is underway. + */ + Reloading, +} + +/** + * Represents a value that can be loaded asynchronously. + * [state]{@link Loadable#state} represents the current state of this Loadable's value. + */ +export class Loadable { + @observable private _value: T; + @observable private _promise: Promise = new Promise(resolve => resolve(this._value)); + @observable private _state = LoadableState.Uninitialized; + private _load?: () => Promise; + @observable private _error?: Error; + + constructor(initialValue: T, load?: () => Promise) { + this._value = initialValue; + this._load = load; + } + + /** + * When this Loadable is uninitialized, a load will be triggered. + * Will return the initial value until a load has succeeded. + */ + @computed get value(): T { + // Load value on first use and return initial placeholder value. + if (this._state === LoadableState.Uninitialized) { + // Defer loading value to avoid side effects in computed value. + defer(() => this.loadValue()); + } + + return this._value; + } + + /** + * This method returns valid data as soon as possible. + * If the Loadable is uninitialized a data load will be triggered, otherwise the current value will be returned. + */ + get promise(): Promise { + // Load value on first use. + if (this._state === LoadableState.Uninitialized) { + return this.loadValue(); + } else { + return this._promise; + } + } + + @computed get state(): LoadableState { + return this._state; + } + + /** + * @returns true if the initial data load has happened. It may or may not have succeeded. + * Check [error]{@link Loadable#error} to know whether an error occurred. + */ + @computed get isInitialized(): boolean { + return this._state !== LoadableState.Uninitialized; + } + + /** + * @returns true if a data load is underway. This may be the initializing load or a later load. + */ + @computed get isLoading(): boolean { + switch (this._state) { + case LoadableState.Initializing: + case LoadableState.Reloading: + return true; + default: + return false; + } + } + + /** + * @returns an {@link Error} if an error occurred during the most recent data load. + */ + @computed get error(): Error | undefined { + return this._error; + } + + /** + * Load the data. Initializes the Loadable if it is uninitialized. + */ + load(): Promise { + return this.loadValue(); + } + + private async loadValue(): Promise { + if (this.isLoading) return this._promise; + + this._state = LoadableState.Initializing; + + try { + if (this._load) { + this._promise = this._load(); + this._value = await this._promise; + } + + this._state = LoadableState.Nominal; + this._error = undefined; + return this._value; + } catch (e) { + this._state = LoadableState.Error; + this._error = e; + throw e; + } + } +} diff --git a/src/actions/huntMethods.ts b/src/actions/huntMethods.ts deleted file mode 100644 index 94392b81..00000000 --- a/src/actions/huntMethods.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { memoize } from 'lodash'; -import { huntMethodStore } from '../stores/HuntMethodStore'; -import { getHuntMethods } from '../data/loading/huntMethods'; - -export const loadHuntMethods = memoize( - async (server: string) => { - huntMethodStore.methods.replace(await getHuntMethods(server)); - } -); diff --git a/src/actions/items.ts b/src/actions/items.ts deleted file mode 100644 index f603687f..00000000 --- a/src/actions/items.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { memoize } from "lodash"; -import { getItems } from "../data/loading/items"; -import { itemStore } from "../stores/ItemStore"; - -export const loadItems = memoize( - async (server: string) => { - itemStore.items.replace(await getItems(server)); - } -); diff --git a/src/actions/quest-editor/loadFile.ts b/src/actions/quest-editor/loadFile.ts deleted file mode 100644 index 2a3c35da..00000000 --- a/src/actions/quest-editor/loadFile.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { action } from 'mobx'; -import { ArrayBufferCursor } from '../../data/ArrayBufferCursor'; -import { getAreaSections } from '../../data/loading/areas'; -import { getNpcGeometry, getObjectGeometry } from '../../data/loading/entities'; -import { parseNj, parseXj } from '../../data/parsing/ninja'; -import { parseQuest } from '../../data/parsing/quest'; -import { Section, Vec3, QuestEntity } from '../../domain'; -import { createNpcMesh, createObjectMesh } from '../../rendering/entities'; -import { createModelMesh } from '../../rendering/models'; -import { setModel, setQuest } from './questEditor'; - -export function loadFile(file: File) { - const reader = new FileReader(); - reader.addEventListener('loadend', () => { loadend(file, reader) }); - reader.readAsArrayBuffer(file); -} - -// TODO: notify user of problems. -async function loadend(file: File, reader: FileReader) { - if (!(reader.result instanceof ArrayBuffer)) { - console.error('Couldn\'t read file.'); - return; - } - - if (file.name.endsWith('.nj')) { - setModel(createModelMesh(parseNj(new ArrayBufferCursor(reader.result, true)))); - } else if (file.name.endsWith('.xj')) { - setModel(createModelMesh(parseXj(new ArrayBufferCursor(reader.result, true)))); - } else { - const quest = parseQuest(new ArrayBufferCursor(reader.result, true)); - setQuest(quest); - - if (quest) { - // Load section data. - for (const variant of quest.areaVariants) { - const sections = await getAreaSections(quest.episode, variant.area.id, variant.id); - variant.sections = sections; - - // Generate object geometry. - for (const object of quest.objects.filter(o => o.areaId === variant.area.id)) { - try { - const geometry = await getObjectGeometry(object.type); - setSectionOnVisibleQuestEntity(object, sections); - object.object3d = createObjectMesh(object, geometry); - } catch (e) { - console.error(e); - } - } - - // Generate NPC geometry. - for (const npc of quest.npcs.filter(npc => npc.areaId === variant.area.id)) { - try { - const geometry = await getNpcGeometry(npc.type); - setSectionOnVisibleQuestEntity(npc, sections); - npc.object3d = createNpcMesh(npc, geometry); - } catch (e) { - console.error(e); - } - } - } - } else { - console.error('Couldn\'t parse quest file.'); - } - } -} - -const setSectionOnVisibleQuestEntity = action('setSectionOnVisibleQuestEntity', - (entity: QuestEntity, sections: Section[]) => { - let { x, y, z } = entity.position; - - const section = sections.find(s => s.id === entity.sectionId); - entity.section = section; - - if (section) { - const { x: secX, y: secY, z: secZ } = section.position; - const rotX = section.cosYAxisRotation * x + section.sinYAxisRotation * z; - const rotZ = -section.sinYAxisRotation * x + section.cosYAxisRotation * z; - x = rotX + secX; - y += secY; - z = rotZ + secZ; - } else { - console.warn(`Section ${entity.sectionId} not found.`); - } - - entity.position = new Vec3(x, y, z); - } -); diff --git a/src/actions/quest-editor/questEditor.ts b/src/actions/quest-editor/questEditor.ts deleted file mode 100644 index 2e5e7583..00000000 --- a/src/actions/quest-editor/questEditor.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { writeQuestQst } from '../../data/parsing/quest'; -import { QuestEntity, Quest } from '../../domain'; -import { questEditorStore } from '../../stores/QuestEditorStore'; -import { action } from 'mobx'; -import { Object3D } from 'three'; - -/** - * Reset application state, then set the current model. - */ -export const setModel = action('setModel', (model?: Object3D) => { - resetModelAndQuestState(); - questEditorStore.currentModel = model; -}); - -/** - * Reset application state, then set current quest and area. - */ -export const setQuest = action('setQuest', (quest?: Quest) => { - resetModelAndQuestState(); - questEditorStore.currentQuest = quest; - - if (quest && quest.areaVariants.length) { - questEditorStore.currentArea = quest.areaVariants[0].area; - } -}); - -function resetModelAndQuestState() { - questEditorStore.currentQuest = undefined; - questEditorStore.currentArea = undefined; - questEditorStore.selectedEntity = undefined; - questEditorStore.currentModel = undefined; -} - -export const setSelectedEntity = action('setSelectedEntity', (entity?: QuestEntity) => { - questEditorStore.selectedEntity = entity; -}); - -export const setCurrentAreaId = action('setCurrentAreaId', (areaId?: number) => { - questEditorStore.selectedEntity = undefined; - - if (areaId == null) { - questEditorStore.currentArea = undefined; - } else if (questEditorStore.currentQuest) { - const areaVariant = questEditorStore.currentQuest.areaVariants.find( - variant => variant.area.id === areaId); - questEditorStore.currentArea = areaVariant && areaVariant.area; - } -}); - -export const saveCurrentQuestToFile = (fileName: string) => { - if (questEditorStore.currentQuest) { - const cursor = writeQuestQst(questEditorStore.currentQuest, fileName); - - if (!fileName.endsWith('.qst')) { - fileName += '.qst'; - } - - const a = document.createElement('a'); - a.href = URL.createObjectURL(new Blob([cursor.buffer])); - a.download = fileName; - document.body.appendChild(a); - a.click(); - URL.revokeObjectURL(a.href); - document.body.removeChild(a); - } -}; diff --git a/src/data/ArrayBufferCursor.test.ts b/src/bin-data/ArrayBufferCursor.test.ts similarity index 100% rename from src/data/ArrayBufferCursor.test.ts rename to src/bin-data/ArrayBufferCursor.test.ts diff --git a/src/data/ArrayBufferCursor.ts b/src/bin-data/ArrayBufferCursor.ts similarity index 100% rename from src/data/ArrayBufferCursor.ts rename to src/bin-data/ArrayBufferCursor.ts diff --git a/src/data/compression/prs/compress.ts b/src/bin-data/compression/prs/compress.ts similarity index 100% rename from src/data/compression/prs/compress.ts rename to src/bin-data/compression/prs/compress.ts diff --git a/src/data/compression/prs/decompress.ts b/src/bin-data/compression/prs/decompress.ts similarity index 100% rename from src/data/compression/prs/decompress.ts rename to src/bin-data/compression/prs/decompress.ts diff --git a/src/data/compression/prs/index.test.ts b/src/bin-data/compression/prs/index.test.ts similarity index 97% rename from src/data/compression/prs/index.test.ts rename to src/bin-data/compression/prs/index.test.ts index 86c8f5d2..cd398114 100644 --- a/src/data/compression/prs/index.test.ts +++ b/src/bin-data/compression/prs/index.test.ts @@ -1,5 +1,5 @@ import { ArrayBufferCursor } from '../../ArrayBufferCursor'; -import { compress, decompress } from '.'; +import { compress, decompress } from '../prs'; function testWithBytes(bytes: number[], expectedCompressedSize: number) { const cursor = new ArrayBufferCursor(new Uint8Array(bytes).buffer, true); diff --git a/src/data/compression/prs/index.ts b/src/bin-data/compression/prs/index.ts similarity index 100% rename from src/data/compression/prs/index.ts rename to src/bin-data/compression/prs/index.ts diff --git a/src/data/loading/areas.ts b/src/bin-data/loading/areas.ts similarity index 100% rename from src/data/loading/areas.ts rename to src/bin-data/loading/areas.ts diff --git a/src/data/loading/binaryAssets.ts b/src/bin-data/loading/binaryAssets.ts similarity index 100% rename from src/data/loading/binaryAssets.ts rename to src/bin-data/loading/binaryAssets.ts diff --git a/src/data/loading/entities.ts b/src/bin-data/loading/entities.ts similarity index 100% rename from src/data/loading/entities.ts rename to src/bin-data/loading/entities.ts diff --git a/src/data/parsing/bin.test.ts b/src/bin-data/parsing/bin.test.ts similarity index 100% rename from src/data/parsing/bin.test.ts rename to src/bin-data/parsing/bin.test.ts diff --git a/src/data/parsing/bin.ts b/src/bin-data/parsing/bin.ts similarity index 100% rename from src/data/parsing/bin.ts rename to src/bin-data/parsing/bin.ts diff --git a/src/data/parsing/dat.test.ts b/src/bin-data/parsing/dat.test.ts similarity index 100% rename from src/data/parsing/dat.test.ts rename to src/bin-data/parsing/dat.test.ts diff --git a/src/data/parsing/dat.ts b/src/bin-data/parsing/dat.ts similarity index 100% rename from src/data/parsing/dat.ts rename to src/bin-data/parsing/dat.ts diff --git a/src/data/parsing/geometry.ts b/src/bin-data/parsing/geometry.ts similarity index 100% rename from src/data/parsing/geometry.ts rename to src/bin-data/parsing/geometry.ts diff --git a/src/data/parsing/ninja/index.ts b/src/bin-data/parsing/ninja/index.ts similarity index 100% rename from src/data/parsing/ninja/index.ts rename to src/bin-data/parsing/ninja/index.ts diff --git a/src/data/parsing/ninja/nj.ts b/src/bin-data/parsing/ninja/nj.ts similarity index 100% rename from src/data/parsing/ninja/nj.ts rename to src/bin-data/parsing/ninja/nj.ts diff --git a/src/data/parsing/ninja/xj.ts b/src/bin-data/parsing/ninja/xj.ts similarity index 100% rename from src/data/parsing/ninja/xj.ts rename to src/bin-data/parsing/ninja/xj.ts diff --git a/src/data/parsing/qst.test.ts b/src/bin-data/parsing/qst.test.ts similarity index 100% rename from src/data/parsing/qst.test.ts rename to src/bin-data/parsing/qst.test.ts diff --git a/src/data/parsing/qst.ts b/src/bin-data/parsing/qst.ts similarity index 100% rename from src/data/parsing/qst.ts rename to src/bin-data/parsing/qst.ts diff --git a/src/data/parsing/quest.test.ts b/src/bin-data/parsing/quest.test.ts similarity index 100% rename from src/data/parsing/quest.test.ts rename to src/bin-data/parsing/quest.test.ts diff --git a/src/data/parsing/quest.ts b/src/bin-data/parsing/quest.ts similarity index 100% rename from src/data/parsing/quest.ts rename to src/bin-data/parsing/quest.ts diff --git a/src/data/loading/huntMethods.ts b/src/data/loading/huntMethods.ts deleted file mode 100644 index 8df28a47..00000000 --- a/src/data/loading/huntMethods.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { HuntMethod, NpcType, SimpleNpc, SimpleQuest } from "../../domain"; - -export async function getHuntMethods(server: string): Promise { - const response = await fetch(process.env.PUBLIC_URL + `/quests.${server}.tsv`); - const data = await response.text(); - const rows = data.split('\n').map(line => line.split('\t')); - - const npcTypeByIndex = rows[0].slice(2, -2).map((episode, i) => { - const enemy = rows[1][i + 2]; - return NpcType.bySimpleNameAndEpisode(enemy, parseInt(episode, 10))!; - }); - - return rows.slice(2).map(row => { - const questName = row[0]; - - const npcs = row.slice(2, -2).flatMap((cell, cellI) => { - const amount = parseInt(cell, 10); - const type = npcTypeByIndex[cellI]; - const enemies = []; - - for (let i = 0; i < amount; i++) { - enemies.push(new SimpleNpc(type)); - } - - return enemies; - }); - - return new HuntMethod( - new SimpleQuest( - questName, - npcs - ) - ); - }); -} diff --git a/src/data/loading/items.ts b/src/data/loading/items.ts deleted file mode 100644 index eb5865fb..00000000 --- a/src/data/loading/items.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Item } from "../../domain"; -import { sortedUniq } from "lodash"; - -export async function getItems(server: string): Promise { - const response = await fetch(process.env.PUBLIC_URL + `/drops.${server}.tsv`); - const data = await response.text(); - return sortedUniq( - data.split('\n').slice(1).map(line => line.split('\t')[4]).sort() - ).map(name => new Item(name)); -} diff --git a/src/domain/index.ts b/src/domain/index.ts index 3a86f40c..a5f47dc9 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -2,12 +2,31 @@ import { Object3D } from 'three'; import { computed, observable } from 'mobx'; import { NpcType } from './NpcType'; import { ObjectType } from './ObjectType'; -import { DatObject, DatNpc, DatUnknown } from '../data/parsing/dat'; -import { ArrayBufferCursor } from '../data/ArrayBufferCursor'; +import { DatObject, DatNpc, DatUnknown } from '../bin-data/parsing/dat'; +import { ArrayBufferCursor } from '../bin-data/ArrayBufferCursor'; export { NpcType } from './NpcType'; export { ObjectType } from './ObjectType'; +export enum Server { + Ephinea = 'Ephinea' +} + +export enum SectionId { + Viridia = 'Viridia', + Greenill = 'Greenill', + Skyly = 'Skyly', + Bluefull = 'Bluefull', + Purplenum = 'Purplenum', + Pinkal = 'Pinkal', + Redria = 'Redria', + Oran = 'Oran', + Yellowboze = 'Yellowboze', + Whitill = 'Whitill', +} + +export const SectionIds = Object.keys(SectionId); + export class Vec3 { x: number; y: number; diff --git a/src/javascript-lp-solver.d.ts b/src/javascript-lp-solver.d.ts new file mode 100644 index 00000000..012468a7 --- /dev/null +++ b/src/javascript-lp-solver.d.ts @@ -0,0 +1 @@ +declare module 'javascript-lp-solver'; \ No newline at end of file diff --git a/src/rendering/Renderer.ts b/src/rendering/Renderer.ts index 0227c1c0..a825e902 100644 --- a/src/rendering/Renderer.ts +++ b/src/rendering/Renderer.ts @@ -1,32 +1,10 @@ import * as THREE from 'three'; -import { - Color, - HemisphereLight, - MOUSE, - Object3D, - PerspectiveCamera, - Plane, - Raycaster, - Scene, - Vector2, - Vector3, - WebGLRenderer, - Intersection, - Mesh, - MeshLambertMaterial -} from 'three'; +import { Color, HemisphereLight, Intersection, Mesh, MeshLambertMaterial, MOUSE, Object3D, PerspectiveCamera, Plane, Raycaster, Scene, Vector2, Vector3, WebGLRenderer } from 'three'; import OrbitControlsCreator from 'three-orbit-controls'; -import { Vec3, Area, Quest, QuestEntity, QuestObject, QuestNpc, Section } from '../domain'; -import { getAreaCollisionGeometry, getAreaRenderGeometry } from '../data/loading/areas'; -import { - OBJECT_COLOR, - OBJECT_HOVER_COLOR, - OBJECT_SELECTED_COLOR, - NPC_COLOR, - NPC_HOVER_COLOR, - NPC_SELECTED_COLOR -} from './entities'; -import { setSelectedEntity } from '../actions/quest-editor/questEditor'; +import { getAreaCollisionGeometry, getAreaRenderGeometry } from '../bin-data/loading/areas'; +import { Area, Quest, QuestEntity, QuestNpc, QuestObject, Section, Vec3 } from '../domain'; +import { questEditorStore } from '../stores/QuestEditorStore'; +import { NPC_COLOR, NPC_HOVER_COLOR, NPC_SELECTED_COLOR, OBJECT_COLOR, OBJECT_HOVER_COLOR, OBJECT_SELECTED_COLOR } from './entities'; const OrbitControls = OrbitControlsCreator(THREE); @@ -273,7 +251,7 @@ export class Renderer { : oldSelectedData !== data; if (selectionChanged) { - setSelectedEntity(data && data.entity); + questEditorStore.setSelectedEntity(data && data.entity); } } diff --git a/src/rendering/entities.test.ts b/src/rendering/entities.test.ts index 2cecd03b..e49fd2d9 100644 --- a/src/rendering/entities.test.ts +++ b/src/rendering/entities.test.ts @@ -1,5 +1,5 @@ import { CylinderBufferGeometry, MeshLambertMaterial, Object3D, Vector3 } from 'three'; -import { DatNpc, DatObject } from '../data/parsing/dat'; +import { DatNpc, DatObject } from '../bin-data/parsing/dat'; import { NpcType, ObjectType, QuestNpc, QuestObject, Vec3 } from '../domain'; import { createNpcMesh, createObjectMesh, NPC_COLOR, OBJECT_COLOR } from './entities'; diff --git a/src/stores/ApplicationStore.ts b/src/stores/ApplicationStore.ts new file mode 100644 index 00000000..be837fff --- /dev/null +++ b/src/stores/ApplicationStore.ts @@ -0,0 +1,8 @@ +import { observable } from "mobx"; +import { Server } from "../domain"; + +class ApplicationStore { + @observable currentServer: Server = Server.Ephinea; +} + +export const applicationStore = new ApplicationStore(); diff --git a/src/stores/HuntMethodStore.ts b/src/stores/HuntMethodStore.ts index e318bf8c..9807dfb3 100644 --- a/src/stores/HuntMethodStore.ts +++ b/src/stores/HuntMethodStore.ts @@ -1,8 +1,46 @@ -import { observable, IObservableArray } from "mobx"; -import { HuntMethod } from "../domain"; +import { observable } from "mobx"; +import { HuntMethod, NpcType, Server, SimpleNpc, SimpleQuest } from "../domain"; +import { Loadable } from "../Loadable"; +import { PerServer } from "./PerServer"; class HuntMethodStore { - @observable methods: IObservableArray = observable.array(); + @observable methods: PerServer>> = new PerServer(server => + new Loadable([], () => this.loadHuntMethods(server)) + ); + + private async loadHuntMethods(server: Server): Promise { + const response = await fetch(process.env.PUBLIC_URL + `/quests.${Server[server]}.tsv`); + const data = await response.text(); + const rows = data.split('\n').map(line => line.split('\t')); + + const npcTypeByIndex = rows[0].slice(2, -2).map((episode, i) => { + const enemy = rows[1][i + 2]; + return NpcType.bySimpleNameAndEpisode(enemy, parseInt(episode, 10))!; + }); + + return rows.slice(2).map(row => { + const questName = row[0]; + + const npcs = row.slice(2, -2).flatMap((cell, cellI) => { + const amount = parseInt(cell, 10); + const type = npcTypeByIndex[cellI]; + const enemies = []; + + for (let i = 0; i < amount; i++) { + enemies.push(new SimpleNpc(type)); + } + + return enemies; + }); + + return new HuntMethod( + new SimpleQuest( + questName, + npcs + ) + ); + }); + } } export const huntMethodStore = new HuntMethodStore(); diff --git a/src/stores/HuntOptimizerStore.ts b/src/stores/HuntOptimizerStore.ts new file mode 100644 index 00000000..c3ebd88e --- /dev/null +++ b/src/stores/HuntOptimizerStore.ts @@ -0,0 +1,55 @@ +import { Solve } from 'javascript-lp-solver'; +import { observable } from "mobx"; +import { Item, SectionIds } from "../domain"; +import { huntMethodStore } from "./HuntMethodStore"; + +export class WantedItem { + @observable item: Item; + @observable amount: number; + + constructor(item: Item, amount: number) { + this.item = item; + this.amount = amount; + } +} + +class HuntOptimizerStore { + @observable wantedItems: Array = []; + + optimize = async () => { + if (!this.wantedItems.length) return; + + const constraints: { [itemName: string]: { min: number } } = {}; + + for (const item of this.wantedItems) { + constraints[item.item.name] = { min: item.amount }; + } + + const variables: { [methodName: string]: { [itemName: string]: number } } = {}; + + for (const method of await huntMethodStore.methods.current.promise) { + for (const sectionId of SectionIds) { + const variable = {}; + + // TODO + + variables[`${sectionId} ${method.quest.name}`] = variable; + } + } + + const result = Solve({ + optimize: '', + opType: 'min', + constraints, + variables + }); + } +} + +export const huntOptimizerStore = new HuntOptimizerStore(); + +type MethodWithDropRates = { + name: string + time: number + [itemName: string]: any +} diff --git a/src/stores/ItemStore.ts b/src/stores/ItemStore.ts index b35ba3d6..03301e9d 100644 --- a/src/stores/ItemStore.ts +++ b/src/stores/ItemStore.ts @@ -1,8 +1,21 @@ -import { observable, IObservableArray } from "mobx"; -import { Item } from "../domain"; +import { sortedUniq } from "lodash"; +import { observable } from "mobx"; +import { Item, Server } from "../domain"; +import { Loadable } from "../Loadable"; +import { PerServer } from "./PerServer"; class ItemStore { - @observable items: IObservableArray = observable.array(); + @observable items: PerServer>> = new PerServer(server => + new Loadable([], () => this.loadItems(server)) + ); + + private async loadItems(server: Server): Promise { + const response = await fetch(process.env.PUBLIC_URL + `/drops.${Server[server]}.tsv`); + const data = await response.text(); + return sortedUniq( + data.split('\n').slice(1).map(line => line.split('\t')[4]).sort() + ).map(name => new Item(name)); + } } -export const itemStore = new ItemStore(); \ No newline at end of file +export const itemStore = new ItemStore(); diff --git a/src/stores/PerServer.ts b/src/stores/PerServer.ts new file mode 100644 index 00000000..7869e993 --- /dev/null +++ b/src/stores/PerServer.ts @@ -0,0 +1,30 @@ +import { Server } from "../domain"; +import { computed } from "mobx"; +import { applicationStore } from "./ApplicationStore"; + +/** + * Represents a value per server. + * E.g. drop tables differ per server, this can be represented by PerServer. + */ +export class PerServer { + private values = new Map(); + + constructor(initialValue: T | ((server: Server) => T)) { + if (!(initialValue instanceof Function)) { + this.values.set(Server.Ephinea, initialValue); + } else { + this.values.set(Server.Ephinea, initialValue(Server.Ephinea)); + } + } + + get(server: Server): T { + return this.values.get(server)!; + } + + /** + * @returns the value for the current server as set in {@link applicationStore}. + */ + @computed get current(): T { + return this.get(applicationStore.currentServer); + } +} diff --git a/src/stores/QuestEditorStore.ts b/src/stores/QuestEditorStore.ts index 45d89426..3f649337 100644 --- a/src/stores/QuestEditorStore.ts +++ b/src/stores/QuestEditorStore.ts @@ -1,12 +1,150 @@ -import { observable } from 'mobx'; +import { observable, action } from 'mobx'; import { Object3D } from 'three'; -import { Area, Quest, QuestEntity } from '../domain'; +import { ArrayBufferCursor } from '../bin-data/ArrayBufferCursor'; +import { getAreaSections } from '../bin-data/loading/areas'; +import { getNpcGeometry, getObjectGeometry } from '../bin-data/loading/entities'; +import { parseNj, parseXj } from '../bin-data/parsing/ninja'; +import { parseQuest, writeQuestQst } from '../bin-data/parsing/quest'; +import { Area, Quest, QuestEntity, Section, Vec3 } from '../domain'; +import { createNpcMesh, createObjectMesh } from '../rendering/entities'; +import { createModelMesh } from '../rendering/models'; class QuestEditorStore { @observable currentModel?: Object3D; @observable currentQuest?: Quest; @observable currentArea?: Area; @observable selectedEntity?: QuestEntity; + + setModel = action('setModel', (model?: Object3D) => { + this.resetModelAndQuestState(); + this.currentModel = model; + }) + + setQuest = action('setQuest', (quest?: Quest) => { + this.resetModelAndQuestState(); + this.currentQuest = quest; + + if (quest && quest.areaVariants.length) { + this.currentArea = quest.areaVariants[0].area; + } + }) + + private resetModelAndQuestState() { + this.currentQuest = undefined; + this.currentArea = undefined; + this.selectedEntity = undefined; + this.currentModel = undefined; + } + + setSelectedEntity = (entity?: QuestEntity) => { + this.selectedEntity = entity; + } + + setCurrentAreaId = action('setCurrentAreaId', (areaId?: number) => { + this.selectedEntity = undefined; + + if (areaId == null) { + this.currentArea = undefined; + } else if (this.currentQuest) { + const areaVariant = this.currentQuest.areaVariants.find( + variant => variant.area.id === areaId + ); + this.currentArea = areaVariant && areaVariant.area; + } + }) + + loadFile = (file: File) => { + const reader = new FileReader(); + reader.addEventListener('loadend', () => { this.loadend(file, reader) }); + reader.readAsArrayBuffer(file); + } + + // TODO: notify user of problems. + private loadend = async (file: File, reader: FileReader) => { + if (!(reader.result instanceof ArrayBuffer)) { + console.error('Couldn\'t read file.'); + return; + } + + if (file.name.endsWith('.nj')) { + this.setModel(createModelMesh(parseNj(new ArrayBufferCursor(reader.result, true)))); + } else if (file.name.endsWith('.xj')) { + this.setModel(createModelMesh(parseXj(new ArrayBufferCursor(reader.result, true)))); + } else { + const quest = parseQuest(new ArrayBufferCursor(reader.result, true)); + this.setQuest(quest); + + if (quest) { + // Load section data. + for (const variant of quest.areaVariants) { + const sections = await getAreaSections(quest.episode, variant.area.id, variant.id); + variant.sections = sections; + + // Generate object geometry. + for (const object of quest.objects.filter(o => o.areaId === variant.area.id)) { + try { + const geometry = await getObjectGeometry(object.type); + this.setSectionOnVisibleQuestEntity(object, sections); + object.object3d = createObjectMesh(object, geometry); + } catch (e) { + console.error(e); + } + } + + // Generate NPC geometry. + for (const npc of quest.npcs.filter(npc => npc.areaId === variant.area.id)) { + try { + const geometry = await getNpcGeometry(npc.type); + this.setSectionOnVisibleQuestEntity(npc, sections); + npc.object3d = createNpcMesh(npc, geometry); + } catch (e) { + console.error(e); + } + } + } + } else { + console.error('Couldn\'t parse quest file.'); + } + } + } + + private setSectionOnVisibleQuestEntity = async (entity: QuestEntity, sections: Section[]) => { + let { x, y, z } = entity.position; + + const section = sections.find(s => s.id === entity.sectionId); + entity.section = section; + + if (section) { + const { x: secX, y: secY, z: secZ } = section.position; + const rotX = section.cosYAxisRotation * x + section.sinYAxisRotation * z; + const rotZ = -section.sinYAxisRotation * x + section.cosYAxisRotation * z; + x = rotX + secX; + y += secY; + z = rotZ + secZ; + } else { + console.warn(`Section ${entity.sectionId} not found.`); + } + + entity.position = new Vec3(x, y, z); + } + + saveCurrentQuestToFile = (fileName: string) => { + if (this.currentQuest) { + const cursor = writeQuestQst(this.currentQuest, fileName); + + if (!fileName.endsWith('.qst')) { + fileName += '.qst'; + } + + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([cursor.buffer])); + a.download = fileName; + document.body.appendChild(a); + a.click(); + URL.revokeObjectURL(a.href); + document.body.removeChild(a); + } + } } export const questEditorStore = new QuestEditorStore(); diff --git a/src/ui/ApplicationComponent.css b/src/ui/ApplicationComponent.css index cd4dcedb..21f878fc 100644 --- a/src/ui/ApplicationComponent.css +++ b/src/ui/ApplicationComponent.css @@ -13,8 +13,7 @@ } .ApplicationComponent-heading { - font-size: 22px; - margin: 10px 10px 0 10px; + margin: 5px 10px 0 10px; } .ApplicationComponent-beta { diff --git a/src/ui/hunt-optimizer/HuntOptimizerComponent.tsx b/src/ui/hunt-optimizer/HuntOptimizerComponent.tsx index b4a1e9ff..c1401ae2 100644 --- a/src/ui/hunt-optimizer/HuntOptimizerComponent.tsx +++ b/src/ui/hunt-optimizer/HuntOptimizerComponent.tsx @@ -1,20 +1,11 @@ import React from "react"; import './HuntOptimizerComponent.css'; import { WantedItemsComponent } from "./WantedItemsComponent"; -import { loadItems } from "../../actions/items"; -import { loadHuntMethods } from "../../actions/huntMethods"; -export class HuntOptimizerComponent extends React.Component { - componentDidMount() { - loadItems('ephinea'); - loadHuntMethods('ephinea'); - } - - render() { - return ( -
- -
- ); - } +export function HuntOptimizerComponent() { + return ( +
+ +
+ ); } diff --git a/src/ui/hunt-optimizer/WantedItemsComponent.tsx b/src/ui/hunt-optimizer/WantedItemsComponent.tsx index edb9fbad..70fc51fb 100644 --- a/src/ui/hunt-optimizer/WantedItemsComponent.tsx +++ b/src/ui/hunt-optimizer/WantedItemsComponent.tsx @@ -1,42 +1,41 @@ import { Button, InputNumber, Select, Table } from "antd"; -import { observable } from "mobx"; import { observer } from "mobx-react"; import React from "react"; -import { Item } from "../../domain"; +import { huntOptimizerStore, WantedItem } from "../../stores/HuntOptimizerStore"; import { itemStore } from "../../stores/ItemStore"; import './WantedItemsComponent.css'; @observer export class WantedItemsComponent extends React.Component { - @observable - private wantedItems: Array = []; - render() { // Make sure render is called on updates. - this.wantedItems.slice(0, 0); + huntOptimizerStore.wantedItems.slice(0, 0); return (

Wanted Items

- +
+ + +
wanted.item.name} pagination={false} > @@ -59,30 +58,20 @@ export class WantedItemsComponent extends React.Component { } private addWanted = (itemName: string) => { - let added = this.wantedItems.find(w => w.item.name === itemName); + let added = huntOptimizerStore.wantedItems.find(w => w.item.name === itemName); if (!added) { - const item = itemStore.items.find(i => i.name === itemName)!; - this.wantedItems.push(new WantedItem(item, 1)); + const item = itemStore.items.current.value.find(i => i.name === itemName)!; + huntOptimizerStore.wantedItems.push(new WantedItem(item, 1)); } - }; + } private removeWanted = (wanted: WantedItem) => () => { - const i = this.wantedItems.findIndex(w => w === wanted); + const i = huntOptimizerStore.wantedItems.findIndex(w => w === wanted); if (i !== -1) { - this.wantedItems.splice(i, 1); + huntOptimizerStore.wantedItems.splice(i, 1); } - }; -} - -class WantedItem { - @observable item: Item; - @observable amount: number; - - constructor(item: Item, amount: number) { - this.item = item; - this.amount = amount; } } diff --git a/src/ui/quest-editor/QuestEditorComponent.tsx b/src/ui/quest-editor/QuestEditorComponent.tsx index 4370180a..0adac55b 100644 --- a/src/ui/quest-editor/QuestEditorComponent.tsx +++ b/src/ui/quest-editor/QuestEditorComponent.tsx @@ -3,8 +3,6 @@ import { UploadChangeParam } from "antd/lib/upload"; import { UploadFile } from "antd/lib/upload/interface"; import { observer } from "mobx-react"; import React, { ChangeEvent } from "react"; -import { loadFile } from "../../actions/quest-editor/loadFile"; -import { saveCurrentQuestToFile, setCurrentAreaId } from "../../actions/quest-editor/questEditor"; import { questEditorStore } from "../../stores/QuestEditorStore"; import { EntityInfoComponent } from "./EntityInfoComponent"; import './QuestEditorComponent.css'; @@ -66,7 +64,7 @@ export class QuestEditorComponent extends React.Component<{}, { } private saveDialogAffirmed = () => { - saveCurrentQuestToFile(this.state.saveDialogFilename); + questEditorStore.saveCurrentQuestToFile(this.state.saveDialogFilename); this.setState({ saveDialogOpen: false }); } @@ -98,8 +96,8 @@ class Toolbar extends React.Component<{ onSaveAsClicked: (filename?: string) => {areas && (