Its now possible to choose a section ID and body type in the model viewer.

This commit is contained in:
Daan Vanden Bosch 2020-01-05 18:40:35 +01:00
parent 66728f7096
commit 05d5ce6e29
31 changed files with 4152 additions and 506 deletions

View File

@ -1,8 +1,9 @@
import { initialize_application } from "./index";
import { DisposableThreeRenderer } from "../core/rendering/Renderer";
import { LogHandler, LogLevel, LogManager } from "../core/Logger";
import { FileSystemHttpClient } from "../../test/src/core/FileSystemHttpClient";
import { timeout } from "../../test/src/utils";
import { StubThreeRenderer } from "../../test/src/core/rendering/StubThreeRenderer";
import { Random } from "../core/Random";
for (const path of [undefined, "/viewer", "/quest_editor", "/hunt_optimizer"]) {
const with_path = path == undefined ? "without specific path" : `with path ${path}`;
@ -23,7 +24,8 @@ for (const path of [undefined, "/viewer", "/quest_editor", "/hunt_optimizer"]) {
const app = initialize_application(
new FileSystemHttpClient(),
() => new StubRenderer(),
new Random(() => 0.27),
() => new StubThreeRenderer(),
);
expect(app).toBeDefined();
@ -37,13 +39,3 @@ for (const path of [undefined, "/viewer", "/quest_editor", "/hunt_optimizer"]) {
});
});
}
class StubRenderer implements DisposableThreeRenderer {
domElement: HTMLCanvasElement = document.createElement("canvas");
dispose(): void {} // eslint-disable-line
render(): void {} // eslint-disable-line
setSize(): void {} // eslint-disable-line
}

View File

@ -8,9 +8,11 @@ import { throttle } from "lodash";
import { DisposableThreeRenderer } from "../core/rendering/Renderer";
import { Disposer } from "../core/observable/Disposer";
import { disposable_custom_listener, disposable_listener } from "../core/gui/dom";
import { Random } from "../core/Random";
export function initialize_application(
http_client: HttpClient,
random: Random,
create_three_renderer: () => DisposableThreeRenderer,
): Disposable {
const disposer = new Disposer();
@ -43,7 +45,7 @@ export function initialize_application(
async () => {
const { initialize_viewer } = await import("../viewer");
const viewer = disposer.add(
initialize_viewer(http_client, gui_store, create_three_renderer),
initialize_viewer(http_client, random, gui_store, create_three_renderer),
);
return viewer.view;

18
src/core/Random.ts Normal file
View File

@ -0,0 +1,18 @@
export class Random {
constructor(private readonly random_number: () => number = Math.random) {}
/**
* @param min - The minimum value, inclusive.
* @param max - The maximum value, exclusive.
* @returns A random integer between `min` and `max`.
*/
integer(min: number, max: number): number {
return min + Math.floor(this.random_number() * (max - min));
}
/**
* @returns A random element from `array`.
*/
sample_array<T>(array: readonly T[]): T {
return array[this.integer(0, array.length)];
}
}

View File

@ -9,7 +9,7 @@ import { Menu } from "./Menu";
export type SelectOptions<T> = LabelledControlOptions & {
readonly items: readonly T[] | Property<readonly T[]>;
readonly to_label: (element: T) => string;
readonly to_label?: (element: T) => string;
readonly selected?: T | Property<T>;
};
@ -31,7 +31,7 @@ export class Select<T> extends LabelledControl {
this.preferred_label_position = "left";
this.to_label = options.to_label;
this.to_label = options.to_label ?? String;
this.button = this.disposable(
new Button({
text: " ",

View File

@ -34,22 +34,6 @@ export function array_remove<T>(array: T[], ...elements: T[]): number {
return count;
}
/**
* @param min - The minimum value, inclusive.
* @param max - The maximum value, exclusive.
* @returns A random integer between `min` and `max`.
*/
export function random_integer(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min));
}
/**
* @returns A random element from `array`.
*/
export function random_array_element<T>(array: readonly T[]): T {
return array[random_integer(0, array.length)];
}
export function array_buffers_equal(a: ArrayBuffer, b: ArrayBuffer): boolean {
if (a.byteLength !== b.byteLength) return false;

View File

@ -10,6 +10,7 @@ import { initialize_application } from "./application";
import { FetchClient } from "./core/HttpClient";
import { WebGLRenderer } from "three";
import { DisposableThreeRenderer } from "./core/rendering/Renderer";
import { Random } from "./core/Random";
function create_three_renderer(): DisposableThreeRenderer {
const renderer = new WebGLRenderer({ antialias: true, alpha: true });
@ -17,4 +18,4 @@ function create_three_renderer(): DisposableThreeRenderer {
return renderer;
}
initialize_application(new FetchClient(), create_three_renderer);
initialize_application(new FetchClient(), new Random(), create_three_renderer);

View File

@ -1,18 +1,18 @@
import { list_property } from "../../core/observable";
import { parse_xvm, XvrTexture } from "../../core/data_formats/parsing/ninja/texture";
import { Controller } from "../../core/controllers/Controller";
import { filename_extension } from "../../core/util";
import { read_file } from "../../core/read_file";
import { parse_xvm, XvrTexture } from "../../core/data_formats/parsing/ninja/texture";
import { ArrayBufferCursor } from "../../core/data_formats/cursor/ArrayBufferCursor";
import { Endianness } from "../../core/data_formats/Endianness";
import { Store } from "../../core/stores/Store";
import { parse_afs } from "../../core/data_formats/parsing/afs";
import { LogManager } from "../../core/Logger";
import { WritableListProperty } from "../../core/observable/property/list/WritableListProperty";
import { list_property } from "../../core/observable";
import { ListProperty } from "../../core/observable/property/list/ListProperty";
import { filename_extension } from "../../core/util";
import { parse_afs } from "../../core/data_formats/parsing/afs";
const logger = LogManager.get("viewer/stores/TextureStore");
const logger = LogManager.get("viewer/controllers/TextureController");
export class TextureStore extends Store {
export class TextureController extends Controller {
private readonly _textures: WritableListProperty<XvrTexture> = list_property();
readonly textures: ListProperty<XvrTexture> = this._textures;

View File

@ -0,0 +1,38 @@
import { Controller } from "../../../core/controllers/Controller";
import { ModelStore } from "../../stores/ModelStore";
import { range } from "lodash";
import { Property } from "../../../core/observable/property/Property";
import { SectionId } from "../../../core/model";
export class CharacterClassOptionsController extends Controller {
readonly enabled: Property<boolean>;
readonly current_section_id: Property<SectionId>;
readonly current_body_options: Property<readonly number[]>;
readonly current_body: Property<number | undefined>;
constructor(private readonly store: ModelStore) {
super();
this.enabled = store.current_character_class.map(cc => cc != undefined);
this.current_section_id = store.current_section_id;
this.current_body_options = store.current_character_class.map(character_class =>
character_class ? range(1, character_class.body_style_count + 1) : [],
);
this.current_body = store.current_body.map(body =>
body == undefined ? undefined : body + 1,
);
}
set_current_section_id = (section_id?: SectionId): void => {
if (section_id != undefined) {
this.store.set_current_section_id(section_id);
}
};
set_current_body = (body?: number): void => {
this.store.set_current_body(body == undefined ? undefined : body - 1);
};
}

View File

@ -0,0 +1,31 @@
import { Controller } from "../../../core/controllers/Controller";
import { ModelStore } from "../../stores/ModelStore";
import { CharacterClassModel } from "../../model/CharacterClassModel";
import { Property } from "../../../core/observable/property/Property";
import { CharacterClassAnimationModel } from "../../model/CharacterClassAnimationModel";
export class ModelController extends Controller {
readonly character_classes: readonly CharacterClassModel[];
readonly current_character_class: Property<CharacterClassModel | undefined>;
readonly animations: readonly CharacterClassAnimationModel[];
readonly current_animation: Property<CharacterClassAnimationModel | undefined>;
constructor(private readonly store: ModelStore) {
super();
this.character_classes = store.character_classes;
this.current_character_class = store.current_character_class;
this.animations = store.animations;
this.current_animation = store.current_animation;
}
set_current_character_class = (character_class: CharacterClassModel): void => {
this.store.set_current_character_class(character_class);
};
set_current_animation = (animation: CharacterClassAnimationModel): void => {
this.store.set_current_animation(animation);
};
}

View File

@ -0,0 +1,92 @@
import { Controller } from "../../../core/controllers/Controller";
import { Property } from "../../../core/observable/property/Property";
import { ModelStore } from "../../stores/ModelStore";
import { read_file } from "../../../core/read_file";
import { ArrayBufferCursor } from "../../../core/data_formats/cursor/ArrayBufferCursor";
import { Endianness } from "../../../core/data_formats/Endianness";
import { parse_nj, parse_xj } from "../../../core/data_formats/parsing/ninja";
import { parse_njm } from "../../../core/data_formats/parsing/ninja/motion";
import { parse_xvm, XvrTexture } from "../../../core/data_formats/parsing/ninja/texture";
import { parse_afs } from "../../../core/data_formats/parsing/afs";
import { LogManager } from "../../../core/Logger";
const logger = LogManager.get("viewer/controllers/model/ModelToolBarController");
export class ModelToolBarController extends Controller {
readonly show_skeleton: Property<boolean>;
readonly animation_frame_count: Property<number>;
readonly animation_frame_count_label: Property<string>;
readonly animation_controls_enabled: Property<boolean>;
readonly animation_playing: Property<boolean>;
readonly animation_frame_rate: Property<number>;
readonly animation_frame: Property<number>;
constructor(private readonly store: ModelStore) {
super();
this.show_skeleton = store.show_skeleton;
this.animation_frame_count = store.animation_frame_count;
this.animation_frame_count_label = store.animation_frame_count.map(count => `/ ${count}`);
this.animation_controls_enabled = store.current_nj_motion.map(njm => njm != undefined);
this.animation_playing = store.animation_playing;
this.animation_frame_rate = store.animation_frame_rate;
this.animation_frame = store.animation_frame.map(v => Math.round(v));
}
set_show_skeleton = (show_skeleton: boolean): void => {
this.store.set_show_skeleton(show_skeleton);
};
set_animation_playing = (playing: boolean): void => {
this.store.set_animation_playing(playing);
};
set_animation_frame_rate = (frame_rate: number): void => {
this.store.set_animation_frame_rate(frame_rate);
};
set_animation_frame = (frame: number): void => {
this.store.set_animation_frame(frame);
};
// TODO: notify user of problems.
load_file = async (file: File): Promise<void> => {
try {
const buffer = await read_file(file);
const cursor = new ArrayBufferCursor(buffer, Endianness.Little);
if (file.name.endsWith(".nj")) {
const nj_object = parse_nj(cursor)[0];
this.store.set_current_nj_object(nj_object);
} else if (file.name.endsWith(".xj")) {
const nj_object = parse_xj(cursor)[0];
this.store.set_current_nj_object(nj_object);
} else if (file.name.endsWith(".njm")) {
this.store.set_current_animation(undefined);
this.store.set_current_nj_motion(undefined);
const nj_object = this.store.current_nj_object.val;
if (nj_object) {
this.set_animation_playing(true);
this.store.set_current_nj_motion(parse_njm(cursor, nj_object.bone_count()));
}
} else if (file.name.endsWith(".xvm")) {
this.store.set_current_textures(parse_xvm(cursor).textures);
} else if (file.name.endsWith(".afs")) {
const files = parse_afs(cursor);
const textures: XvrTexture[] = files.flatMap(
file => parse_xvm(new ArrayBufferCursor(file, Endianness.Little)).textures,
);
this.store.set_current_textures(textures);
} else {
logger.error(`Unknown file extension in filename "${file.name}".`);
}
} catch (e) {
logger.error("Couldn't read file.", e);
}
};
}

View File

@ -0,0 +1,15 @@
import { TextureView } from "./TextureView";
import { with_disposer } from "../../../test/src/core/observables/disposable_helpers";
import { TextureController } from "../controllers/TextureController";
import { TextureRenderer } from "../rendering/TextureRenderer";
import { StubThreeRenderer } from "../../../test/src/core/rendering/StubThreeRenderer";
test("Renders correctly without textures.", () =>
with_disposer(disposer => {
const ctrl = disposer.add(new TextureController());
const view = disposer.add(
new TextureView(ctrl, new TextureRenderer(ctrl, new StubThreeRenderer())),
);
expect(view.element).toMatchSnapshot("Should render a toolbar and a renderer widget.");
}));

View File

@ -3,9 +3,8 @@ import { FileButton } from "../../core/gui/FileButton";
import { ToolBar } from "../../core/gui/ToolBar";
import { RendererWidget } from "../../core/gui/RendererWidget";
import { TextureRenderer } from "../rendering/TextureRenderer";
import { TextureStore } from "../stores/TextureStore";
import { DisposableThreeRenderer } from "../../core/rendering/Renderer";
import { ResizableView } from "../../core/gui/ResizableView";
import { TextureController } from "../controllers/TextureController";
export class TextureView extends ResizableView {
readonly element = div({ className: "viewer_TextureView" });
@ -19,18 +18,16 @@ export class TextureView extends ResizableView {
private readonly renderer_view: RendererWidget;
constructor(texture_store: TextureStore, three_renderer: DisposableThreeRenderer) {
constructor(ctrl: TextureController, renderer: TextureRenderer) {
super();
this.renderer_view = this.add(
new RendererWidget(new TextureRenderer(three_renderer, texture_store)),
);
this.renderer_view = this.add(new RendererWidget(renderer));
this.element.append(this.tool_bar.element, this.renderer_view.element);
this.disposables(
this.open_file_button.files.observe(({ value: files }) => {
if (files.length) texture_store.load_file(files[0]);
if (files.length) ctrl.load_file(files[0]);
}),
);

View File

@ -1,5 +1,5 @@
import { TabContainer } from "../../core/gui/TabContainer";
import { Model3DView } from "./model_3d/Model3DView";
import { ModelView } from "./model/ModelView";
import { TextureView } from "./TextureView";
import { ResizableView } from "../../core/gui/ResizableView";
import { GuiStore } from "../../core/stores/GuiStore";
@ -13,7 +13,7 @@ export class ViewerView extends ResizableView {
constructor(
gui_store: GuiStore,
create_model_3d_view: () => Promise<Model3DView>,
create_model_view: () => Promise<ModelView>,
create_texture_view: () => Promise<TextureView>,
) {
super();
@ -24,13 +24,13 @@ export class ViewerView extends ResizableView {
tabs: [
{
title: "Models",
key: "models",
key: "model",
path: "/models",
create_view: create_model_3d_view,
create_view: create_model_view,
},
{
title: "Textures",
key: "textures",
key: "texture",
path: "/textures",
create_view: create_texture_view,
},

View File

@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Renders correctly without textures.: Should render a toolbar and a renderer widget. 1`] = `
<div
class="viewer_TextureView"
>
<div
class="core_ToolBar"
style="height: 33px;"
>
<label
class="core_FileButton core_Button"
>
<span
class="core_FileButton_inner core_Button_inner"
>
<span
class="core_FileButton_left core_Button_left"
>
<span>
<span
class="fas fa-file"
/>
</span>
</span>
<span
class="core_Button_center"
>
Open file...
</span>
</span>
<input
accept=".afs, .xvm"
class="core_FileButton_input core_Button_inner"
type="file"
/>
</label>
</div>
<div
class="core_RendererWidget"
>
<canvas
style="outline: none;"
tabindex="0"
/>
</div>
</div>
`;

View File

@ -0,0 +1,23 @@
.viewer_model_CharacterClassOptionsView {
box-sizing: border-box;
border-left: var(--border);
border-right: var(--border);
padding: 0 2px;
}
.viewer_model_CharacterClassOptionsView table {
table-layout: fixed;
width: 100%;
}
.viewer_model_CharacterClassOptionsView td:first-child {
width: 80px;
}
.viewer_model_CharacterClassOptionsView_section_id {
width: 100%;
}
.viewer_model_CharacterClassOptionsView_body {
width: 60px;
}

View File

@ -0,0 +1,50 @@
import { ResizableView } from "../../../core/gui/ResizableView";
import { div, table, td, tr } from "../../../core/gui/dom";
import { Select } from "../../../core/gui/Select";
import { CharacterClassOptionsController } from "../../controllers/model/CharacterClassOptionsController";
import "./CharacterClassOptionsView.css";
import { SectionId, SectionIds } from "../../../core/model";
export class CharacterClassOptionsView extends ResizableView {
readonly element: HTMLElement;
constructor(ctrl: CharacterClassOptionsController) {
super();
const section_id_select: Select<SectionId> = this.add(
new Select({
class: "viewer_model_CharacterClassOptionsView_section_id",
label: "Section ID:",
items: SectionIds,
selected: ctrl.current_section_id,
to_label: section_id => SectionId[section_id],
enabled: ctrl.enabled,
}),
);
const body_select: Select<number | undefined> = this.add(
new Select({
class: "viewer_model_CharacterClassOptionsView_body",
label: "Body:",
items: ctrl.current_body_options,
selected: ctrl.current_body,
enabled: ctrl.enabled,
}),
);
this.element = div(
{ className: "viewer_model_CharacterClassOptionsView" },
table(
tr(td(section_id_select.label?.element), td(section_id_select.element)),
tr(td(body_select.label?.element), td(body_select.element)),
),
);
this.disposables(
section_id_select.selected.observe(({ value }) => ctrl.set_current_section_id(value)),
body_select.selected.observe(({ value }) => ctrl.set_current_body(value)),
);
this.finalize_construction();
}
}

View File

@ -1,4 +1,4 @@
.viewer_Model3DSelectListView {
.viewer_model_CharacterClassSelectionView {
box-sizing: border-box;
list-style: none;
padding: 0;
@ -6,16 +6,16 @@
overflow: auto;
}
.viewer_Model3DSelectListView li {
.viewer_model_CharacterClassSelectionView li {
padding: 4px 8px;
}
.viewer_Model3DSelectListView li:hover {
.viewer_model_CharacterClassSelectionView li:hover {
color: hsl(0, 0%, 90%);
background-color: hsl(0, 0%, 18%);
}
.viewer_Model3DSelectListView li.active {
.viewer_model_CharacterClassSelectionView li.active {
color: hsl(0, 0%, 90%);
background-color: hsl(0, 0%, 21%);
}

View File

@ -1,35 +1,30 @@
import "./Model3DSelectListView.css";
import "./CharacterClassSelectionView.css";
import { Property } from "../../../core/observable/property/Property";
import { li, ul } from "../../../core/gui/dom";
import { ResizableView } from "../../../core/gui/ResizableView";
export class Model3DSelectListView<T extends { name: string }> extends ResizableView {
readonly element = ul({ className: "viewer_Model3DSelectListView" });
set borders(borders: boolean) {
if (borders) {
this.element.style.borderLeft = "var(--border)";
this.element.style.borderRight = "var(--border)";
} else {
this.element.style.borderLeft = "none";
this.element.style.borderRight = "none";
}
}
export class CharacterClassSelectionView<T extends { name: string }> extends ResizableView {
private selected_model?: T;
private selected_element?: HTMLLIElement;
readonly element = ul({ className: "viewer_model_CharacterClassSelectionView" });
constructor(
private models: readonly T[],
private character_classes: readonly T[],
private selected: Property<T | undefined>,
private set_selected: (selected: T) => void,
private border_left: boolean,
) {
super();
this.element.onclick = this.list_click;
models.forEach((model, index) => {
this.element.append(li({ data: { index: index.toString() } }, model.name));
if (border_left) {
this.element.style.borderLeft = "var(--border)";
}
character_classes.forEach((character_class, index) => {
this.element.append(li({ data: { index: index.toString() } }, character_class.name));
});
this.disposables(
@ -41,7 +36,7 @@ export class Model3DSelectListView<T extends { name: string }> extends Resizable
}
if (model && model !== this.selected_model) {
const index = this.models.indexOf(model);
const index = this.character_classes.indexOf(model);
if (index !== -1) {
this.selected_element = this.element.childNodes[index] as HTMLLIElement;
@ -67,7 +62,7 @@ export class Model3DSelectListView<T extends { name: string }> extends Resizable
const index = parseInt(e.target.dataset["index"]!, 10);
this.selected_element = e.target;
this.set_selected(this.models[index]);
this.set_selected(this.character_classes[index]);
}
};
}

View File

@ -5,10 +5,10 @@ import { NumberInput } from "../../../core/gui/NumberInput";
import { PSO_FRAME_RATE } from "../../../core/rendering/conversion/ninja_animation";
import { Label } from "../../../core/gui/Label";
import { Icon } from "../../../core/gui/dom";
import { Model3DStore } from "../../stores/Model3DStore";
import { View } from "../../../core/gui/View";
import { ModelToolBarController } from "../../controllers/model/ModelToolBarController";
export class Model3DToolBarView extends View {
export class ModelToolBarView extends View {
private readonly toolbar: ToolBar;
get element(): HTMLElement {
@ -19,7 +19,7 @@ export class Model3DToolBarView extends View {
return this.toolbar.height;
}
constructor(model_3d_store: Model3DStore) {
constructor(ctrl: ModelToolBarController) {
super();
const open_file_button = new FileButton("Open file...", {
@ -37,12 +37,10 @@ export class Model3DToolBarView extends View {
const animation_frame_input = new NumberInput(1, {
label: "Frame:",
min: 1,
max: model_3d_store.animation_frame_count,
max: ctrl.animation_frame_count,
step: 1,
});
const animation_frame_count_label = new Label(
model_3d_store.animation_frame_count.map(count => `/ ${count}`),
);
const animation_frame_count_label = new Label(ctrl.animation_frame_count_label);
this.toolbar = this.add(
new ToolBar(
@ -58,36 +56,30 @@ export class Model3DToolBarView extends View {
// Always-enabled controls.
this.disposables(
open_file_button.files.observe(({ value: files }) => {
if (files.length) model_3d_store.load_file(files[0]);
if (files.length) ctrl.load_file(files[0]);
}),
skeleton_checkbox.checked.observe(({ value }) =>
model_3d_store.set_show_skeleton(value),
),
skeleton_checkbox.checked.observe(({ value }) => ctrl.set_show_skeleton(value)),
);
// Controls that are only enabled when an animation is selected.
const enabled = model_3d_store.current_nj_motion.map(njm => njm != undefined);
const enabled = ctrl.animation_controls_enabled;
this.disposables(
play_animation_checkbox.enabled.bind_to(enabled),
play_animation_checkbox.checked.bind_to(model_3d_store.animation_playing),
play_animation_checkbox.checked.bind_to(ctrl.animation_playing),
play_animation_checkbox.checked.observe(({ value }) =>
model_3d_store.set_animation_playing(value),
ctrl.set_animation_playing(value),
),
animation_frame_rate_input.enabled.bind_to(enabled),
animation_frame_rate_input.value.observe(({ value }) =>
model_3d_store.set_animation_frame_rate(value),
ctrl.set_animation_frame_rate(value),
),
animation_frame_input.enabled.bind_to(enabled),
animation_frame_input.value.bind_to(
model_3d_store.animation_frame.map(v => Math.round(v)),
),
animation_frame_input.value.observe(({ value }) =>
model_3d_store.set_animation_frame(value),
),
animation_frame_input.value.bind_to(ctrl.animation_frame),
animation_frame_input.value.observe(({ value }) => ctrl.set_animation_frame(value)),
animation_frame_count_label.enabled.bind_to(enabled),
);

View File

@ -1,4 +1,4 @@
.viewer_Model3DView_container {
.viewer_model_ModelView_container {
display: flex;
flex-direction: row;
}

View File

@ -0,0 +1,31 @@
import { with_disposer } from "../../../../test/src/core/observables/disposable_helpers";
import { ModelController } from "../../controllers/model/ModelController";
import { CharacterClassAssetLoader } from "../../loading/CharacterClassAssetLoader";
import { FileSystemHttpClient } from "../../../../test/src/core/FileSystemHttpClient";
import { ModelView } from "./ModelView";
import { ModelRenderer } from "../../rendering/ModelRenderer";
import { StubThreeRenderer } from "../../../../test/src/core/rendering/StubThreeRenderer";
import { Random } from "../../../core/Random";
import { ModelStore } from "../../stores/ModelStore";
import { ModelToolBarView } from "./ModelToolBarView";
import { ModelToolBarController } from "../../controllers/model/ModelToolBarController";
import { CharacterClassOptionsView } from "./CharacterClassOptionsView";
import { CharacterClassOptionsController } from "../../controllers/model/CharacterClassOptionsController";
test("Renders correctly.", () =>
with_disposer(disposer => {
const store = disposer.add(
new ModelStore(
disposer.add(new CharacterClassAssetLoader(new FileSystemHttpClient())),
new Random(() => 0.04),
),
);
const view = new ModelView(
disposer.add(new ModelController(store)),
new ModelToolBarView(disposer.add(new ModelToolBarController(store))),
new CharacterClassOptionsView(disposer.add(new CharacterClassOptionsController(store))),
new ModelRenderer(store, new StubThreeRenderer()),
);
expect(view.element).toMatchSnapshot();
}));

View File

@ -0,0 +1,102 @@
import "./ModelView.css";
import { RendererWidget } from "../../../core/gui/RendererWidget";
import { ModelRenderer } from "../../rendering/ModelRenderer";
import { ModelToolBarView } from "./ModelToolBarView";
import { CharacterClassSelectionView } from "./CharacterClassSelectionView";
import { CharacterClassModel } from "../../model/CharacterClassModel";
import { CharacterClassAnimationModel } from "../../model/CharacterClassAnimationModel";
import { ModelController } from "../../controllers/model/ModelController";
import { div } from "../../../core/gui/dom";
import { ResizableView } from "../../../core/gui/ResizableView";
import { CharacterClassOptionsView } from "./CharacterClassOptionsView";
const CHARACTER_CLASS_SELECTION_WIDTH = 100;
const CHARACTER_CLASS_OPTIONS_WIDTH = 220;
const ANIMATION_SELECTION_WIDTH = 140;
export class ModelView extends ResizableView {
readonly element = div({ className: "viewer_model_ModelView" });
private tool_bar_view: ModelToolBarView;
private character_class_selection_view: CharacterClassSelectionView<CharacterClassModel>;
private options_view: CharacterClassOptionsView;
private renderer_view: RendererWidget;
private animation_selection_view: CharacterClassSelectionView<CharacterClassAnimationModel>;
constructor(
ctrl: ModelController,
tool_bar_view: ModelToolBarView,
options_view: CharacterClassOptionsView,
renderer: ModelRenderer,
) {
super();
this.tool_bar_view = this.add(tool_bar_view);
this.character_class_selection_view = this.add(
new CharacterClassSelectionView(
ctrl.character_classes,
ctrl.current_character_class,
ctrl.set_current_character_class,
false,
),
);
this.options_view = this.add(options_view);
this.renderer_view = this.add(new RendererWidget(renderer));
this.animation_selection_view = this.add(
new CharacterClassSelectionView(
ctrl.animations,
ctrl.current_animation,
ctrl.set_current_animation,
true,
),
);
this.element.append(
this.tool_bar_view.element,
div(
{ className: "viewer_model_ModelView_container" },
this.character_class_selection_view.element,
this.options_view.element,
this.renderer_view.element,
this.animation_selection_view.element,
),
);
this.finalize_construction();
}
activate(): void {
this.renderer_view.start_rendering();
super.activate();
}
deactivate(): void {
super.deactivate();
this.renderer_view.stop_rendering();
}
resize(width: number, height: number): this {
super.resize(width, height);
const container_height = Math.max(0, height - this.tool_bar_view.height);
this.character_class_selection_view.resize(
CHARACTER_CLASS_SELECTION_WIDTH,
container_height,
);
this.options_view.resize(CHARACTER_CLASS_OPTIONS_WIDTH, container_height);
this.renderer_view.resize(
Math.max(
0,
width -
CHARACTER_CLASS_SELECTION_WIDTH -
CHARACTER_CLASS_OPTIONS_WIDTH -
ANIMATION_SELECTION_WIDTH,
),
container_height,
);
this.animation_selection_view.resize(ANIMATION_SELECTION_WIDTH, container_height);
return this;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,90 +0,0 @@
import "./Model3DView.css";
import { RendererWidget } from "../../../core/gui/RendererWidget";
import { Model3DRenderer } from "../../rendering/Model3DRenderer";
import { Model3DToolBarView } from "./Model3DToolBarView";
import { Model3DSelectListView } from "./Model3DSelectListView";
import { CharacterClassModel } from "../../model/CharacterClassModel";
import { CharacterClassAnimationModel } from "../../model/CharacterClassAnimationModel";
import { Model3DStore } from "../../stores/Model3DStore";
import { DisposableThreeRenderer } from "../../../core/rendering/Renderer";
import { div } from "../../../core/gui/dom";
import { ResizableView } from "../../../core/gui/ResizableView";
import { GuiStore } from "../../../core/stores/GuiStore";
const MODEL_LIST_WIDTH = 100;
const ANIMATION_LIST_WIDTH = 140;
export class Model3DView extends ResizableView {
readonly element = div({ className: "viewer_Model3DView" });
private tool_bar_view: Model3DToolBarView;
private model_list_view: Model3DSelectListView<CharacterClassModel>;
private animation_list_view: Model3DSelectListView<CharacterClassAnimationModel>;
private renderer_view: RendererWidget;
constructor(
private readonly gui_store: GuiStore,
model_3d_store: Model3DStore,
three_renderer: DisposableThreeRenderer,
) {
super();
this.tool_bar_view = this.add(new Model3DToolBarView(model_3d_store));
this.model_list_view = this.add(
new Model3DSelectListView(
model_3d_store.models,
model_3d_store.current_model,
model_3d_store.set_current_model,
),
);
this.animation_list_view = this.add(
new Model3DSelectListView(
model_3d_store.animations,
model_3d_store.current_animation,
model_3d_store.set_current_animation,
),
);
this.renderer_view = this.add(
new RendererWidget(new Model3DRenderer(three_renderer, model_3d_store)),
);
this.animation_list_view.borders = true;
this.element.append(
this.tool_bar_view.element,
div(
{ className: "viewer_Model3DView_container" },
this.model_list_view.element,
this.animation_list_view.element,
this.renderer_view.element,
),
);
this.finalize_construction();
}
activate(): void {
this.renderer_view.start_rendering();
super.activate();
}
deactivate(): void {
super.deactivate();
this.renderer_view.stop_rendering();
}
resize(width: number, height: number): this {
super.resize(width, height);
const container_height = Math.max(0, height - this.tool_bar_view.height);
this.model_list_view.resize(MODEL_LIST_WIDTH, container_height);
this.animation_list_view.resize(ANIMATION_LIST_WIDTH, container_height);
this.renderer_view.resize(
Math.max(0, width - MODEL_LIST_WIDTH - ANIMATION_LIST_WIDTH),
container_height,
);
return this;
}
}

View File

@ -4,9 +4,18 @@ import { HttpClient } from "../core/HttpClient";
import { DisposableThreeRenderer } from "../core/rendering/Renderer";
import { Disposable } from "../core/observable/Disposable";
import { Disposer } from "../core/observable/Disposer";
import { TextureRenderer } from "./rendering/TextureRenderer";
import { ModelRenderer } from "./rendering/ModelRenderer";
import { Random } from "../core/Random";
import { ModelToolBarView } from "./gui/model/ModelToolBarView";
import { ModelStore } from "./stores/ModelStore";
import { ModelToolBarController } from "./controllers/model/ModelToolBarController";
import { CharacterClassOptionsView } from "./gui/model/CharacterClassOptionsView";
import { CharacterClassOptionsController } from "./controllers/model/CharacterClassOptionsController";
export function initialize_viewer(
http_client: HttpClient,
random: Random,
gui_store: GuiStore,
create_three_renderer: () => DisposableThreeRenderer,
): { view: ViewerView } & Disposable {
@ -14,36 +23,36 @@ export function initialize_viewer(
const view = new ViewerView(
gui_store,
async () => {
const { Model3DStore } = await import("./stores/Model3DStore");
const { Model3DView } = await import("./gui/model_3d/Model3DView");
const { ModelController } = await import("./controllers/model/ModelController");
const { ModelView } = await import("./gui/model/ModelView");
const { CharacterClassAssetLoader } = await import(
"./loading/CharacterClassAssetLoader"
);
const asset_loader = disposer.add(new CharacterClassAssetLoader(http_client));
const store = new Model3DStore(asset_loader);
const store = disposer.add(new ModelStore(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);
if (disposer.disposed) {
store.dispose();
} else {
disposer.add(store);
}
return new Model3DView(gui_store, store, create_three_renderer());
return new ModelView(
model_controller,
new ModelToolBarView(model_tool_bar_controller),
new CharacterClassOptionsView(character_class_options_controller),
new ModelRenderer(store, create_three_renderer()),
);
},
async () => {
const { TextureStore } = await import("./stores/TextureStore");
const { TextureController } = await import("./controllers/TextureController");
const { TextureView } = await import("./gui/TextureView");
const store = new TextureStore();
const controller = disposer.add(new TextureController());
if (disposer.disposed) {
store.dispose();
} else {
disposer.add(store);
}
return new TextureView(store, create_three_renderer());
return new TextureView(
controller,
new TextureRenderer(controller, create_three_renderer()),
);
},
);

View File

@ -327,7 +327,7 @@ function texture_ids(
case FOMARL: {
const body_idx = body * 16;
return {
section_id: section_id + 310,
section_id: section_id + 326,
body: [body_idx, body_idx + 2, body_idx + 1, 322 /*hands*/],
head: [288],
hair: [undefined, undefined, 308],

View File

@ -1,10 +1,8 @@
import {
AdditiveBlending,
AnimationClip,
AnimationMixer,
Clock,
DoubleSide,
Mesh,
MeshBasicMaterial,
MeshLambertMaterial,
Object3D,
@ -25,10 +23,10 @@ import {
import { DisposableThreeRenderer, Renderer } from "../../core/rendering/Renderer";
import { Disposer } from "../../core/observable/Disposer";
import { ChangeEvent } from "../../core/observable/Observable";
import { Model3DStore } from "../stores/Model3DStore";
import { LogManager } from "../../core/Logger";
import { ModelStore } from "../stores/ModelStore";
const logger = LogManager.get("viewer/rendering/Model3DRenderer");
const logger = LogManager.get("viewer/rendering/ModelRenderer");
const DEFAULT_MATERIAL = new MeshLambertMaterial({
color: 0xffffff,
@ -40,7 +38,7 @@ const DEFAULT_SKINNED_MATERIAL = new MeshLambertMaterial({
side: DoubleSide,
});
export class Model3DRenderer extends Renderer implements Disposable {
export class ModelRenderer extends Renderer implements Disposable {
private readonly disposer = new Disposer();
private readonly clock = new Clock();
private mesh?: Object3D;
@ -53,23 +51,21 @@ export class Model3DRenderer extends Renderer implements Disposable {
readonly camera = new PerspectiveCamera(75, 1, 1, 200);
constructor(
three_renderer: DisposableThreeRenderer,
private readonly model_3d_store: Model3DStore,
) {
constructor(private readonly store: ModelStore, three_renderer: DisposableThreeRenderer) {
super(three_renderer);
this.disposer.add_all(
model_3d_store.current_nj_data.observe(this.nj_data_or_xvm_changed),
model_3d_store.current_textures.observe(this.nj_data_or_xvm_changed),
model_3d_store.current_nj_motion.observe(this.nj_motion_changed),
model_3d_store.show_skeleton.observe(this.show_skeleton_changed),
model_3d_store.animation_playing.observe(this.animation_playing_changed),
model_3d_store.animation_frame_rate.observe(this.animation_frame_rate_changed),
model_3d_store.animation_frame.observe(this.animation_frame_changed),
store.current_nj_object.observe(this.nj_object_or_xvm_changed),
store.current_textures.observe(this.nj_object_or_xvm_changed),
store.current_nj_motion.observe(this.nj_motion_changed),
store.show_skeleton.observe(this.show_skeleton_changed),
store.animation_playing.observe(this.animation_playing_changed),
store.animation_frame_rate.observe(this.animation_frame_rate_changed),
store.animation_frame.observe(this.animation_frame_changed),
);
this.init_camera_controls();
this.reset_camera(new Vector3(0, 10, 20), new Vector3(0, 0, 0));
}
set_size(width: number, height: number): void {
@ -97,7 +93,7 @@ export class Model3DRenderer extends Renderer implements Disposable {
}
}
private nj_data_or_xvm_changed = (): void => {
private nj_object_or_xvm_changed = (): void => {
if (this.mesh) {
this.scene.remove(this.mesh);
this.mesh = undefined;
@ -105,20 +101,22 @@ export class Model3DRenderer extends Renderer implements Disposable {
this.skeleton_helper = undefined;
}
if (this.animation) {
this.animation.mixer.stopAllAction();
if (this.mesh) this.animation.mixer.uncacheRoot(this.mesh);
this.animation = undefined;
}
const nj_object = this.store.current_nj_object.val;
const nj_data = this.model_3d_store.current_nj_data.val;
if (nj_object) {
// Stop animation and store animation time.
let animation_time: number | undefined;
if (nj_data) {
const { nj_object, has_skeleton } = nj_data;
if (this.animation) {
const mixer = this.animation.mixer;
animation_time = mixer.existingAction(this.animation.clip)?.time;
mixer.stopAllAction();
mixer.uncacheAction(this.animation.clip);
this.animation = undefined;
}
let mesh: Mesh;
const textures = this.model_3d_store.current_textures.val.map(tex => {
// Convert textures and geometry.
const textures = this.store.current_textures.val.map(tex => {
if (tex) {
try {
return xvr_texture_to_texture(tex);
@ -130,6 +128,9 @@ export class Model3DRenderer extends Renderer implements Disposable {
return undefined;
});
const geometry = ninja_object_to_buffer_geometry(nj_object);
const has_skeleton = geometry.getAttribute("skinIndex") != undefined;
const materials = textures.map(tex =>
tex
? new MeshBasicMaterial({
@ -145,64 +146,66 @@ export class Model3DRenderer extends Renderer implements Disposable {
}),
);
if (has_skeleton) {
mesh = create_skinned_mesh(
ninja_object_to_buffer_geometry(nj_object),
materials,
DEFAULT_SKINNED_MATERIAL,
);
} else {
mesh = create_mesh(
ninja_object_to_buffer_geometry(nj_object),
materials,
DEFAULT_MATERIAL,
);
}
this.mesh = has_skeleton
? create_skinned_mesh(geometry, materials, DEFAULT_SKINNED_MATERIAL)
: create_mesh(geometry, materials, DEFAULT_MATERIAL);
// Make sure we rotate around the center of the model instead of its origin.
const bb = mesh.geometry.boundingBox;
const bb = geometry.boundingBox;
const height = bb.max.y - bb.min.y;
mesh.translateY(-height / 2 - bb.min.y);
this.mesh.translateY(-height / 2 - bb.min.y);
this.mesh = mesh;
this.scene.add(mesh);
this.scene.add(this.mesh);
this.skeleton_helper = new SkeletonHelper(mesh);
this.skeleton_helper.visible = this.model_3d_store.show_skeleton.val;
// Add skeleton.
this.skeleton_helper = new SkeletonHelper(this.mesh);
this.skeleton_helper.visible = this.store.show_skeleton.val;
(this.skeleton_helper.material as any).linewidth = 3;
this.scene.add(this.skeleton_helper);
this.reset_camera(new Vector3(0, 10, 20), new Vector3(0, 0, 0));
// Create a new animation mixer and clip.
const nj_motion = this.store.current_nj_motion.val;
if (nj_motion) {
const mixer = new AnimationMixer(this.mesh);
mixer.timeScale = this.store.animation_frame_rate.val / PSO_FRAME_RATE;
const clip = create_animation_clip(nj_object, nj_motion);
this.animation = { mixer, clip };
const action = mixer.clipAction(clip, this.mesh);
action.time = animation_time ?? 0;
action.play();
}
}
this.schedule_render();
};
private nj_motion_changed = ({ value: nj_motion }: ChangeEvent<NjMotion | undefined>): void => {
let mixer!: AnimationMixer;
let mixer: AnimationMixer | undefined;
if (this.animation) {
this.animation.mixer.stopAllAction();
this.animation.mixer.uncacheAction(this.animation.clip);
mixer = this.animation.mixer;
}
const nj_data = this.model_3d_store.current_nj_data.val;
const nj_object = this.store.current_nj_object.val;
if (!this.mesh || !(this.mesh instanceof SkinnedMesh) || !nj_motion || !nj_data) return;
if (!this.mesh || !(this.mesh instanceof SkinnedMesh) || !nj_motion || !nj_object) return;
if (!this.animation) {
if (!mixer) {
mixer = new AnimationMixer(this.mesh);
}
const clip = create_animation_clip(nj_data.nj_object, nj_motion);
const clip = create_animation_clip(nj_object, nj_motion);
this.animation = {
mixer,
clip,
};
this.animation = { mixer, clip };
this.clock.start();
this.animation.mixer.clipAction(this.animation.clip).play();
mixer.clipAction(clip).play();
this.schedule_render();
};
@ -233,7 +236,7 @@ export class Model3DRenderer extends Renderer implements Disposable {
};
private animation_frame_changed = ({ value: frame }: ChangeEvent<number>): void => {
const nj_motion = this.model_3d_store.current_nj_motion.val;
const nj_motion = this.store.current_nj_motion.val;
if (this.animation && nj_motion) {
const frame_count = nj_motion.frame_count;
@ -255,7 +258,7 @@ export class Model3DRenderer extends Renderer implements Disposable {
if (!action.paused) {
this.update_animation_time = false;
this.model_3d_store.set_animation_frame(action.time * PSO_FRAME_RATE + 1);
this.store.set_animation_frame(action.time * PSO_FRAME_RATE + 1);
this.update_animation_time = true;
}
}

View File

@ -10,10 +10,10 @@ import {
import { Disposable } from "../../core/observable/Disposable";
import { DisposableThreeRenderer, Renderer } from "../../core/rendering/Renderer";
import { Disposer } from "../../core/observable/Disposer";
import { TextureStore } from "../stores/TextureStore";
import { XvrTexture } from "../../core/data_formats/parsing/ninja/texture";
import { xvr_texture_to_texture } from "../../core/rendering/conversion/ninja_textures";
import { LogManager } from "../../core/Logger";
import { TextureController } from "../controllers/TextureController";
const logger = LogManager.get("viewer/rendering/TextureRenderer");
@ -23,11 +23,11 @@ export class TextureRenderer extends Renderer implements Disposable {
readonly camera = new OrthographicCamera(-400, 400, 300, -300, 1, 10);
constructor(three_renderer: DisposableThreeRenderer, texture_store: TextureStore) {
constructor(ctrl: TextureController, three_renderer: DisposableThreeRenderer) {
super(three_renderer);
this.disposer.add_all(
texture_store.textures.observe(({ value: textures }) => {
ctrl.textures.observe(({ value: textures }) => {
this.scene.remove(...this.quad_meshes);
this.render_textures(textures);

View File

@ -1,237 +0,0 @@
import { ArrayBufferCursor } from "../../core/data_formats/cursor/ArrayBufferCursor";
import { Endianness } from "../../core/data_formats/Endianness";
import { NjMotion, parse_njm } from "../../core/data_formats/parsing/ninja/motion";
import { NjObject, parse_nj, parse_xj } from "../../core/data_formats/parsing/ninja";
import {
CharacterClassModel,
FOMAR,
FOMARL,
FONEWEARL,
FONEWM,
HUCASEAL,
HUCAST,
HUMAR,
HUNEWEARL,
RACASEAL,
RACAST,
RAMAR,
RAMARL,
} from "../model/CharacterClassModel";
import { CharacterClassAnimationModel } from "../model/CharacterClassAnimationModel";
import { WritableProperty } from "../../core/observable/property/WritableProperty";
import { read_file } from "../../core/read_file";
import { list_property, property } from "../../core/observable";
import { Property } from "../../core/observable/property/Property";
import { PSO_FRAME_RATE } from "../../core/rendering/conversion/ninja_animation";
import { parse_xvm, XvrTexture } from "../../core/data_formats/parsing/ninja/texture";
import { CharacterClassAssetLoader } from "../loading/CharacterClassAssetLoader";
import { Store } from "../../core/stores/Store";
import { LogManager } from "../../core/Logger";
import { ListProperty } from "../../core/observable/property/list/ListProperty";
import { parse_afs } from "../../core/data_formats/parsing/afs";
import { random_array_element, random_integer } from "../../core/util";
import { SectionIds } from "../../core/model";
const logger = LogManager.get("viewer/stores/ModelStore");
export type NjData = {
nj_object: NjObject;
bone_count: number;
has_skeleton: boolean;
};
export class Model3DStore extends Store {
private readonly _current_model: WritableProperty<CharacterClassModel | undefined> = property(
undefined,
);
private readonly _current_nj_data = property<NjData | undefined>(undefined);
private readonly _current_textures = list_property<XvrTexture | undefined>();
private readonly _show_skeleton: WritableProperty<boolean> = property(false);
private readonly _current_animation: WritableProperty<
CharacterClassAnimationModel | undefined
> = property(undefined);
private readonly _current_nj_motion = property<NjMotion | undefined>(undefined);
private readonly _animation_playing: WritableProperty<boolean> = property(true);
private readonly _animation_frame_rate: WritableProperty<number> = property(PSO_FRAME_RATE);
private readonly _animation_frame: WritableProperty<number> = property(0);
readonly models: readonly CharacterClassModel[] = [
HUMAR,
HUNEWEARL,
HUCAST,
HUCASEAL,
RAMAR,
RAMARL,
RACAST,
RACASEAL,
FOMAR,
FOMARL,
FONEWM,
FONEWEARL,
];
readonly animations: readonly CharacterClassAnimationModel[] = new Array(572)
.fill(undefined)
.map((_, i) => new CharacterClassAnimationModel(i, `Animation ${i + 1}`));
readonly current_model: Property<CharacterClassModel | undefined> = this._current_model;
readonly current_nj_data: Property<NjData | undefined> = this._current_nj_data;
readonly current_textures: ListProperty<XvrTexture | undefined> = this._current_textures;
readonly show_skeleton: Property<boolean> = this._show_skeleton;
readonly current_animation: Property<CharacterClassAnimationModel | undefined> = this
._current_animation;
readonly current_nj_motion: Property<NjMotion | undefined> = this._current_nj_motion;
readonly animation_playing: Property<boolean> = this._animation_playing;
readonly animation_frame_rate: Property<number> = this._animation_frame_rate;
readonly animation_frame: Property<number> = this._animation_frame;
readonly animation_frame_count: Property<number> = this.current_nj_motion.map(njm =>
njm ? njm.frame_count : 0,
);
constructor(private readonly asset_loader: CharacterClassAssetLoader) {
super();
this.disposables(
this.current_model.observe(({ value }) => this.load_model(value)),
this.current_animation.observe(({ value }) => this.load_animation(value)),
);
this.set_current_model(random_array_element(this.models));
}
set_current_model = (current_model?: CharacterClassModel): void => {
this._current_model.val = current_model;
};
set_show_skeleton = (show_skeleton: boolean): void => {
this._show_skeleton.val = show_skeleton;
};
set_current_animation = (animation?: CharacterClassAnimationModel): void => {
this._current_animation.val = animation;
};
set_animation_playing = (playing: boolean): void => {
this._animation_playing.val = playing;
};
set_animation_frame_rate = (frame_rate: number): void => {
this._animation_frame_rate.val = frame_rate;
};
set_animation_frame = (frame: number): void => {
this._animation_frame.val = frame;
};
// TODO: notify user of problems.
load_file = async (file: File): Promise<void> => {
try {
const buffer = await read_file(file);
const cursor = new ArrayBufferCursor(buffer, Endianness.Little);
if (file.name.endsWith(".nj")) {
this.set_current_model(undefined);
this._current_textures.clear();
const nj_object = parse_nj(cursor)[0];
this.set_current_nj_data({
nj_object,
bone_count: nj_object.bone_count(),
has_skeleton: true,
});
} else if (file.name.endsWith(".xj")) {
this.set_current_model(undefined);
this._current_textures.clear();
const nj_object = parse_xj(cursor)[0];
this.set_current_nj_data({
nj_object,
bone_count: 0,
has_skeleton: false,
});
} else if (file.name.endsWith(".njm")) {
this.set_current_animation(undefined);
this._current_nj_motion.val = undefined;
const nj_data = this.current_nj_data.val;
if (nj_data) {
this.set_animation_playing(true);
this._current_nj_motion.val = parse_njm(cursor, nj_data.bone_count);
}
} else if (file.name.endsWith(".xvm")) {
this._current_textures.val = parse_xvm(cursor).textures;
} else if (file.name.endsWith(".afs")) {
const files = parse_afs(cursor);
const textures: XvrTexture[] = [];
for (const file of files) {
textures.push(
...parse_xvm(new ArrayBufferCursor(file, Endianness.Little)).textures,
);
}
this._current_textures.val = textures;
} else {
logger.error(`Unknown file extension in filename "${file.name}".`);
}
} catch (e) {
logger.error("Couldn't read file.", e);
}
};
private load_model = async (model?: CharacterClassModel): Promise<void> => {
this.set_current_animation(undefined);
if (model) {
try {
this.set_current_nj_data(undefined);
const nj_object = await this.asset_loader.load_geometry(model);
this._current_textures.val = await this.asset_loader.load_textures(
model,
random_array_element(SectionIds),
random_integer(0, model.body_style_count),
);
this.set_current_nj_data({
nj_object,
// Ignore the bones from the head parts.
bone_count: model ? 64 : nj_object.bone_count(),
has_skeleton: true,
});
} catch (e) {
logger.error(`Couldn't load model for ${model.name}.`);
this._current_nj_data.val = undefined;
}
} else {
this._current_nj_data.val = undefined;
}
};
private set_current_nj_data(nj_data?: NjData): void {
this._current_nj_data.val = nj_data;
}
private load_animation = async (animation?: CharacterClassAnimationModel): Promise<void> => {
const nj_data = this.current_nj_data.val;
if (nj_data && animation) {
try {
this._current_nj_motion.val = await this.asset_loader.load_animation(
animation.id,
nj_data.bone_count,
);
this.set_animation_playing(true);
} catch (e) {
logger.error(`Couldn't load animation "${animation.name}".`);
this._current_nj_motion.val = undefined;
}
} else {
this._current_nj_motion.val = undefined;
}
};
}

View File

@ -0,0 +1,226 @@
import { Store } from "../../core/stores/Store";
import { WritableProperty } from "../../core/observable/property/WritableProperty";
import {
CharacterClassModel,
FOMAR,
FOMARL,
FONEWEARL,
FONEWM,
HUCASEAL,
HUCAST,
HUMAR,
HUNEWEARL,
RACASEAL,
RACAST,
RAMAR,
RAMARL,
} from "../model/CharacterClassModel";
import { list_property, property } from "../../core/observable";
import { XvrTexture } from "../../core/data_formats/parsing/ninja/texture";
import { CharacterClassAnimationModel } from "../model/CharacterClassAnimationModel";
import { NjMotion } from "../../core/data_formats/parsing/ninja/motion";
import { PSO_FRAME_RATE } from "../../core/rendering/conversion/ninja_animation";
import { Property } from "../../core/observable/property/Property";
import { ListProperty } from "../../core/observable/property/list/ListProperty";
import { CharacterClassAssetLoader } from "../loading/CharacterClassAssetLoader";
import { Random } from "../../core/Random";
import { SectionId, SectionIds } from "../../core/model";
import { LogManager } from "../../core/Logger";
import { NjObject } from "../../core/data_formats/parsing/ninja";
const logger = LogManager.get("viewer/stores/ModelStore");
export type NjData = {
readonly nj_object: NjObject;
readonly bone_count: number;
readonly has_skeleton: boolean;
};
export class ModelStore extends Store {
// Character classes and their animations.
private readonly _current_character_class: WritableProperty<
CharacterClassModel | undefined
> = property(undefined);
private readonly _current_section_id: WritableProperty<SectionId>;
private readonly _current_body: WritableProperty<number | undefined> = property(0);
private readonly _current_animation: WritableProperty<
CharacterClassAnimationModel | undefined
> = property(undefined);
// Geometry, textures and animations.
private readonly _current_nj_object = property<NjObject | undefined>(undefined);
private readonly _current_textures = list_property<XvrTexture | undefined>();
private readonly _current_nj_motion = property<NjMotion | undefined>(undefined);
// User settings.
private readonly _show_skeleton: WritableProperty<boolean> = property(false);
private readonly _animation_playing: WritableProperty<boolean> = property(true);
private readonly _animation_frame_rate: WritableProperty<number> = property(PSO_FRAME_RATE);
private readonly _animation_frame: WritableProperty<number> = property(0);
// Character classes and their animations.
readonly character_classes: readonly CharacterClassModel[] = [
HUMAR,
HUNEWEARL,
HUCAST,
HUCASEAL,
RAMAR,
RAMARL,
RACAST,
RACASEAL,
FOMAR,
FOMARL,
FONEWM,
FONEWEARL,
];
readonly current_character_class: Property<CharacterClassModel | undefined> = this
._current_character_class;
readonly current_section_id: Property<SectionId>;
readonly current_body: Property<number | undefined> = this._current_body;
readonly animations: readonly CharacterClassAnimationModel[] = new Array(572)
.fill(undefined)
.map((_, i) => new CharacterClassAnimationModel(i, `Animation ${i + 1}`));
readonly current_animation: Property<CharacterClassAnimationModel | undefined> = this
._current_animation;
// Geometry, textures and animations.
readonly current_nj_object: Property<NjObject | undefined> = this._current_nj_object;
readonly current_textures: ListProperty<XvrTexture | undefined> = this._current_textures;
readonly current_nj_motion: Property<NjMotion | undefined> = this._current_nj_motion;
readonly animation_frame_count: Property<number> = this.current_nj_motion.map(njm =>
njm ? njm.frame_count : 0,
);
// User settings.
readonly show_skeleton: Property<boolean> = this._show_skeleton;
readonly animation_playing: Property<boolean> = this._animation_playing;
readonly animation_frame_rate: Property<number> = this._animation_frame_rate;
readonly animation_frame: Property<number> = this._animation_frame;
constructor(
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),
this.current_body.observe(this.load_character_class_model),
this.current_animation.observe(this.load_animation),
);
const character_class = random.sample_array(this.character_classes);
this.set_current_character_class(character_class);
}
set_current_character_class = (character_class?: CharacterClassModel): void => {
if (this._current_character_class.val !== character_class) {
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);
}
}
};
set_current_section_id = (section_id: SectionId): void => {
this._current_section_id.val = section_id;
};
set_current_body = (body?: number): void => {
this._current_body.val = body;
};
set_current_animation = (animation?: CharacterClassAnimationModel): void => {
if (this._current_animation.val !== animation) {
this._current_animation.val = animation;
}
};
set_current_nj_object = (nj_object?: NjObject): void => {
this.set_current_character_class(undefined);
this.set_current_animation(undefined);
this.set_current_textures([]);
this.set_current_nj_motion(undefined);
this._current_nj_object.val = nj_object;
};
set_current_textures = (textures: (XvrTexture | undefined)[]): void => {
this._current_textures.val = textures;
};
set_current_nj_motion = (nj_motion?: NjMotion): void => {
this.set_current_animation(undefined);
this._current_nj_motion.val = nj_motion;
};
set_show_skeleton = (show_skeleton: boolean): void => {
this._show_skeleton.val = show_skeleton;
};
set_animation_playing = (playing: boolean): void => {
this._animation_playing.val = playing;
};
set_animation_frame_rate = (frame_rate: number): void => {
this._animation_frame_rate.val = frame_rate;
};
set_animation_frame = (frame: number): void => {
this._animation_frame.val = frame;
};
private load_character_class_model = async (): Promise<void> => {
const character_class = this.current_character_class.val;
if (character_class == undefined) return;
const body = this.current_body.val;
if (body == undefined) return;
try {
this._current_nj_object.val = undefined;
const nj_object = await this.asset_loader.load_geometry(character_class);
this._current_textures.val = await this.asset_loader.load_textures(
character_class,
this.current_section_id.val,
body,
);
this._current_nj_object.val = nj_object;
} catch (e) {
logger.error(`Couldn't load model for ${character_class.name}.`);
this._current_nj_object.val = undefined;
}
};
private load_animation = async (): Promise<void> => {
const nj_object = this._current_nj_object.val;
const animation = this.current_animation.val;
if (nj_object && animation) {
try {
this._current_nj_motion.val = await this.asset_loader.load_animation(
animation.id,
64,
);
this.set_animation_playing(true);
} catch (e) {
logger.error(`Couldn't load animation "${animation.name}".`, e);
this._current_nj_motion.val = undefined;
}
} else {
this._current_nj_motion.val = undefined;
}
};
}

View File

@ -0,0 +1,11 @@
import { DisposableThreeRenderer } from "../../../../src/core/rendering/Renderer";
export class StubThreeRenderer implements DisposableThreeRenderer {
domElement: HTMLCanvasElement = document.createElement("canvas");
dispose(): void {} // eslint-disable-line
render(): void {} // eslint-disable-line
setSize(): void {} // eslint-disable-line
}