From cb4152951838ed3863044dd530a7f6223b115f06 Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Sun, 5 Jan 2020 22:40:26 +0100 Subject: [PATCH] Model viewer options are now stored in url parameters so you can link to a specific set of options. --- src/core/enums.ts | 23 ++---- src/core/stores/GuiStore.ts | 101 ++++++++++++++++++++----- src/core/util.ts | 11 +++ src/viewer/gui/model/ModelView.test.ts | 2 + src/viewer/index.ts | 2 +- src/viewer/rendering/ModelRenderer.ts | 6 +- src/viewer/stores/ModelStore.ts | 85 +++++++++++++++++---- 7 files changed, 179 insertions(+), 51 deletions(-) diff --git a/src/core/enums.ts b/src/core/enums.ts index 98d941d1..84c96f50 100644 --- a/src/core/enums.ts +++ b/src/core/enums.ts @@ -9,22 +9,13 @@ export function enum_values(e: any): E[] { } } -/** - * Map with a guaranteed value per enum key. - */ -export class EnumMap { - private readonly keys: K[]; - private readonly values = new Map(); +export function string_to_enum(e: any, str: string): E | undefined { + if (str === "") return undefined; - constructor(enum_: any, initial_value: (key: K) => V) { - this.keys = enum_values(enum_); + // Filter out strings that start with a digit to avoid index `e` with a number string which + // could result in return a string. + const first_char_code = str.charCodeAt(0); + if (48 <= first_char_code && first_char_code <= 57) return undefined; - for (const key of this.keys) { - this.values.set(key, initial_value(key)); - } - } - - get(key: K): V { - return this.values.get(key)!; - } + return e[str]; } diff --git a/src/core/stores/GuiStore.ts b/src/core/stores/GuiStore.ts index c7d33296..32c8bcbb 100644 --- a/src/core/stores/GuiStore.ts +++ b/src/core/stores/GuiStore.ts @@ -5,6 +5,8 @@ import { Property } from "../observable/property/Property"; import { Server } from "../model"; import { Store } from "./Store"; import { disposable_listener } from "../gui/dom"; +import { assert, map_get_or_put } from "../util"; +import { Observable } from "../observable/Observable"; export enum GuiTool { Viewer, @@ -22,6 +24,19 @@ const STRING_TO_GUI_TOOL = new Map([...GUI_TOOL_TO_STRING.entries()].map(([k, v] export class GuiStore extends Store { private readonly _tool: WritableProperty = property(GuiTool.Viewer); private readonly _path: WritableProperty = property(""); + + /** + * Path prefixed with tool path. + */ + private get full_path(): string { + return `/${gui_tool_to_string(this.tool.val)}${this.path.val}`; + } + + /** + * Maps full paths to maps of parameters and their values. In other words we keep track of + * parameter values per {@link full_path}. + */ + private readonly parameters: Map> = new Map(); private readonly _server: WritableProperty = property(Server.Ephinea); private readonly global_keydown_handlers = new Map void>(); private readonly features: Set = new Set(); @@ -33,29 +48,33 @@ export class GuiStore extends Store { constructor() { super(); - const url = window.location.hash.slice(2); + const url = window.location.hash.slice(1); const [full_path, params_str] = url.split("?"); + const second_slash_idx = full_path.indexOf("/", 1); + const tool_str = second_slash_idx === -1 ? full_path : full_path.slice(1, second_slash_idx); - const first_slash_idx = full_path.indexOf("/"); - const tool_str = first_slash_idx === -1 ? full_path : full_path.slice(0, first_slash_idx); - const path = first_slash_idx === -1 ? "" : full_path.slice(first_slash_idx); + const tool = string_to_gui_tool(tool_str) ?? GuiTool.Viewer; + const path = second_slash_idx === -1 ? "" : full_path.slice(second_slash_idx); if (params_str) { - const features = params_str - .split("&") - .map(p => p.split("=")) - .find(([key]) => key === "features"); + const params = new Map(); - if (features && features.length >= 2) { - for (const feature of features[1].split(",")) { - this.features.add(feature); + for (const [param, value] of params_str.split("&").map(p => p.split("=", 2))) { + if (param === "features") { + for (const feature of value.split(",")) { + this.features.add(feature); + } + } else { + params.set(param, value); } } + + this.parameters.set(full_path, params); } this.disposables(disposable_listener(window, "keydown", this.dispatch_global_keydown)); - this.set_tool(string_to_gui_tool(tool_str) ?? GuiTool.Viewer, path); + this.set_tool(tool, path); } set_tool(tool: GuiTool, path: string = ""): void { @@ -74,14 +93,62 @@ export class GuiStore extends Store { } } - private update_location(): void { - let hash = `#/${gui_tool_to_string(this.tool.val)}${this.path.val}`; + get_parameter(tool: GuiTool, path: string, parameter: string): string | undefined { + return map_get_or_put( + this.parameters, + `/${gui_tool_to_string(tool)}${path}`, + () => new Map(), + ).get(parameter); + } - if (this.features.size) { - hash += "?features=" + [...this.features].join(","); + bind_parameter( + tool: GuiTool, + path: string, + parameter: string, + observable: Observable, + ): Disposable { + assert( + parameter !== "features", + "features can't be bound because it is a global parameter.", + ); + + const params: Map = map_get_or_put( + this.parameters, + this.full_path, + () => new Map(), + ); + + return observable.observe(({ value }) => { + if (this.tool.val !== tool || this.path.val !== path) return; + + if (value === undefined) { + params.delete(parameter); + } else { + params.set(parameter, value); + } + + this.update_location(); + }); + } + + private update_location(): void { + const params_array: [string, string][] = []; + const params = this.parameters.get(this.full_path); + + if (params) { + for (const [param, value] of params.entries()) { + params_array.push([param, value]); + } } - window.location.hash = hash; + if (this.features.size) { + params_array.push(["features", [...this.features].join(",")]); + } + + const param_str = + params_array.length === 0 ? "" : "?" + params_array.map(kv => kv.join("=")).join("&"); + + window.location.hash = `#${this.full_path}${param_str}`; } on_global_keydown( diff --git a/src/core/util.ts b/src/core/util.ts index 505f7d34..804698d1 100644 --- a/src/core/util.ts +++ b/src/core/util.ts @@ -47,6 +47,17 @@ export function array_buffers_equal(a: ArrayBuffer, b: ArrayBuffer): boolean { return true; } +export function map_get_or_put(map: Map, key: K, get_default: () => V): V { + let value = map.get(key); + + if (value === undefined) { + value = get_default(); + map.set(key, value); + } + + return value; +} + /** * Returns the given filename without the file extension. */ diff --git a/src/viewer/gui/model/ModelView.test.ts b/src/viewer/gui/model/ModelView.test.ts index 89e0190e..5db80616 100644 --- a/src/viewer/gui/model/ModelView.test.ts +++ b/src/viewer/gui/model/ModelView.test.ts @@ -11,11 +11,13 @@ import { ModelToolBarView } from "./ModelToolBarView"; import { ModelToolBarController } from "../../controllers/model/ModelToolBarController"; import { CharacterClassOptionsView } from "./CharacterClassOptionsView"; import { CharacterClassOptionsController } from "../../controllers/model/CharacterClassOptionsController"; +import { GuiStore } from "../../../core/stores/GuiStore"; test("Renders correctly.", () => with_disposer(disposer => { const store = disposer.add( new ModelStore( + disposer.add(new GuiStore()), disposer.add(new CharacterClassAssetLoader(new FileSystemHttpClient())), new Random(() => 0.04), ), diff --git a/src/viewer/index.ts b/src/viewer/index.ts index 5d7b6188..ad9b4476 100644 --- a/src/viewer/index.ts +++ b/src/viewer/index.ts @@ -31,7 +31,7 @@ export function initialize_viewer( "./loading/CharacterClassAssetLoader" ); const asset_loader = disposer.add(new CharacterClassAssetLoader(http_client)); - const store = disposer.add(new ModelStore(asset_loader, random)); + const store = disposer.add(new ModelStore(gui_store, asset_loader, random)); const model_controller = new ModelController(store); const model_tool_bar_controller = new ModelToolBarController(store); const character_class_options_controller = new CharacterClassOptionsController(store); diff --git a/src/viewer/rendering/ModelRenderer.ts b/src/viewer/rendering/ModelRenderer.ts index 184edd77..e36a7d1e 100644 --- a/src/viewer/rendering/ModelRenderer.ts +++ b/src/viewer/rendering/ModelRenderer.ts @@ -39,7 +39,7 @@ const DEFAULT_SKINNED_MATERIAL = new MeshLambertMaterial({ side: DoubleSide, }); const CAMERA_POSITION = Object.freeze(new Vector3(0, 10, 20)); -const CAMERA_LOOKAT = Object.freeze(new Vector3(0, 0, 0)); +const CAMERA_LOOK_AT = Object.freeze(new Vector3(0, 0, 0)); export class ModelRenderer extends Renderer implements Disposable { private readonly disposer = new Disposer(); @@ -72,7 +72,7 @@ export class ModelRenderer extends Renderer implements Disposable { ); this.init_camera_controls(); - this.reset_camera(CAMERA_POSITION, CAMERA_LOOKAT); + this.reset_camera(CAMERA_POSITION, CAMERA_LOOK_AT); } set_size(width: number, height: number): void { @@ -106,7 +106,7 @@ export class ModelRenderer extends Renderer implements Disposable { const character_class_active = change.value != undefined; if (this.character_class_active !== character_class_active) { - this.reset_camera(CAMERA_POSITION, CAMERA_LOOKAT); + this.reset_camera(CAMERA_POSITION, CAMERA_LOOK_AT); } this.character_class_active = character_class_active; diff --git a/src/viewer/stores/ModelStore.ts b/src/viewer/stores/ModelStore.ts index a6599904..d37d8241 100644 --- a/src/viewer/stores/ModelStore.ts +++ b/src/viewer/stores/ModelStore.ts @@ -27,6 +27,8 @@ import { Random } from "../../core/Random"; import { SectionId, SectionIds } from "../../core/model"; import { LogManager } from "../../core/Logger"; import { NjObject } from "../../core/data_formats/parsing/ninja"; +import { GuiStore, GuiTool } from "../../core/stores/GuiStore"; +import { string_to_enum } from "../../core/enums"; const logger = LogManager.get("viewer/stores/ModelStore"); @@ -35,8 +37,10 @@ export class ModelStore extends Store { private readonly _current_character_class: WritableProperty< CharacterClassModel | undefined > = property(undefined); - private readonly _current_section_id: WritableProperty; - private readonly _current_body: WritableProperty = property(0); + private readonly _current_section_id: WritableProperty = property( + undefined, + ); + private readonly _current_body: WritableProperty = property(undefined); private readonly _current_animation: WritableProperty< CharacterClassAnimationModel | undefined > = property(undefined); @@ -69,7 +73,7 @@ export class ModelStore extends Store { ]; readonly current_character_class: Property = this ._current_character_class; - readonly current_section_id: Property; + readonly current_section_id: Property = this._current_section_id; readonly current_body: Property = this._current_body; readonly animations: readonly CharacterClassAnimationModel[] = new Array(572) .fill(undefined) @@ -92,14 +96,12 @@ export class ModelStore extends Store { readonly animation_frame: Property = this._animation_frame; constructor( + gui_store: GuiStore, private readonly asset_loader: CharacterClassAssetLoader, private readonly random: Random, ) { super(); - this._current_section_id = property(random.sample_array(SectionIds)); - this.current_section_id = this._current_section_id; - this.disposables( this.current_character_class.observe(this.load_character_class_model), this.current_section_id.observe(this.load_character_class_model), @@ -107,18 +109,70 @@ export class ModelStore extends Store { this.current_animation.observe(this.load_animation), ); - const character_class = random.sample_array(this.character_classes); - this.set_current_character_class(character_class); + // Parameters. + this.disposables( + gui_store.bind_parameter( + GuiTool.Viewer, + "/models", + "model", + this.current_character_class.map(cc => (cc === undefined ? undefined : cc.name)), + ), + gui_store.bind_parameter( + GuiTool.Viewer, + "/models", + "section_id", + this.current_section_id.map(section_id => + section_id === undefined ? undefined : SectionId[section_id], + ), + ), + gui_store.bind_parameter( + GuiTool.Viewer, + "/models", + "body", + this.current_body.map(body => (body === undefined ? undefined : String(body + 1))), + ), + ); + + const model = gui_store.get_parameter(GuiTool.Viewer, "/models", "model"); + let character_class = this.character_classes.find(cc => cc.name === model); + + if (character_class == undefined) { + character_class = random.sample_array(this.character_classes); + } + + const body_arg = gui_store.get_parameter(GuiTool.Viewer, "/models", "body"); + let body = body_arg == undefined ? undefined : parseInt(body_arg, 10); + + if (body == undefined || !Number.isInteger(body)) { + body = random.integer(0, character_class.body_style_count); + } else { + body--; + } + + const section_id_arg = gui_store.get_parameter(GuiTool.Viewer, "/models", "section_id"); + const section_id = + section_id_arg === undefined + ? undefined + : string_to_enum(SectionId, section_id_arg); + + this._current_section_id.val = section_id ?? random.sample_array(SectionIds); + this._current_body.val = body; + this._current_character_class.val = character_class; } set_current_character_class = (character_class?: CharacterClassModel): void => { if (this._current_character_class.val !== character_class) { + if (character_class == undefined) { + this.set_current_body(undefined); + } else { + const body = this.current_body.val; + + if (body === undefined || body >= character_class?.body_style_count) { + this.set_current_body(character_class.body_style_count - 1); + } + } + this._current_character_class.val = character_class; - this.set_current_body( - character_class - ? this.random.integer(0, character_class.body_style_count) - : undefined, - ); if (this.current_animation.val == undefined) { this.set_current_nj_motion(undefined); @@ -177,6 +231,9 @@ export class ModelStore extends Store { const character_class = this.current_character_class.val; if (character_class == undefined) return; + const section_id = this.current_section_id.val; + if (section_id == undefined) return; + const body = this.current_body.val; if (body == undefined) return; @@ -187,7 +244,7 @@ export class ModelStore extends Store { this._current_textures.val = await this.asset_loader.load_textures( character_class, - this.current_section_id.val, + section_id, body, );