Started working on new UI.

This commit is contained in:
Daan Vanden Bosch 2019-08-19 22:56:40 +02:00
parent 1d0da754ca
commit 5571f6b1a8
40 changed files with 1339 additions and 43 deletions

View File

@ -18,7 +18,7 @@ export function is_xj_model(model: NjModel): model is XjModel {
return model.type === "xj";
}
export class NjObject<M extends NjModel> {
export class NjObject<M extends NjModel = NjModel> {
evaluation_flags: NjEvaluationFlags;
model: M | undefined;
position: Vec3;

View File

@ -1,6 +1,7 @@
import CameraControls from "camera-controls";
import * as THREE from "three";
import {
Camera,
Clock,
Color,
Group,
@ -21,7 +22,7 @@ CameraControls.install({
},
});
export abstract class Renderer<C extends PerspectiveCamera | OrthographicCamera> {
export abstract class Renderer {
protected _debug = false;
get debug(): boolean {
@ -32,7 +33,7 @@ export abstract class Renderer<C extends PerspectiveCamera | OrthographicCamera>
this._debug = debug;
}
readonly camera: C;
readonly camera: Camera;
readonly controls: CameraControls;
readonly scene = new Scene();
readonly light_holder = new Group();
@ -43,7 +44,7 @@ export abstract class Renderer<C extends PerspectiveCamera | OrthographicCamera>
private light = new HemisphereLight(0xffffff, 0x505050, 1.2);
private controls_clock = new Clock();
protected constructor(camera: C) {
protected constructor(camera: PerspectiveCamera | OrthographicCamera) {
this.camera = camera;
this.dom_element.tabIndex = 0;
@ -100,6 +101,10 @@ export abstract class Renderer<C extends PerspectiveCamera | OrthographicCamera>
);
}
dispose(): void {
this.renderer.dispose();
}
protected render(): void {
this.renderer.render(this.scene, this.camera);
}

View File

@ -11,14 +11,11 @@ const NO_TRANSLATION = new Vector3(0, 0, 0);
const NO_ROTATION = new Quaternion(0, 0, 0, 1);
const NO_SCALE = new Vector3(1, 1, 1);
export function ninja_object_to_geometry_builder(
object: NjObject<NjModel>,
builder: GeometryBuilder,
): void {
export function ninja_object_to_geometry_builder(object: NjObject, builder: GeometryBuilder): void {
new GeometryCreator(builder).to_geometry_builder(object);
}
export function ninja_object_to_buffer_geometry(object: NjObject<NjModel>): BufferGeometry {
export function ninja_object_to_buffer_geometry(object: NjObject): BufferGeometry {
return new GeometryCreator(new GeometryBuilder()).create_buffer_geometry(object);
}
@ -62,17 +59,17 @@ class GeometryCreator {
this.builder = builder;
}
to_geometry_builder(object: NjObject<NjModel>): void {
to_geometry_builder(object: NjObject): void {
this.object_to_geometry(object, undefined, new Matrix4());
}
create_buffer_geometry(object: NjObject<NjModel>): BufferGeometry {
create_buffer_geometry(object: NjObject): BufferGeometry {
this.to_geometry_builder(object);
return this.builder.build();
}
private object_to_geometry(
object: NjObject<NjModel>,
object: NjObject,
parent_bone: Bone | undefined,
parent_matrix: Matrix4,
): void {

View File

@ -1,9 +1,8 @@
import React, { Component, ReactNode } from "react";
import { OrthographicCamera, PerspectiveCamera } from "three";
import { Renderer } from "../rendering/Renderer";
type Props = {
renderer: Renderer<PerspectiveCamera | OrthographicCamera>;
renderer: Renderer;
width: number;
height: number;
debug?: boolean;

View File

@ -3,12 +3,13 @@ import ReactDOM from "react-dom";
import Logger from "js-logger";
import styles from "./core/ui/index.css";
import { ApplicationComponent } from "./application/ui/ApplicationComponent";
import "react-virtualized/styles.css";
import "react-select/dist/react-select.css";
import "react-virtualized-select/styles.css";
// import "react-virtualized/styles.css";
// import "react-select/dist/react-select.css";
// import "react-virtualized-select/styles.css";
import "golden-layout/src/css/goldenlayout-base.css";
import "golden-layout/src/css/goldenlayout-dark-theme.css";
import "antd/dist/antd.less";
// import "antd/dist/antd.less";
import { initialize } from "./new";
Logger.useDefaults({
defaultLevel: (Logger as any)[process.env["LOG_LEVEL"] || "OFF"],
@ -31,8 +32,10 @@ document.addEventListener("beforeinput", e => {
}
});
const root_element = document.createElement("div");
root_element.id = styles.phantasmal_world_root;
document.body.append(root_element);
// const root_element = document.createElement("div");
// root_element.id = styles.phantasmal_world_root;
// document.body.append(root_element);
//
// ReactDOM.render(<ApplicationComponent />, root_element);
ReactDOM.render(<ApplicationComponent />, root_element);
initialize();

View File

@ -0,0 +1,23 @@
import { NavigationView } from "./NavigationView";
import { MainContentView } from "./MainContentView";
import { create_el } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
export class ApplicationView extends ResizableView {
element = create_el("div", "application_ApplicationView");
private menu_view = this.disposable(new NavigationView());
private main_content_view = this.disposable(new MainContentView());
constructor() {
super();
this.element.append(this.menu_view.element, this.main_content_view.element);
}
resize(width: number, height: number): this {
super.resize(width, height);
this.main_content_view.resize(width, height - this.menu_view.height);
return this;
}
}

View File

@ -0,0 +1,47 @@
import { create_el } from "../../core/gui/dom";
import { View } from "../../core/gui/View";
import { gui_store, GuiTool } from "../../core/stores/GuiStore";
import { LazyView } from "../../core/gui/LazyView";
import { Resizable } from "../../core/gui/Resizable";
import { ResizableView } from "../../core/gui/ResizableView";
const TOOLS: [GuiTool, () => Promise<View & Resizable>][] = [
[GuiTool.Viewer, async () => new (await import("../../viewer/gui/ViewerView")).ViewerView()],
];
export class MainContentView extends ResizableView {
element = create_el("div", "application_MainContentView");
private tool_views = new Map(
TOOLS.map(([tool, create_view]) => [tool, this.disposable(new LazyView(create_view))]),
);
constructor() {
super();
for (const tool_view of this.tool_views.values()) {
this.element.append(tool_view.element);
}
this.tool_changed(gui_store.tool, gui_store.tool);
this.disposable(gui_store.tool_prop.observe(this.tool_changed));
}
resize(width: number, height: number): this {
super.resize(width, height);
for (const tool_view of this.tool_views.values()) {
tool_view.resize(width, height);
}
return this;
}
private tool_changed = (new_tool: GuiTool, old_tool: GuiTool) => {
const old_view = this.tool_views.get(old_tool);
if (old_view) old_view.visible = false;
const new_view = this.tool_views.get(new_tool);
if (new_view) new_view.visible = true;
};
}

View File

@ -0,0 +1,31 @@
.application_NavigationView {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: stretch;
background-color: hsl(0, 0%, 12%);
border-bottom: solid 2px var(--bg-color);
}
.application_ToolButton input {
display: none;
}
.application_ToolButton label {
box-sizing: border-box;
display: inline-block;
font-size: 16px;
height: 100%;
padding: 0 20px;
line-height: 40px;
}
.application_ToolButton label:hover {
color: hsl(200, 25%, 85%);
background-color: hsl(0, 0%, 16%);
}
.application_ToolButton input:checked + label {
color: hsl(200, 50%, 85%);
background-color: hsl(0, 0%, 20%);
}

View File

@ -0,0 +1,72 @@
import { create_el } from "../../core/gui/dom";
import "./NavigationView.css";
import { gui_store, GuiTool } from "../../core/stores/GuiStore";
import { View } from "../../core/gui/View";
const TOOLS: [GuiTool, string][] = [
[GuiTool.Viewer, "Viewer"],
[GuiTool.QuestEditor, "Quest Editor"],
[GuiTool.HuntOptimizer, "Hunt Optimizer"],
];
export class NavigationView extends View {
element = create_el("div", "application_NavigationView");
height = 40;
private buttons = new Map<GuiTool, ToolButton>(
TOOLS.map(([value, text]) => [value, this.disposable(new ToolButton(value, text))]),
);
constructor() {
super();
this.element.style.height = `${this.height}px`;
this.element.onclick = this.click;
for (const button of this.buttons.values()) {
this.element.append(button.element);
}
this.tool_changed(gui_store.tool);
this.disposable(gui_store.tool_prop.observe(this.tool_changed));
}
private click(e: MouseEvent): void {
if (e.target instanceof HTMLLabelElement && e.target.control instanceof HTMLInputElement) {
gui_store.tool = (GuiTool as any)[e.target.control.value];
}
}
private tool_changed = (tool: GuiTool) => {
const button = this.buttons.get(tool);
if (button) button.checked = true;
};
}
class ToolButton extends View {
element: HTMLElement = create_el("span");
private input: HTMLInputElement = create_el("input");
private label: HTMLLabelElement = create_el("label");
constructor(tool: GuiTool, text: string) {
super();
const tool_str = GuiTool[tool];
this.input.type = "radio";
this.input.name = "application_ToolButton";
this.input.value = tool_str;
this.input.id = `application_ToolButton_${tool_str}`;
this.label.append(text);
this.label.htmlFor = `application_ToolButton_${tool_str}`;
this.element.className = "application_ToolButton";
this.element.append(this.input, this.label);
}
set checked(checked: boolean) {
this.input.checked = checked;
}
}

View File

@ -0,0 +1,20 @@
.core_Button {
box-sizing: border-box;
background-color: #404040;
height: 26px;
padding: 2px 8px;
border: solid 1px #606060;
color: #f0f0f0;
outline: none;
}
.core_Button:hover {
background-color: #505050;
border-color: #707070;
}
.core_Button:active {
background-color: #404040;
border-color: #606060;
color: #e0e0e0;
}

View File

@ -0,0 +1,13 @@
import { create_el } from "./dom";
import { View } from "./View";
import "./Button.css";
export class Button extends View {
element: HTMLButtonElement = create_el("button", "core_Button");
constructor(text: string) {
super();
this.element.textContent = text;
}
}

View File

@ -0,0 +1,3 @@
export interface Disposable {
dispose(): void;
}

View File

@ -0,0 +1,46 @@
import { View } from "./View";
import { create_el } from "./dom";
import { Resizable } from "./Resizable";
import { ResizableView } from "./ResizableView";
export class LazyView extends ResizableView {
element = create_el("div", "core_LazyView");
private _visible = false;
set visible(visible: boolean) {
if (this._visible !== visible) {
this._visible = visible;
this.element.hidden = !visible;
if (visible && !this.initialized) {
this.initialized = true;
this.create_view().then(view => {
this.view = this.disposable(view);
this.view.resize(this.width, this.height);
this.element.append(view.element);
});
}
}
}
private initialized = false;
private view: View & Resizable | undefined;
constructor(private create_view: () => Promise<View & Resizable>) {
super();
this.element.hidden = true;
}
resize(width: number, height: number): this {
super.resize(width, height);
if (this.view) {
this.view.resize(width, height);
}
return this;
}
}

View File

@ -0,0 +1,26 @@
import { ResizableView } from "./ResizableView";
import { create_el } from "./dom";
import { Renderer } from "../../../core/rendering/Renderer";
export class RendererView extends ResizableView {
readonly element = create_el("div");
constructor(private renderer: Renderer) {
super();
this.element.append(renderer.dom_element);
this.disposable(renderer);
// TODO: stop on hidden
renderer.start_rendering();
}
resize(width: number, height: number): this {
super.resize(width, height);
this.renderer.set_size(width, height);
return this;
}
}

View File

@ -0,0 +1,3 @@
export interface Resizable {
resize(width: number, height: number): this;
}

View File

@ -0,0 +1,15 @@
import { View } from "./View";
import { Resizable } from "./Resizable";
export abstract class ResizableView extends View implements Resizable {
protected width: number = 0;
protected height: number = 0;
resize(width: number, height: number): this {
this.width = width;
this.height = height;
this.element.style.width = `${width}px`;
this.element.style.height = `${height}px`;
return this;
}
}

View File

@ -0,0 +1,25 @@
.core_TabContainer_Bar {
box-sizing: border-box;
background-color: hsl(0, 0%, 16%);
padding: 3px 0 0 0;
}
.core_TabContainer_Tab {
display: inline-block;
height: 100%;
line-height: 25px;
padding: 0 10px;
margin: 0 1px;
color: #c0c0c0;
font-size: 15px;
}
.core_TabContainer_Tab:hover {
background-color: hsl(0, 0%, 18%);
color: hsl(0, 0%, 85%);
}
.core_TabContainer_Tab.active {
background-color: var(--bg-color);
color: hsl(0, 0%, 90%);
}

View File

@ -0,0 +1,94 @@
import { View } from "./View";
import { create_el } from "./dom";
import { LazyView } from "./LazyView";
import { Resizable } from "./Resizable";
import { ResizableView } from "./ResizableView";
import "./TabContainer.css";
export type Tab = {
title: string;
key: string;
create_view: () => Promise<View & Resizable>;
};
type TabInfo = Tab & { tab_element: HTMLSpanElement; lazy_view: LazyView };
const BAR_HEIGHT = 28;
export class TabContainer extends ResizableView {
element = create_el("div", "core_TabContainer");
private tabs: TabInfo[] = [];
private bar_element = create_el("div", "core_TabContainer_Bar");
private panes_element = create_el("div", "core_TabContainer_Panes");
constructor(...tabs: Tab[]) {
super();
this.bar_element.onclick = this.bar_click;
for (const tab of tabs) {
const tab_element = create_el("span", "core_TabContainer_Tab", tab_element => {
tab_element.textContent = tab.title;
tab_element.dataset["key"] = tab.key;
});
this.bar_element.append(tab_element);
const lazy_view = new LazyView(tab.create_view);
this.tabs.push({
...tab,
tab_element,
lazy_view,
});
this.panes_element.append(lazy_view.element);
this.disposable(lazy_view);
}
if (this.tabs.length) {
this.activate(this.tabs[0].key);
}
this.element.append(this.bar_element, this.panes_element);
}
resize(width: number, height: number): this {
super.resize(width, height);
this.bar_element.style.width = `${width}px`;
this.bar_element.style.height = `${BAR_HEIGHT}px`;
const tab_pane_height = height - BAR_HEIGHT;
this.panes_element.style.width = `${width}px`;
this.panes_element.style.height = `${tab_pane_height}px`;
for (const tabs of this.tabs) {
tabs.lazy_view.resize(width, tab_pane_height);
}
return this;
}
private bar_click = (e: MouseEvent) => {
if (e.target instanceof HTMLElement) {
const key = e.target.dataset["key"];
if (key) this.activate(key);
}
};
private activate(key: string): void {
for (const tab of this.tabs) {
const active = tab.key === key;
if (active) {
tab.tab_element.classList.add("active");
} else {
tab.tab_element.classList.remove("active");
}
tab.lazy_view.visible = active;
}
}
}

View File

@ -0,0 +1,25 @@
.core_ToolBar {
box-sizing: border-box;
padding-top: 1px;
border-bottom: solid var(--border-color) 1px;
}
.core_ToolBar > * {
margin: 2px;
}
.core_ToolBar .core_Button {
background-color: transparent;
border-color: transparent;
}
.core_ToolBar .core_Button:hover {
background-color: #404040;
border-color: #505050;
}
.core_ToolBar .core_Button:active {
background-color: #383838;
border-color: #404040;
color: #d0d0d0;
}

View File

@ -0,0 +1,19 @@
import { View } from "./View";
import { create_el } from "./dom";
import "./ToolBar.css";
export class ToolBar extends View {
readonly element = create_el("div", "core_ToolBar");
readonly height = 32;
constructor(...children: View[]) {
super();
this.element.style.height = `${this.height}px`;
for (const child of children) {
this.element.append(child.element);
this.disposable(child);
}
}
}

17
src/new/core/gui/View.ts Normal file
View File

@ -0,0 +1,17 @@
import { Disposable } from "./Disposable";
export abstract class View implements Disposable {
abstract readonly element: HTMLElement;
private disposables: Disposable[] = [];
protected disposable<T extends Disposable>(disposable: T): T {
this.disposables.push(disposable);
return disposable;
}
dispose(): void {
this.element.remove();
this.disposables.forEach(d => d.dispose());
}
}

20
src/new/core/gui/dom.ts Normal file
View File

@ -0,0 +1,20 @@
import { Disposable } from "./Disposable";
export function create_el<T extends HTMLElement>(
tag_name: string,
class_name?: string,
modify?: (element: T) => void,
): T {
const element = document.createElement(tag_name) as T;
if (class_name) element.className = class_name;
if (modify) modify(element);
return element;
}
export function disposable_el(element: HTMLElement): Disposable {
return {
dispose(): void {
element.remove();
},
};
}

View File

@ -0,0 +1,45 @@
import { Disposable } from "../gui/Disposable";
export class Observable<T> {
private value: T;
private readonly observers: ((new_value: T, old_value: T) => void)[] = [];
constructor(value: T) {
this.value = value;
}
get(): T {
return this.value;
}
set(value: T): void {
if (value !== this.value) {
const old_value = this.value;
this.value = value;
for (const observer of this.observers) {
try {
observer(value, old_value);
} catch (e) {
console.error(e);
}
}
}
}
observe(observer: (new_value: T, old_value: T) => void): Disposable {
if (!this.observers.includes(observer)) {
this.observers.push(observer);
}
return {
dispose: () => {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
},
};
}
}

View File

@ -0,0 +1,48 @@
import { Observable } from "../observable/Observable";
export enum GuiTool {
Viewer,
QuestEditor,
HuntOptimizer,
}
const GUI_TOOL_TO_STRING = new Map([
[GuiTool.Viewer, "viewer"],
[GuiTool.QuestEditor, "quest_editor"],
[GuiTool.HuntOptimizer, "hunt_optimizer"],
]);
const STRING_TO_GUI_TOOL = new Map([...GUI_TOOL_TO_STRING.entries()].map(([k, v]) => [v, k]));
class GuiStore {
tool_prop = new Observable<GuiTool>(GuiTool.Viewer);
get tool(): GuiTool {
return this.tool_prop.get();
}
set tool(tool: GuiTool) {
window.location.hash = `#/${gui_tool_to_string(tool)}`;
this.tool_prop.set(tool);
}
constructor() {
const tool = window.location.hash.slice(2);
this.tool = string_to_gui_tool(tool) || GuiTool.Viewer;
}
}
export const gui_store = new GuiStore();
function string_to_gui_tool(tool: string): GuiTool | undefined {
return STRING_TO_GUI_TOOL.get(tool);
}
function gui_tool_to_string(tool: GuiTool): string {
const str = GUI_TOOL_TO_STRING.get(tool);
if (str) {
return str;
} else {
throw new Error(`To string not implemented for ${(GuiTool as any)[tool]}.`);
}
}

42
src/new/index.css Normal file
View File

@ -0,0 +1,42 @@
:root {
--bg-color: hsl(0, 0%, 20%);
--text-color: hsl(0, 0%, 85%);
--border-color: hsl(0, 0%, 30%);
--scrollbar-color: hsl(0, 0%, 17%);
--scrollbar-thumb-color: hsl(0, 0%, 23%);
}
* {
scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-color);
/* Turn off antd animations by turning all animations off. */
animation-duration: 0s !important;
transition-duration: 0s !important;
}
::-webkit-scrollbar {
background-color: var(--scrollbar-color);
}
::-webkit-scrollbar-track {
background-color: var(--scrollbar-color);
}
::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-color);
}
::-webkit-scrollbar-corner {
background-color: var(--scrollbar-color);
}
body {
cursor: default;
user-select: none;
overflow: hidden;
margin: 0;
font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica,
Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
}

27
src/new/index.ts Normal file
View File

@ -0,0 +1,27 @@
import { ApplicationView } from "./application/gui/ApplicationView";
import { Disposable } from "./core/gui/Disposable";
import "./index.css";
import { throttle } from "lodash";
export function initialize(): Disposable {
const application_view = new ApplicationView();
const resize = throttle(
() => {
application_view.resize(window.innerWidth, window.innerHeight);
},
100,
{ leading: true, trailing: true },
);
resize();
document.body.append(application_view.element);
window.addEventListener("resize", resize);
return {
dispose(): void {
window.removeEventListener("resize", resize);
application_view.dispose();
},
};
}

View File

@ -0,0 +1,3 @@
export class CharacterClassAnimation {
constructor(readonly id: number, readonly name: string) {}
}

View File

@ -0,0 +1,8 @@
export class CharacterClassModel {
constructor(
readonly name: string,
readonly head_style_count: number,
readonly hair_styles_count: number,
readonly hair_styles_with_accessory: Set<number>,
) {}
}

View File

@ -0,0 +1,26 @@
.viewer_ModelView_container {
display: flex;
flex-direction: row;
}
.viewer_ModelSelectListView {
box-sizing: border-box;
list-style: none;
padding: 0;
margin: 0;
overflow: auto;
}
.viewer_ModelSelectListView li {
padding: 4px 8px;
}
.viewer_ModelSelectListView li:hover {
color: hsl(200, 25%, 85%);
background-color: hsl(0, 0%, 25%);
}
.viewer_ModelSelectListView li.active {
color: hsl(200, 50%, 85%);
background-color: hsl(0, 0%, 30%);
}

View File

@ -0,0 +1,123 @@
import { create_el } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
import { ToolBar } from "../../core/gui/ToolBar";
import { Button } from "../../core/gui/Button";
import "./ModelView.css";
import { model_store } from "../stores/ModelStore";
import { Observable } from "../../core/observable/Observable";
import { RendererView } from "../../core/gui/RendererView";
import { ModelRenderer } from "../rendering/ModelRenderer";
const MODEL_LIST_WIDTH = 100;
const ANIMATION_LIST_WIDTH = 150;
export class ModelView extends ResizableView {
element = create_el("div", "viewer_ModelView");
private tool_bar = this.disposable(new ToolBar(new Button("Open file...")));
private container_element = create_el("div", "viewer_ModelView_container");
private model_list_view = this.disposable(
new ModelSelectListView(model_store.models, model_store.current_model),
);
private animation_list_view = this.disposable(
new ModelSelectListView(model_store.animations, model_store.current_animation),
);
private renderer_view = this.disposable(new RendererView(new ModelRenderer()));
constructor() {
super();
this.animation_list_view.borders = true;
this.container_element.append(
this.model_list_view.element,
this.animation_list_view.element,
this.renderer_view.element,
);
this.element.append(this.tool_bar.element, this.container_element);
model_store.current_model.set(model_store.models[5]);
}
resize(width: number, height: number): this {
super.resize(width, height);
const container_height = Math.max(0, height - this.tool_bar.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;
}
}
class ModelSelectListView<T extends { name: string }> extends ResizableView {
element = create_el("ul", "viewer_ModelSelectListView");
set borders(borders: boolean) {
if (borders) {
this.element.style.borderLeft = "solid 1px var(--border-color)";
this.element.style.borderRight = "solid 1px var(--border-color)";
} else {
this.element.style.borderLeft = "none";
this.element.style.borderRight = "none";
}
}
private selected_model?: T;
private selected_element?: HTMLLIElement;
constructor(private models: T[], private selected: Observable<T | undefined>) {
super();
this.element.onclick = this.list_click;
models.forEach((model, index) => {
this.element.append(
create_el("li", undefined, li => {
li.textContent = model.name;
li.dataset["index"] = index.toString();
}),
);
});
this.disposable(
selected.observe(model => {
if (this.selected_element) {
this.selected_element.classList.remove("active");
this.selected_element = undefined;
}
if (model && model !== this.selected_model) {
const index = this.models.indexOf(model);
if (index !== -1) {
this.selected_element = this.element.childNodes[index] as HTMLLIElement;
this.selected_element.classList.add("active");
}
}
}),
);
}
private list_click = (e: MouseEvent) => {
if (e.target instanceof HTMLLIElement && e.target.dataset["index"]) {
if (this.selected_element) {
this.selected_element.classList.remove("active");
}
e.target.classList.add("active");
const index = parseInt(e.target.dataset["index"]!, 10);
this.selected_element = e.target;
this.selected.set(this.models[index]);
}
};
}

View File

@ -0,0 +1,6 @@
import { create_el } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView";
export class TextureView extends ResizableView {
element = create_el("div", "viewer_TextureView", el => (el.textContent = "Texture"));
}

View File

@ -0,0 +1,29 @@
import { TabContainer } from "../../core/gui/TabContainer";
import { ResizableView } from "../../core/gui/ResizableView";
export class ViewerView extends ResizableView {
private tabs = this.disposable(
new TabContainer(
{
title: "Models",
key: "model",
create_view: async () => new (await import("./ModelView")).ModelView(),
},
{
title: "Textures",
key: "texture",
create_view: async () => new (await import("./TextureView")).TextureView(),
},
),
);
get element(): HTMLElement {
return this.tabs.element;
}
resize(width: number, height: number): this {
super.resize(width, height);
this.tabs.resize(width, height);
return this;
}
}

View File

@ -0,0 +1,143 @@
import {
DoubleSide,
Mesh,
MeshLambertMaterial,
Object3D,
PerspectiveCamera,
SkeletonHelper,
Texture,
Vector3,
} from "three";
import { Renderer } from "../../../core/rendering/Renderer";
import { model_store } from "../stores/ModelStore";
import { Disposable } from "../../core/gui/Disposable";
import { create_mesh, create_skinned_mesh } from "../../../core/rendering/conversion/create_mesh";
import { ninja_object_to_buffer_geometry } from "../../../core/rendering/conversion/ninja_geometry";
import { NjObject } from "../../../core/data_formats/parsing/ninja";
export class ModelRenderer extends Renderer implements Disposable {
private nj_object?: NjObject;
private object_3d?: Object3D;
private skeleton_helper?: SkeletonHelper;
private perspective_camera: PerspectiveCamera;
private disposables: Disposable[] = [];
constructor() {
super(new PerspectiveCamera(75, 1, 1, 200));
this.perspective_camera = this.camera as PerspectiveCamera;
this.disposables.push(model_store.current_nj_data.observe(this.update));
}
set_size(width: number, height: number): void {
this.perspective_camera.aspect = width / height;
this.perspective_camera.updateProjectionMatrix();
super.set_size(width, height);
}
dispose(): void {
super.dispose();
this.disposables.forEach(d => d.dispose());
}
protected render(): void {
// if (model_viewer_store.animation) {
// model_viewer_store.animation.mixer.update(model_viewer_store.clock.getDelta());
// model_viewer_store.update_animation_frame();
// }
this.light_holder.quaternion.copy(this.perspective_camera.quaternion);
super.render();
// if (model_viewer_store.animation && !model_viewer_store.animation.action.paused) {
// this.schedule_render();
// }
}
private update = () => {
// TODO:
const textures: Texture[] | undefined = Math.random() > 1 ? [] : undefined;
const nj_data = model_store.current_nj_data.get();
if (nj_data) {
const { nj_object, has_skeleton } = nj_data;
if (nj_object !== this.nj_object) {
this.nj_object = nj_object;
if (nj_object) {
let mesh: Mesh;
const materials =
textures &&
textures.map(
tex =>
new MeshLambertMaterial({
skinning: has_skeleton,
map: tex,
side: DoubleSide,
alphaTest: 0.5,
}),
);
if (has_skeleton) {
mesh = create_skinned_mesh(
ninja_object_to_buffer_geometry(nj_object),
materials,
);
} else {
mesh = create_mesh(ninja_object_to_buffer_geometry(nj_object), materials);
}
// Make sure we rotate around the center of the model instead of its origin.
const bb = mesh.geometry.boundingBox;
const height = bb.max.y - bb.min.y;
mesh.translateY(-height / 2 - bb.min.y);
this.set_object_3d(mesh);
} else {
this.set_object_3d(undefined);
}
}
if (this.skeleton_helper) {
this.skeleton_helper.visible = model_store.show_skeleton.get();
}
// if (model_viewer_store.animation) {
// this.schedule_render();
// }
//
// if (!model_viewer_store.animation_playing) {
// // Reference animation_frame here to make sure we render when the user sets the frame manually.
// model_viewer_store.animation_frame;
// this.schedule_render();
// }
} else {
this.set_object_3d(undefined);
}
this.schedule_render();
};
private set_object_3d(object_3d?: Object3D): void {
if (this.object_3d) {
this.scene.remove(this.object_3d);
this.scene.remove(this.skeleton_helper!);
this.skeleton_helper = undefined;
}
if (object_3d) {
this.scene.add(object_3d);
this.skeleton_helper = new SkeletonHelper(object_3d);
this.skeleton_helper.visible = model_store.show_skeleton.get();
(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));
}
this.object_3d = object_3d;
this.schedule_render();
}
}

View File

@ -0,0 +1,286 @@
import { Clock } from "three";
import { ArrayBufferCursor } from "../../../core/data_formats/cursor/ArrayBufferCursor";
import { Endianness } from "../../../core/data_formats/Endianness";
import { NjMotion } from "../../../core/data_formats/parsing/ninja/motion";
import { NjObject, parse_nj } from "../../../core/data_formats/parsing/ninja";
import { CharacterClassModel } from "../domain/CharacterClassModel";
import { CharacterClassAnimation } from "../domain/CharacterClassAnimation";
import { Observable } from "../../core/observable/Observable";
import { get_player_data } from "../../../viewer/loading/player";
import { Disposable } from "../../core/gui/Disposable";
const nj_object_cache: Map<string, Promise<NjObject>> = new Map();
const nj_motion_cache: Map<number, Promise<NjMotion>> = new Map();
// TODO: move all Three.js stuff into the renderer.
class ModelStore implements Disposable {
readonly models: CharacterClassModel[] = [
new CharacterClassModel("HUmar", 1, 10, new Set([6])),
new CharacterClassModel("HUnewearl", 1, 10, new Set()),
new CharacterClassModel("HUcast", 5, 0, new Set()),
new CharacterClassModel("HUcaseal", 5, 0, new Set()),
new CharacterClassModel("RAmar", 1, 10, new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])),
new CharacterClassModel("RAmarl", 1, 10, new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])),
new CharacterClassModel("RAcast", 5, 0, new Set()),
new CharacterClassModel("RAcaseal", 5, 0, new Set()),
new CharacterClassModel("FOmar", 1, 10, new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])),
new CharacterClassModel("FOmarl", 1, 10, new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])),
new CharacterClassModel("FOnewm", 1, 10, new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])),
new CharacterClassModel("FOnewearl", 1, 10, new Set([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])),
];
readonly animations: CharacterClassAnimation[] = new Array(572)
.fill(undefined)
.map((_, i) => new CharacterClassAnimation(i, `Animation ${i + 1}`));
readonly clock = new Clock();
readonly current_model = new Observable<CharacterClassModel | undefined>(undefined);
readonly current_nj_data = new Observable<
| {
nj_object: NjObject;
bone_count: number;
has_skeleton: boolean;
}
| undefined
>(undefined);
readonly current_animation = new Observable<CharacterClassAnimation | undefined>(undefined);
// @observable.ref animation?: {
// player_animation?: CharacterClassAnimation;
// mixer: AnimationMixer;
// clip: AnimationClip;
// action: AnimationAction;
// };
// @observable animation_playing: boolean = false;
// @observable animation_frame_rate: number = PSO_FRAME_RATE;
// @observable animation_frame: number = 0;
// @observable animation_frame_count: number = 0;
readonly show_skeleton = new Observable(false);
private disposables: Disposable[] = [];
constructor() {
this.disposables.push(this.current_model.observe(this.load_model));
}
dispose(): void {
this.disposables.forEach(d => d.dispose());
}
// set_animation_frame_rate = (rate: number) => {
// if (this.animation) {
// this.animation.mixer.timeScale = rate / PSO_FRAME_RATE;
// this.animation_frame_rate = rate;
// }
// };
//
// set_animation_frame = (frame: number) => {
// if (this.animation) {
// const frame_count = this.animation_frame_count;
// if (frame > frame_count) frame = 1;
// if (frame < 1) frame = frame_count;
// this.animation.action.time = (frame - 1) / PSO_FRAME_RATE;
// this.animation_frame = frame;
// }
// };
// load_animation = async (animation: CharacterClassAnimation) => {
// const nj_motion = await this.get_nj_motion(animation);
// const nj_data = this.current_nj_data.get();
//
// if (nj_data) {
// this.set_animation(create_animation_clip(nj_data, nj_motion), animation);
// }
// };
// TODO: notify user of problems.
// load_file = async (file: File) => {
// try {
// const buffer = await read_file(file);
// const cursor = new ArrayBufferCursor(buffer, Endianness.Little);
//
// if (file.name.endsWith(".nj")) {
// const model = parse_nj(cursor)[0];
// this.set_selected(model, true);
// } else if (file.name.endsWith(".xj")) {
// const model = parse_xj(cursor)[0];
// this.set_selected(model, false);
// } else if (file.name.endsWith(".njm")) {
// if (this.current_model) {
// const njm = parse_njm(cursor, this.current_bone_count);
// this.set_animation(create_animation_clip(this.current_model, njm));
// }
// } else if (file.name.endsWith(".xvm")) {
// if (this.current_model) {
// const xvm = parse_xvm(cursor);
// this.set_textures(xvm_to_textures(xvm));
// }
// } else {
// logger.error(`Unknown file extension in filename "${file.name}".`);
// }
// } catch (e) {
// logger.error("Couldn't read file.", e);
// }
// };
// pause_animation = () => {
// if (this.animation) {
// this.animation.action.paused = true;
// this.animation_playing = false;
// this.clock.stop();
// }
// };
//
// toggle_animation_playing = () => {
// if (this.animation) {
// this.animation.action.paused = !this.animation.action.paused;
// this.animation_playing = !this.animation.action.paused;
//
// if (this.animation_playing) {
// this.clock.start();
// } else {
// this.clock.stop();
// }
// }
// };
// update_animation_frame = () => {
// if (this.animation && this.animation_playing) {
// const time = this.animation.action.time;
// this.animation_frame = Math.round(time * PSO_FRAME_RATE) + 1;
// }
// };
// set_animation = (clip: AnimationClip, animation?: CharacterClassAnimation) => {
// if (!this.current_obj3d || !(this.current_obj3d instanceof SkinnedMesh)) return;
//
// let mixer: AnimationMixer;
//
// if (this.animation) {
// this.animation.mixer.stopAllAction();
// mixer = this.animation.mixer;
// } else {
// mixer = new AnimationMixer(this.current_obj3d);
// }
//
// this.animation = {
// player_animation: animation,
// mixer,
// clip,
// action: mixer.clipAction(clip),
// };
//
// this.clock.start();
// this.animation.action.play();
// this.animation_playing = true;
// this.animation_frame_count = Math.round(PSO_FRAME_RATE * clip.duration) + 1;
// };
private load_model = async (model?: CharacterClassModel) => {
if (model) {
const nj_object = await this.get_player_nj_object(model);
// if (this.current_obj3d && this.animation) {
// this.animation.mixer.stopAllAction();
// this.animation.mixer.uncacheRoot(this.current_obj3d);
// this.animation = undefined;
// }
this.current_nj_data.set({
nj_object,
// Ignore the bones from the head parts.
bone_count: model ? 64 : nj_object.bone_count(),
has_skeleton: true,
});
} else {
this.current_nj_data.set(undefined);
}
};
private async get_player_nj_object(model: CharacterClassModel): Promise<NjObject> {
let nj_object = nj_object_cache.get(model.name);
if (nj_object) {
return nj_object;
} else {
nj_object = this.get_all_assets(model);
nj_object_cache.set(model.name, nj_object);
return nj_object;
}
}
private async get_all_assets(model: CharacterClassModel): Promise<NjObject> {
const body_data = await get_player_data(model.name, "Body");
const body = parse_nj(new ArrayBufferCursor(body_data, Endianness.Little))[0];
if (!body) {
throw new Error(`Couldn't parse body for player class ${model.name}.`);
}
const head_data = await get_player_data(model.name, "Head", 0);
const head = parse_nj(new ArrayBufferCursor(head_data, Endianness.Little))[0];
if (head) {
this.add_to_bone(body, head, 59);
}
if (model.hair_styles_count > 0) {
const hair_data = await get_player_data(model.name, "Hair", 0);
const hair = parse_nj(new ArrayBufferCursor(hair_data, Endianness.Little))[0];
if (hair) {
this.add_to_bone(body, hair, 59);
}
if (model.hair_styles_with_accessory.has(0)) {
const accessory_data = await get_player_data(model.name, "Accessory", 0);
const accessory = parse_nj(
new ArrayBufferCursor(accessory_data, Endianness.Little),
)[0];
if (accessory) {
this.add_to_bone(body, accessory, 59);
}
}
}
return body;
}
private add_to_bone(object: NjObject, head_part: NjObject, bone_id: number): void {
const bone = object.get_bone(bone_id);
if (bone) {
bone.evaluation_flags.hidden = false;
bone.evaluation_flags.break_child_trace = false;
bone.children.push(head_part);
}
}
// private async get_nj_motion(animation: CharacterClassAnimation): Promise<NjMotion> {
// let nj_motion = nj_motion_cache.get(animation.id);
//
// if (nj_motion) {
// return nj_motion;
// } else {
// nj_motion = get_player_animation_data(animation.id).then(motion_data =>
// parse_njm(
// new ArrayBufferCursor(motion_data, Endianness.Little),
// this.current_bone_count,
// ),
// );
//
// nj_motion_cache.set(animation.id, nj_motion);
// return nj_motion;
// }
// }
//
// private set_textures = (textures: Texture[]) => {
// this.set_obj3d(textures);
// };
}
export const model_store = new ModelStore();

View File

@ -1,13 +1,11 @@
import { autorun } from "mobx";
import { Mesh, Object3D, PerspectiveCamera, Group } from "three";
import { Group, Mesh, Object3D, PerspectiveCamera } from "three";
import { quest_editor_store } from "../stores/QuestEditorStore";
import { QuestEntityControls } from "./QuestEntityControls";
import { QuestModelManager } from "./QuestModelManager";
import { Renderer } from "../../core/rendering/Renderer";
import { EntityUserData } from "./conversion/entities";
import { ObservableQuestEntity } from "../domain/observable_quest_entities";
import { DND_OBJECT_TYPE } from "../ui/UiConstants";
import { DragEvent } from "react";
let renderer: QuestRenderer | undefined;
@ -16,7 +14,7 @@ export function get_quest_renderer(): QuestRenderer {
return renderer;
}
export class QuestRenderer extends Renderer<PerspectiveCamera> {
export class QuestRenderer extends Renderer {
get debug(): boolean {
return this._debug;
}
@ -60,12 +58,15 @@ export class QuestRenderer extends Renderer<PerspectiveCamera> {
return this._entity_models;
}
private perspective_camera: PerspectiveCamera;
private entity_to_mesh = new Map<ObservableQuestEntity, Mesh>();
private entity_controls: QuestEntityControls;
constructor() {
super(new PerspectiveCamera(60, 1, 10, 10000));
this.perspective_camera = this.camera as PerspectiveCamera;
const model_manager = new QuestModelManager(this);
autorun(
@ -86,8 +87,8 @@ export class QuestRenderer extends Renderer<PerspectiveCamera> {
}
set_size(width: number, height: number): void {
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.perspective_camera.aspect = width / height;
this.perspective_camera.updateProjectionMatrix();
super.set_size(width, height);
}

View File

@ -15,5 +15,5 @@ export async function get_player_animation_data(animation_id: number): Promise<A
}
function player_class_to_url(player_class: string, body_part: string, no?: number): string {
return `/player/${player_class}${body_part}${no == null ? "" : no}.nj`;
return `/player/${player_class}${body_part}${no == undefined ? "" : no}.nj`;
}

View File

@ -10,13 +10,16 @@ export function get_model_renderer(): ModelRenderer {
return renderer;
}
export class ModelRenderer extends Renderer<PerspectiveCamera> {
export class ModelRenderer extends Renderer {
private model?: Object3D;
private skeleton_helper?: SkeletonHelper;
private perspective_camera: PerspectiveCamera;
constructor() {
super(new PerspectiveCamera(75, 1, 1, 200));
this.perspective_camera = this.camera as PerspectiveCamera;
autorun(() => {
this.set_model(model_viewer_store.current_obj3d);
@ -40,8 +43,8 @@ export class ModelRenderer extends Renderer<PerspectiveCamera> {
}
set_size(width: number, height: number): void {
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.perspective_camera.aspect = width / height;
this.perspective_camera.updateProjectionMatrix();
super.set_size(width, height);
}
@ -51,7 +54,7 @@ export class ModelRenderer extends Renderer<PerspectiveCamera> {
model_viewer_store.update_animation_frame();
}
this.light_holder.quaternion.copy(this.camera.quaternion);
this.light_holder.quaternion.copy(this.perspective_camera.quaternion);
super.render();
if (model_viewer_store.animation && !model_viewer_store.animation.action.paused) {

View File

@ -23,12 +23,15 @@ export function get_texture_renderer(): TextureRenderer {
return renderer;
}
export class TextureRenderer extends Renderer<OrthographicCamera> {
export class TextureRenderer extends Renderer {
private ortho_camera: OrthographicCamera;
private quad_meshes: Mesh[] = [];
constructor() {
super(new OrthographicCamera(-400, 400, 300, -300, 1, 10));
this.ortho_camera = this.camera as OrthographicCamera;
this.controls.azimuthRotateSpeed = 0;
this.controls.polarRotateSpeed = 0;
@ -47,11 +50,11 @@ export class TextureRenderer extends Renderer<OrthographicCamera> {
}
set_size(width: number, height: number): void {
this.camera.left = -Math.floor(width / 2);
this.camera.right = Math.ceil(width / 2);
this.camera.top = Math.floor(height / 2);
this.camera.bottom = -Math.ceil(height / 2);
this.camera.updateProjectionMatrix();
this.ortho_camera.left = -Math.floor(width / 2);
this.ortho_camera.right = Math.ceil(width / 2);
this.ortho_camera.top = Math.floor(height / 2);
this.ortho_camera.bottom = -Math.ceil(height / 2);
this.ortho_camera.updateProjectionMatrix();
super.set_size(width, height);
}

View File

@ -2,7 +2,7 @@
"compilerOptions": {
"outDir": "./dist/",
"sourceMap": true,
"module": "es6",
"module": "commonjs",
"target": "es6",
"lib": ["es6", "dom", "dom.iterable"],
"allowJs": true,

View File

@ -38,9 +38,9 @@ module.exports = merge(common, {
loader: "css-loader",
options: {
sourceMap: true,
modules: {
localIdentName: "[path][name]__[local]",
},
// modules: {
// localIdentName: "[path][name]__[local]",
// },
},
},
],