From 8580cd4f66f17451a93f796d16b9111d051fc249 Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Mon, 6 Jan 2020 21:09:44 +0100 Subject: [PATCH] The model viewer now shows a problems popup when loading a file failed or succeeded with some problems. --- assets_generation/update_ephinea_data.ts | 13 ++- assets_generation/update_generic_data.ts | 5 +- src/application/index.test.ts | 7 +- src/core/Logger.ts | 67 ++++-------- src/core/Result.ts | 87 +++++++++++++++ src/core/Severity.ts | 20 ++++ src/core/data_formats/parsing/iff.ts | 66 +++++++++-- src/core/data_formats/parsing/ninja/index.ts | 17 ++- .../data_formats/parsing/ninja/texture.ts | 40 ++++++- src/core/gui/Button.ts | 39 ++----- src/core/gui/DropDown.ts | 4 +- src/core/gui/FileButton.ts | 2 +- src/core/gui/ProblemsPopup.css | 42 +++++++ src/core/gui/ProblemsPopup.ts | 103 ++++++++++++++++++ src/core/gui/Select.ts | 4 +- src/core/gui/dom.ts | 11 ++ src/core/gui/index.css | 2 + src/hunt_optimizer/gui/WantedItemsView.ts | 2 +- src/quest_editor/gui/EventView.ts | 2 +- src/quest_editor/gui/EventsView.ts | 2 +- src/quest_editor/gui/LogView.ts | 19 ++-- src/quest_editor/gui/QuestEditorToolBar.ts | 18 +-- src/quest_editor/loading/EntityAssetLoader.ts | 6 +- src/quest_editor/stores/LogStore.ts | 19 ++-- src/viewer/controllers/TextureController.ts | 13 ++- .../model/ModelToolBarController.ts | 71 ++++++++++-- .../loading/CharacterClassAssetLoader.ts | 7 +- 27 files changed, 526 insertions(+), 162 deletions(-) create mode 100644 src/core/Result.ts create mode 100644 src/core/Severity.ts create mode 100644 src/core/gui/ProblemsPopup.css create mode 100644 src/core/gui/ProblemsPopup.ts diff --git a/assets_generation/update_ephinea_data.ts b/assets_generation/update_ephinea_data.ts index c639f040..316e3b64 100644 --- a/assets_generation/update_ephinea_data.ts +++ b/assets_generation/update_ephinea_data.ts @@ -12,15 +12,16 @@ import { Endianness } from "../src/core/data_formats/Endianness"; import { ItemTypeDto } from "../src/core/dto/ItemTypeDto"; import { QuestDto } from "../src/hunt_optimizer/dto/QuestDto"; import { BoxDropDto, EnemyDropDto } from "../src/hunt_optimizer/dto/drops"; -import { LogLevel, LogManager } from "../src/core/Logger"; +import { LogManager } from "../src/core/Logger"; +import { Severity } from "../src/core/Severity"; const logger = LogManager.get("assets_generation/update_ephinea_data"); -LogManager.default_level = LogLevel.Error; -logger.level = LogLevel.Info; -LogManager.get("static/update_drops_ephinea").level = LogLevel.Info; -LogManager.get("core/data_formats/parsing/quest").level = LogLevel.Off; -LogManager.get("core/data_formats/parsing/quest/bin").level = LogLevel.Off; +LogManager.default_severity = Severity.Error; +logger.severity = Severity.Info; +LogManager.get("static/update_drops_ephinea").severity = Severity.Info; +LogManager.get("core/data_formats/parsing/quest").severity = Severity.Off; +LogManager.get("core/data_formats/parsing/quest/bin").severity = Severity.Off; /** * Used by static data generation scripts. diff --git a/assets_generation/update_generic_data.ts b/assets_generation/update_generic_data.ts index 3bf48314..7992bf8c 100644 --- a/assets_generation/update_generic_data.ts +++ b/assets_generation/update_generic_data.ts @@ -4,11 +4,12 @@ import { BufferCursor } from "../src/core/data_formats/cursor/BufferCursor"; import { parse_rlc } from "../src/core/data_formats/parsing/rlc"; import * as yaml from "yaml"; import { Endianness } from "../src/core/data_formats/Endianness"; -import { LogLevel, LogManager } from "../src/core/Logger"; +import { LogManager } from "../src/core/Logger"; +import { Severity } from "../src/core/Severity"; const logger = LogManager.get("assets_generation/update_generic_data"); -LogManager.default_level = LogLevel.Trace; +LogManager.default_severity = Severity.Trace; const OPCODES_YML_FILE = `${RESOURCE_DIR}/asm/opcodes.yml`; const OPCODES_SRC_FILE = `${SRC_DIR}/core/data_formats/asm/opcodes.ts`; diff --git a/src/application/index.test.ts b/src/application/index.test.ts index 37b30c05..eeaf7047 100644 --- a/src/application/index.test.ts +++ b/src/application/index.test.ts @@ -1,9 +1,10 @@ import { initialize_application } from "./index"; -import { LogHandler, LogLevel, LogManager } from "../core/Logger"; +import { LogHandler, 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"; +import { Severity } from "../core/Severity"; for (const path of [undefined, "/viewer", "/quest_editor", "/hunt_optimizer"]) { const with_path = path == undefined ? "without specific path" : `with path ${path}`; @@ -11,8 +12,8 @@ for (const path of [undefined, "/viewer", "/quest_editor", "/hunt_optimizer"]) { test(`Initialization and shutdown ${with_path} should succeed without throwing or logging errors.`, async () => { const logged_errors: string[] = []; - const handler: LogHandler = ({ level, message }) => { - if (level >= LogLevel.Error) { + const handler: LogHandler = ({ severity, message }) => { + if (severity >= Severity.Error) { logged_errors.push(message); } }; diff --git a/src/core/Logger.ts b/src/core/Logger.ts index 85b47c70..54836341 100644 --- a/src/core/Logger.ts +++ b/src/core/Logger.ts @@ -1,54 +1,35 @@ -import { enum_values } from "./enums"; -import { assert } from "./util"; - -// Log level names in order of importance. -export enum LogLevel { - Trace, - Debug, - Info, - Warn, - Error, - Off, -} - -export const LogLevels = enum_values(LogLevel); - -export function log_level_from_string(str: string): LogLevel { - const level = (LogLevel as any)[str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()]; - assert(level != undefined, () => `"${str}" is not a valid log level.`); - return level; -} +import { Severity, severity_from_string } from "./Severity"; export type LogEntry = { readonly time: Date; readonly message: string; - readonly level: LogLevel; + readonly severity: Severity; readonly logger: Logger; readonly cause?: any; }; export type LogHandler = (entry: LogEntry, logger_name: string) => void; -function default_log_handler({ time, message, level, logger, cause }: LogEntry): void { - const str = `${time_to_string(time)} [${LogLevel[level]}] ${logger.name} - ${message}`; +function default_log_handler({ time, message, severity, logger, cause }: LogEntry): void { + const str = `${time_to_string(time)} [${Severity[severity]}] ${logger.name} - ${message}`; /* eslint-disable no-console */ let method: (...args: any[]) => void; - switch (level) { - case LogLevel.Trace: + switch (severity) { + case Severity.Trace: method = console.trace; break; - case LogLevel.Debug: + case Severity.Debug: method = console.debug; break; - case LogLevel.Info: + case Severity.Info: method = console.info; break; - case LogLevel.Warn: + case Severity.Warn: method = console.warn; break; - case LogLevel.Error: + case Severity.Error: method = console.error; break; default: @@ -76,14 +57,14 @@ function time_part_to_string(value: number, n: number): string { } export class Logger { - private _level?: LogLevel; + private _severity?: Severity; - get level(): LogLevel { - return this._level ?? LogManager.default_level; + get severity(): Severity { + return this._severity ?? LogManager.default_severity; } - set level(level: LogLevel) { - this._level = level; + set severity(severity: Severity) { + this._severity = severity; } private _handler?: LogHandler; @@ -99,28 +80,28 @@ export class Logger { constructor(readonly name: string) {} trace = (message: string, cause?: any): void => { - this.handle(LogLevel.Trace, message, cause); + this.log(Severity.Trace, message, cause); }; debug = (message: string, cause?: any): void => { - this.handle(LogLevel.Debug, message, cause); + this.log(Severity.Debug, message, cause); }; info = (message: string, cause?: any): void => { - this.handle(LogLevel.Info, message, cause); + this.log(Severity.Info, message, cause); }; warn = (message: string, cause?: any): void => { - this.handle(LogLevel.Warn, message, cause); + this.log(Severity.Warn, message, cause); }; error = (message: string, cause?: any): void => { - this.handle(LogLevel.Error, message, cause); + this.log(Severity.Error, message, cause); }; - private handle(level: LogLevel, message: string, cause?: any): void { - if (level >= this.level) { - this.handler({ time: new Date(), message, level, logger: this, cause }, this.name); + log(severity: Severity, message: string, cause?: any): void { + if (severity >= this.severity) { + this.handler({ time: new Date(), message, severity, logger: this, cause }, this.name); } } } @@ -128,7 +109,7 @@ export class Logger { export class LogManager { private static readonly loggers = new Map(); - static default_level: LogLevel = log_level_from_string(process.env["LOG_LEVEL"] ?? "Info"); + static default_severity: Severity = severity_from_string(process.env["LOG_LEVEL"] ?? "Info"); static default_handler: LogHandler = default_log_handler; static get(name: string): Logger { diff --git a/src/core/Result.ts b/src/core/Result.ts new file mode 100644 index 00000000..afe55a67 --- /dev/null +++ b/src/core/Result.ts @@ -0,0 +1,87 @@ +import { Logger } from "./Logger"; +import { Severity } from "./Severity"; + +export type Result = Success | Failure; + +export type Success = { + readonly success: true; + readonly value: T; + readonly problems: readonly Problem[]; +}; + +export type Failure = { + readonly success: false; + readonly value?: undefined; + readonly problems: readonly Problem[]; +}; + +export type Problem = { + readonly severity: Severity; + readonly ui_message: string; +}; + +export function success(value: T, problems?: readonly Problem[]): Success { + return { + success: true, + value, + problems: problems ?? [], + }; +} + +export function failure(problems?: readonly Problem[]): Failure { + return { + success: false, + problems: problems ?? [], + }; +} + +/** + * "Unwraps" the given result by either return its value if it's a success or throwing an error with + * its problems as message if it was a failure. + */ +export function unwrap(result: Result): T { + if (result.success) { + return result.value; + } else { + throw new Error(result.problems.join("\n")); + } +} + +export function result_builder(logger: Logger): ResultBuilder { + return new ResultBuilder(logger); +} + +/** + * Useful for building up a {@link Result} and logging problems at the same time. Use + * {@link result_builder} to instantiate. + */ +export class ResultBuilder { + private readonly problems: Problem[] = []; + + constructor(private readonly logger: Logger) {} + + /** + * Add a problem to the problems array and log it with {@link logger}. + */ + add_problem(severity: Severity, ui_message: string, message: string, cause?: any): this { + this.logger.log(severity, message, cause); + this.problems.push({ severity, ui_message }); + return this; + } + + /** + * Add the given result's problems. + */ + add_result(result: Result): this { + this.problems.push(...result.problems); + return this; + } + + success(value: T): Success { + return success(value, this.problems); + } + + failure(): Failure { + return failure(this.problems); + } +} diff --git a/src/core/Severity.ts b/src/core/Severity.ts new file mode 100644 index 00000000..5d013fd4 --- /dev/null +++ b/src/core/Severity.ts @@ -0,0 +1,20 @@ +// Severities in order of importance. +import { enum_values } from "./enums"; +import { assert } from "./util"; + +export enum Severity { + Trace, + Debug, + Info, + Warn, + Error, + Off, +} + +export const Severities = enum_values(Severity); + +export function severity_from_string(str: string): Severity { + const severity = (Severity as any)[str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase()]; + assert(severity != undefined, () => `"${str}" is not a valid severity.`); + return severity; +} diff --git a/src/core/data_formats/parsing/iff.ts b/src/core/data_formats/parsing/iff.ts index 507ead40..d09d1308 100644 --- a/src/core/data_formats/parsing/iff.ts +++ b/src/core/data_formats/parsing/iff.ts @@ -1,11 +1,24 @@ import { Cursor } from "../cursor/Cursor"; +import { Result, result_builder } from "../../Result"; +import { LogManager } from "../../Logger"; +import { Severity } from "../../Severity"; + +const logger = LogManager.get("core/data_formats/parsing/iff"); export type IffChunk = { /** * 32-bit unsigned integer. */ - type: number; - data: Cursor; + readonly type: number; + readonly data: Cursor; +}; + +export type IffChunkHeader = { + /** + * 32-bit unsigned integer. + */ + readonly type: number; + readonly size: number; }; /** @@ -13,22 +26,55 @@ export type IffChunk = { * IFF files contain chunks preceded by an 8-byte header. * The header consists of 4 ASCII characters for the "Type ID" and a 32-bit integer specifying the chunk size. */ -export function parse_iff(cursor: Cursor): IffChunk[] { - const chunks: IffChunk[] = []; +export function parse_iff(cursor: Cursor, silent = false): Result { + return parse(cursor, silent, [], (cursor, type, size) => { + return { type, data: cursor.take(size) }; + }); +} - while (cursor.bytes_left) { +/** + * Parses just the chunk headers. + */ +export function parse_iff_headers(cursor: Cursor, silent = false): Result { + return parse(cursor, silent, [], (_, type, size) => { + return { type, size }; + }); +} + +function parse( + cursor: Cursor, + silent: boolean, + chunks: T[], + get_chunk: (cursor: Cursor, type: number, size: number) => T, +): Result { + const result = result_builder(logger); + let corrupted = false; + + while (cursor.bytes_left >= 8) { const type = cursor.u32(); + const size_pos = cursor.position; const size = cursor.u32(); if (size > cursor.bytes_left) { + corrupted = true; + + if (!silent) { + result.add_problem( + chunks.length === 0 ? Severity.Error : Severity.Warn, + "Invalid IFF format.", + `Size ${size} was too large (only ${cursor.bytes_left} bytes left) at position ${size_pos}.`, + ); + } + break; } - chunks.push({ - type, - data: cursor.take(size), - }); + chunks.push(get_chunk(cursor, type, size)); } - return chunks; + if (corrupted && chunks.length === 0) { + return result.failure(); + } else { + return result.success(chunks); + } } diff --git a/src/core/data_formats/parsing/ninja/index.ts b/src/core/data_formats/parsing/ninja/index.ts index ce30dd12..696a8f64 100644 --- a/src/core/data_formats/parsing/ninja/index.ts +++ b/src/core/data_formats/parsing/ninja/index.ts @@ -3,6 +3,7 @@ import { Vec3 } from "../../vector"; import { parse_iff } from "../iff"; import { NjcmModel, parse_njcm_model } from "./njcm"; import { parse_xj_model, XjModel } from "./xj"; +import { Result, success } from "../../../Result"; export const ANGLE_TO_RAD = (2 * Math.PI) / 0xffff; @@ -113,14 +114,14 @@ export type NjEvaluationFlags = { /** * Parses an NJCM file. */ -export function parse_nj(cursor: Cursor): NjObject[] { +export function parse_nj(cursor: Cursor): Result[]> { return parse_ninja(cursor, parse_njcm_model, []); } /** * Parses an NJCM file. */ -export function parse_xj(cursor: Cursor): NjObject[] { +export function parse_xj(cursor: Cursor): Result[]> { return parse_ninja(cursor, parse_xj_model, undefined); } @@ -135,16 +136,22 @@ function parse_ninja( cursor: Cursor, parse_model: (cursor: Cursor, context: any) => M, context: any, -): NjObject[] { +): Result[]> { + const parse_iff_result = parse_iff(cursor); + + if (!parse_iff_result.success) { + return parse_iff_result; + } + // POF0 and other chunks types are ignored. - const njcm_chunks = parse_iff(cursor).filter(chunk => chunk.type === NJCM); + const njcm_chunks = parse_iff_result.value.filter(chunk => chunk.type === NJCM); const objects: NjObject[] = []; for (const chunk of njcm_chunks) { objects.push(...parse_sibling_objects(chunk.data, parse_model, context)); } - return objects; + return success(objects, parse_iff_result.problems); } // TODO: cache model and object offsets so we don't reparse the same data. diff --git a/src/core/data_formats/parsing/ninja/texture.ts b/src/core/data_formats/parsing/ninja/texture.ts index f2b32c0e..bcad5838 100644 --- a/src/core/data_formats/parsing/ninja/texture.ts +++ b/src/core/data_formats/parsing/ninja/texture.ts @@ -1,6 +1,8 @@ import { Cursor } from "../../cursor/Cursor"; -import { parse_iff } from "../iff"; +import { parse_iff, parse_iff_headers } from "../iff"; import { LogManager } from "../../../Logger"; +import { Result, result_builder } from "../../../Result"; +import { Severity } from "../../../Severity"; const logger = LogManager.get("core/data_formats/parsing/ninja/texture"); @@ -43,8 +45,26 @@ export function parse_xvr(cursor: Cursor): XvrTexture { }; } -export function parse_xvm(cursor: Cursor): Xvm | undefined { - const chunks = parse_iff(cursor); +export function is_xvm(cursor: Cursor): boolean { + const iff_result = parse_iff_headers(cursor, true); + + if (!iff_result.success) { + return false; + } + + return iff_result.value.find(chunk => chunk.type === XVMH || chunk.type === XVRT) != undefined; +} + +export function parse_xvm(cursor: Cursor): Result { + const iff_result = parse_iff(cursor); + + if (!iff_result.success) { + return iff_result; + } + + const result = result_builder(logger); + result.add_result(iff_result); + const chunks = iff_result.value; const header_chunk = chunks.find(chunk => chunk.type === XVMH); const header = header_chunk && parse_header(header_chunk.data); @@ -53,16 +73,24 @@ export function parse_xvm(cursor: Cursor): Xvm | undefined { .map(chunk => parse_xvr(chunk.data)); if (!header && textures.length === 0) { - return undefined; + result.add_problem( + Severity.Error, + "Corrupted XVM file.", + "No header and no XVRT chunks found.", + ); + + return result.failure(); } if (header && header.texture_count !== textures.length) { - logger.warn( + result.add_problem( + Severity.Warn, + "Corrupted XVM file.", `Found ${textures.length} textures instead of ${header.texture_count} as defined in the header.`, ); } - return { textures }; + return result.success({ textures }); } function parse_header(cursor: Cursor): Header { diff --git a/src/core/gui/Button.ts b/src/core/gui/Button.ts index b1c55e98..5f0ef563 100644 --- a/src/core/gui/Button.ts +++ b/src/core/gui/Button.ts @@ -3,7 +3,6 @@ import "./Button.css"; import { Observable } from "../observable/Observable"; import { emitter } from "../observable"; import { Control } from "./Control"; -import { Emitter } from "../observable/Emitter"; import { WidgetOptions } from "./Widget"; import { Property } from "../observable/property/Property"; import { WritableProperty } from "../observable/property/WritableProperty"; @@ -16,20 +15,16 @@ export type ButtonOptions = WidgetOptions & { }; export class Button extends Control { - private readonly _mousedown: Emitter; - private readonly _mouseup: Emitter; - private readonly _click: Emitter; - private readonly _keydown: Emitter; - private readonly _keyup: Emitter; + private readonly _onmouseup = emitter(); + private readonly _onclick = emitter(); + private readonly _onkeydown = emitter(); private readonly _text: WidgetProperty; private readonly center_element: HTMLSpanElement; readonly element = button({ className: "core_Button" }); - readonly mousedown: Observable; - readonly mouseup: Observable; - readonly click: Observable; - readonly keydown: Observable; - readonly keyup: Observable; + readonly onmouseup: Observable = this._onmouseup; + readonly onclick: Observable = this._onclick; + readonly onkeydown: Observable = this._onkeydown; readonly text: WritableProperty; constructor(options?: ButtonOptions) { @@ -50,25 +45,9 @@ export class Button extends Control { ); } - this._mousedown = emitter(); - this.mousedown = this._mousedown; - this.element.onmousedown = (e: MouseEvent) => this._mousedown.emit({ value: e }); - - this._mouseup = emitter(); - this.mouseup = this._mouseup; - this.element.onmouseup = (e: MouseEvent) => this._mouseup.emit({ value: e }); - - this._click = emitter(); - this.click = this._click; - this.element.onclick = (e: MouseEvent) => this._click.emit({ value: e }); - - this._keydown = emitter(); - this.keydown = this._keydown; - this.element.onkeydown = (e: KeyboardEvent) => this._keydown.emit({ value: e }); - - this._keyup = emitter(); - this.keyup = this._keyup; - this.element.onkeyup = (e: KeyboardEvent) => this._keyup.emit({ value: e }); + this.element.onmouseup = (e: MouseEvent) => this._onmouseup.emit({ value: e }); + this.element.onclick = (e: MouseEvent) => this._onclick.emit({ value: e }); + this.element.onkeydown = (e: KeyboardEvent) => this._onkeydown.emit({ value: e }); this._text = new WidgetProperty(this, "", this.set_text); this.text = this._text; diff --git a/src/core/gui/DropDown.ts b/src/core/gui/DropDown.ts index c9f5e476..19f0b84f 100644 --- a/src/core/gui/DropDown.ts +++ b/src/core/gui/DropDown.ts @@ -53,9 +53,9 @@ export class DropDown extends Control { capture: true, }), - this.button.mouseup.observe(this.button_mouseup), + this.button.onmouseup.observe(this.button_mouseup), - this.button.keydown.observe(this.button_keydown), + this.button.onkeydown.observe(this.button_keydown), this.menu.selected.observe(({ value }) => { if (value !== undefined) { diff --git a/src/core/gui/FileButton.ts b/src/core/gui/FileButton.ts index 75b7c8b8..24587214 100644 --- a/src/core/gui/FileButton.ts +++ b/src/core/gui/FileButton.ts @@ -20,7 +20,7 @@ export class FileButton extends Button { this.element.classList.add("core_FileButton"); this.disposables( - this.click.observe(async () => { + this.onclick.observe(async () => { this._files.val = await open_files(options); }), ); diff --git a/src/core/gui/ProblemsPopup.css b/src/core/gui/ProblemsPopup.css new file mode 100644 index 00000000..af5f36fc --- /dev/null +++ b/src/core/gui/ProblemsPopup.css @@ -0,0 +1,42 @@ +.core_ProblemsPopup { + display: flex; + flex-direction: column; + outline: none; + position: fixed; + background-color: var(--bg-color); + border: var(--border); + padding: 10px; + box-shadow: black 0 0 10px -2px; +} + +.core_ProblemsPopup:focus-within { + border: var(--border-focus); +} + +.core_ProblemsPopup h1 { + font-size: 20px; + margin: 0 0 10px 0; + padding-bottom: 4px; + border-bottom: var(--border); +} + +.core_ProblemsPopup_description { + user-select: text; + cursor: text; +} + +.core_ProblemsPopup_body { + user-select: text; + overflow: auto; + margin: 4px 0; +} + +.core_ProblemsPopup_body ul { + cursor: text; +} + +.core_ProblemsPopup_footer { + display: flex; + flex-direction: row; + justify-content: flex-end; +} diff --git a/src/core/gui/ProblemsPopup.ts b/src/core/gui/ProblemsPopup.ts new file mode 100644 index 00000000..0f7de692 --- /dev/null +++ b/src/core/gui/ProblemsPopup.ts @@ -0,0 +1,103 @@ +import { ResizableWidget } from "./ResizableWidget"; +import { Widget } from "./Widget"; +import { div, h1, li, section, ul } from "./dom"; +import { Problem } from "../Result"; +import { Button } from "./Button"; +import { Disposable } from "../observable/Disposable"; +import "./ProblemsPopup.css"; + +const POPUP_WIDTH = 500; +const POPUP_HEIGHT = 500; + +export class ProblemsPopup extends ResizableWidget { + private x = 0; + private y = 0; + private prev_mouse_x = 0; + private prev_mouse_y = 0; + + readonly element: HTMLElement; + readonly children: readonly Widget[] = []; + readonly dismiss_button = this.disposable(new Button({ text: "Dismiss" })); + + constructor(description: string, problems: readonly Problem[] = []) { + super(); + + let header_element: HTMLElement; + + this.element = section( + { className: "core_ProblemsPopup", tabIndex: 0 }, + (header_element = h1("Problems")), + div({ className: "core_ProblemsPopup_description" }, description), + div( + { className: "core_ProblemsPopup_body" }, + ul(...problems.map(problem => li(problem.ui_message))), + ), + div({ className: "core_ProblemsPopup_footer" }, this.dismiss_button.element), + ); + + this.element.style.width = `${POPUP_WIDTH}px`; + this.element.style.maxHeight = `${POPUP_HEIGHT}px`; + + this.set_position( + (window.innerWidth - POPUP_WIDTH) / 2, + (window.innerHeight - POPUP_HEIGHT) / 2, + ); + + this.element.addEventListener("keydown", this.keydown); + header_element.addEventListener("mousedown", this.mousedown); + + this.finalize_construction(); + } + + set_position(x: number, y: number): void { + this.x = x; + this.y = y; + this.element.style.transform = `translate(${Math.floor(x)}px, ${Math.floor(y)}px)`; + } + + private mousedown = (evt: MouseEvent): void => { + this.prev_mouse_x = evt.clientX; + this.prev_mouse_y = evt.clientY; + window.addEventListener("mousemove", this.window_mousemove); + window.addEventListener("mouseup", this.window_mouseup); + }; + + private window_mousemove = (evt: MouseEvent): void => { + evt.preventDefault(); + this.set_position( + this.x + evt.clientX - this.prev_mouse_x, + this.y + evt.clientY - this.prev_mouse_y, + ); + this.prev_mouse_x = evt.clientX; + this.prev_mouse_y = evt.clientY; + }; + + private window_mouseup = (evt: MouseEvent): void => { + evt.preventDefault(); + window.removeEventListener("mousemove", this.window_mousemove); + window.removeEventListener("mouseup", this.window_mouseup); + }; + + private keydown = (evt: KeyboardEvent): void => { + if (evt.key === "Escape") { + this.dispose(); + } + }; +} + +export function show_problems_popup( + description: string, + problems?: readonly Problem[], +): Disposable { + const popup = new ProblemsPopup(description, problems); + const onclick = popup.dismiss_button.onclick.observe(() => popup.dispose()); + document.body.append(popup.element); + popup.focus(); + + return { + dispose() { + onclick.dispose(); + popup.dispose(); + }, + }; +} diff --git a/src/core/gui/Select.ts b/src/core/gui/Select.ts index 4b5ea923..c14866f9 100644 --- a/src/core/gui/Select.ts +++ b/src/core/gui/Select.ts @@ -58,9 +58,9 @@ export class Select extends LabelledControl { this.disposables( disposable_listener(this.button.element, "mousedown", this.button_mousedown), - this.button.mouseup.observe(this.button_mouseup), + this.button.onmouseup.observe(this.button_mouseup), - this.button.keydown.observe(this.button_keydown), + this.button.onkeydown.observe(this.button_keydown), this.menu.selected.observe(({ value }) => { this._selected.set_val(value, { silent: false }); diff --git a/src/core/gui/dom.ts b/src/core/gui/dom.ts index c711091f..a8abe62b 100644 --- a/src/core/gui/dom.ts +++ b/src/core/gui/dom.ts @@ -42,6 +42,13 @@ export function div(attributes?: Attributes, ...children: Child[ return create_element("div", attributes, ...children); } +export function h1( + attributes?: Attributes, + ...children: Child[] +): HTMLHeadingElement { + return create_element("h1", attributes, ...children); +} + export function h2( attributes?: Attributes, ...children: Child[] @@ -81,6 +88,10 @@ export function p( return create_element("p", attributes, ...children); } +export function section(attributes?: Attributes, ...children: Child[]): HTMLElement { + return create_element("section", attributes, ...children); +} + export function span( attributes?: Attributes, ...children: Child[] diff --git a/src/core/gui/index.css b/src/core/gui/index.css index 98707278..378de9c8 100644 --- a/src/core/gui/index.css +++ b/src/core/gui/index.css @@ -6,7 +6,9 @@ --text-color-disabled: hsl(0, 0%, 55%); --font-family: Verdana, Geneva, sans-serif; --border-color: hsl(0, 0%, 25%); + --border-color-focus: hsl(0, 0%, 35%); --border: solid 1px var(--border-color); + --border-focus: solid 1px var(--border-color-focus); /* Scrollbars */ diff --git a/src/hunt_optimizer/gui/WantedItemsView.ts b/src/hunt_optimizer/gui/WantedItemsView.ts index ab068042..86cd7926 100644 --- a/src/hunt_optimizer/gui/WantedItemsView.ts +++ b/src/hunt_optimizer/gui/WantedItemsView.ts @@ -102,7 +102,7 @@ export class WantedItemsView extends View { const remove_button = row_disposer.add(new Button({ icon_left: Icon.Remove })); row_disposer.add( - remove_button.click.observe(async () => + remove_button.onclick.observe(async () => (await this.hunt_optimizer_stores.current.val).remove_wanted_item(wanted_item), ), ); diff --git a/src/quest_editor/gui/EventView.ts b/src/quest_editor/gui/EventView.ts index ef23d65b..54679f44 100644 --- a/src/quest_editor/gui/EventView.ts +++ b/src/quest_editor/gui/EventView.ts @@ -151,7 +151,7 @@ export class EventView extends View { const remove_button = disposer.add(new Button({ icon_left: Icon.Remove })); disposer.add_all( - remove_button.click.observe(() => this.ctrl.remove_action(this.event, action)), + remove_button.onclick.observe(() => this.ctrl.remove_action(this.event, action)), ); return [tr(th(label), td(node), td(remove_button.element)), disposer]; diff --git a/src/quest_editor/gui/EventsView.ts b/src/quest_editor/gui/EventsView.ts index 6fcf8bcf..3ff0f9cb 100644 --- a/src/quest_editor/gui/EventsView.ts +++ b/src/quest_editor/gui/EventsView.ts @@ -52,7 +52,7 @@ export class EventsView extends ResizableView { this.enabled.bind_to(ctrl.enabled), - this.add_event_button.click.observe(ctrl.add_event), + this.add_event_button.onclick.observe(ctrl.add_event), bind_children_to( this.dag_container_element, diff --git a/src/quest_editor/gui/LogView.ts b/src/quest_editor/gui/LogView.ts index 0ab0da2c..681e4a07 100644 --- a/src/quest_editor/gui/LogView.ts +++ b/src/quest_editor/gui/LogView.ts @@ -3,8 +3,9 @@ import { ToolBar } from "../../core/gui/ToolBar"; import "./LogView.css"; import { log_store } from "../stores/LogStore"; import { Select } from "../../core/gui/Select"; -import { LogEntry, LogLevel, LogLevels, time_to_string } from "../../core/Logger"; +import { LogEntry, time_to_string } from "../../core/Logger"; import { ResizableView } from "../../core/gui/ResizableView"; +import { Severities, Severity } from "../../core/Severity"; const AUTOSCROLL_TRESHOLD = 5; @@ -15,7 +16,7 @@ export class LogView extends ResizableView { private readonly list_container: HTMLElement; private readonly list_element: HTMLElement; - private readonly level_filter: Select; + private readonly level_filter: Select; private readonly settings_bar: ToolBar; private should_scroll_to_bottom = true; @@ -30,8 +31,8 @@ export class LogView extends ResizableView { new Select({ class: "quest_editor_LogView_level_filter", label: "Level:", - items: LogLevels, - to_label: level => LogLevel[level], + items: Severities, + to_label: level => Severity[level], }), ); @@ -47,10 +48,10 @@ export class LogView extends ResizableView { }), this.level_filter.selected.observe( - ({ value }) => value != undefined && log_store.set_level(value), + ({ value }) => value != undefined && log_store.set_severity(value), ), - log_store.level.observe( + log_store.severity.observe( ({ value }) => { this.level_filter.selected.val = value; }, @@ -83,16 +84,16 @@ export class LogView extends ResizableView { } }; - private create_message_element = ({ time, level, message }: LogEntry): HTMLElement => { + private create_message_element = ({ time, severity, message }: LogEntry): HTMLElement => { return div( { className: [ "quest_editor_LogView_message", - "quest_editor_LogView_" + LogLevel[level] + "_message", + "quest_editor_LogView_" + Severity[severity] + "_message", ].join(" "), }, div({ className: "quest_editor_LogView_message_timestamp" }, time_to_string(time)), - div({ className: "quest_editor_LogView_message_level" }, "[" + LogLevel[level] + "]"), + div({ className: "quest_editor_LogView_message_level" }, "[" + Severity[severity] + "]"), div({ className: "quest_editor_LogView_message_contents" }, message), ); }; diff --git a/src/quest_editor/gui/QuestEditorToolBar.ts b/src/quest_editor/gui/QuestEditorToolBar.ts index bb7b78fd..c79a1284 100644 --- a/src/quest_editor/gui/QuestEditorToolBar.ts +++ b/src/quest_editor/gui/QuestEditorToolBar.ts @@ -111,35 +111,35 @@ export class QuestEditorToolBar extends ToolBar { open_file_button.files.observe(({ value: files }) => ctrl.parse_files(files)), - save_as_button.click.observe(ctrl.save_as), + save_as_button.onclick.observe(ctrl.save_as), save_as_button.enabled.bind_to(ctrl.can_save), - undo_button.click.observe(() => undo_manager.undo()), + undo_button.onclick.observe(() => undo_manager.undo()), undo_button.enabled.bind_to(ctrl.can_undo), - redo_button.click.observe(() => undo_manager.redo()), + redo_button.onclick.observe(() => undo_manager.redo()), redo_button.enabled.bind_to(ctrl.can_redo), area_select.selected.bind_to(ctrl.current_area), area_select.selected.observe(({ value }) => ctrl.set_area(value!)), area_select.enabled.bind_to(ctrl.can_select_area), - debug_button.click.observe(ctrl.debug), + debug_button.onclick.observe(ctrl.debug), debug_button.enabled.bind_to(ctrl.can_debug), - resume_button.click.observe(ctrl.resume), + resume_button.onclick.observe(ctrl.resume), resume_button.enabled.bind_to(ctrl.can_step), - step_over_button.click.observe(ctrl.step_over), + step_over_button.onclick.observe(ctrl.step_over), step_over_button.enabled.bind_to(ctrl.can_step), - step_in_button.click.observe(ctrl.step_in), + step_in_button.onclick.observe(ctrl.step_in), step_in_button.enabled.bind_to(ctrl.can_step), - step_out_button.click.observe(ctrl.step_out), + step_out_button.onclick.observe(ctrl.step_out), step_out_button.enabled.bind_to(ctrl.can_step), - stop_button.click.observe(ctrl.stop), + stop_button.onclick.observe(ctrl.stop), stop_button.enabled.bind_to(ctrl.can_stop), ); diff --git a/src/quest_editor/loading/EntityAssetLoader.ts b/src/quest_editor/loading/EntityAssetLoader.ts index efda6922..ac99c43d 100644 --- a/src/quest_editor/loading/EntityAssetLoader.ts +++ b/src/quest_editor/loading/EntityAssetLoader.ts @@ -56,8 +56,8 @@ export class EntityAssetLoader implements Disposable { const cursor = new ArrayBufferCursor(data, Endianness.Little); const nj_objects = url.endsWith(".nj") ? parse_nj(cursor) : parse_xj(cursor); - if (nj_objects.length) { - return ninja_object_to_buffer_geometry(nj_objects[0]); + if (nj_objects.success && nj_objects.value.length) { + return ninja_object_to_buffer_geometry(nj_objects.value[0]); } else { logger.warn(`Couldn't parse ${url} for ${entity_type_to_string(type)}.`); return DEFAULT_ENTITY; @@ -79,7 +79,7 @@ export class EntityAssetLoader implements Disposable { .then(({ data }) => { const cursor = new ArrayBufferCursor(data, Endianness.Little); const xvm = parse_xvm(cursor); - return xvm === undefined ? [] : xvm_to_textures(xvm); + return xvm.success ? xvm_to_textures(xvm.value) : []; }) .catch(e => { logger.warn( diff --git a/src/quest_editor/stores/LogStore.ts b/src/quest_editor/stores/LogStore.ts index 9b05e63c..f7a412ce 100644 --- a/src/quest_editor/stores/LogStore.ts +++ b/src/quest_editor/stores/LogStore.ts @@ -3,27 +3,28 @@ import { Property } from "../../core/observable/property/Property"; import { list_property, property } from "../../core/observable"; import { Disposable } from "../../core/observable/Disposable"; import { Disposer } from "../../core/observable/Disposer"; -import { LogEntry, Logger, LogHandler, LogLevel, LogManager } from "../../core/Logger"; +import { LogEntry, Logger, LogHandler, LogManager } from "../../core/Logger"; +import { Severity } from "../../core/Severity"; -const logger = LogManager.get("quest_editor/stroes/LogStore"); +const logger = LogManager.get("quest_editor/stores/LogStore"); export class LogStore implements Disposable { private readonly disposer = new Disposer(); - private readonly default_log_level = LogLevel.Info; + private readonly default_log_severity = Severity.Info; private readonly log_buffer: LogEntry[] = []; private readonly logger_name_buffer: string[] = []; - private readonly _level = property(this.default_log_level); + private readonly _severity = property(this.default_log_severity); private readonly _log = list_property(); private readonly handler: LogHandler = (entry: LogEntry, logger_name: string): void => { this.buffer_log_entry(entry, logger_name); }; - readonly level: Property = this._level; + readonly severity: Property = this._severity; readonly log: ListProperty = this._log.filtered( - this.level.map(level => message => message.level >= level), + this.severity.map(severity => message => message.severity >= severity), ); get_logger(name: string): Logger { @@ -36,8 +37,8 @@ export class LogStore implements Disposable { this.disposer.dispose(); } - set_level(log_level: LogLevel): void { - this._level.val = log_level; + set_severity(severity: Severity): void { + this._severity.val = severity; } private buffer_log_entry(entry: LogEntry, logger_name: string): void { @@ -64,7 +65,7 @@ export class LogStore implements Disposable { this.log_buffer.splice(DROP_THRESHOLD_HALF, drop_len, { time: new Date(), message: `...dropped ${drop_len} messages...`, - level: LogLevel.Warn, + severity: Severity.Warn, logger, }); this.logger_name_buffer.splice( diff --git a/src/viewer/controllers/TextureController.ts b/src/viewer/controllers/TextureController.ts index dcf03f9d..8e4c561c 100644 --- a/src/viewer/controllers/TextureController.ts +++ b/src/viewer/controllers/TextureController.ts @@ -17,6 +17,7 @@ export class TextureController extends Controller { private readonly _textures: WritableListProperty = list_property(); readonly textures: ListProperty = this._textures; + // TODO: notify user of problems. load_file = async (file: File): Promise => { try { const ext = filename_extension(file.name).toLowerCase(); @@ -25,8 +26,8 @@ export class TextureController extends Controller { if (ext === "xvm") { const xvm = parse_xvm(new ArrayBufferCursor(buffer, Endianness.Little)); - if (xvm) { - this._textures.splice(0, Infinity, ...xvm.textures); + if (xvm.success) { + this._textures.splice(0, Infinity, ...xvm.value.textures); } } else if (ext === "afs") { const afs = parse_afs(new ArrayBufferCursor(buffer, Endianness.Little)); @@ -36,13 +37,13 @@ export class TextureController extends Controller { const cursor = new ArrayBufferCursor(buffer, Endianness.Little); const xvm = parse_xvm(cursor); - if (xvm) { - textures.push(...xvm.textures); + if (xvm.success) { + textures.push(...xvm.value.textures); } else { const xvm = parse_xvm(prs_decompress(cursor.seek_start(0))); - if (xvm) { - textures.push(...xvm.textures); + if (xvm.success) { + textures.push(...xvm.value.textures); } } } diff --git a/src/viewer/controllers/model/ModelToolBarController.ts b/src/viewer/controllers/model/ModelToolBarController.ts index 9f448565..00ef6caf 100644 --- a/src/viewer/controllers/model/ModelToolBarController.ts +++ b/src/viewer/controllers/model/ModelToolBarController.ts @@ -6,9 +6,13 @@ import { ArrayBufferCursor } from "../../../core/data_formats/cursor/ArrayBuffer 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 { is_xvm, parse_xvm, XvrTexture } from "../../../core/data_formats/parsing/ninja/texture"; import { parse_afs } from "../../../core/data_formats/parsing/afs"; import { LogManager } from "../../../core/Logger"; +import { prs_decompress } from "../../../core/data_formats/compression/prs/decompress"; +import { show_problems_popup } from "../../../core/gui/ProblemsPopup"; +import { failure, Result, result_builder } from "../../../core/Result"; +import { Severity } from "../../../core/Severity"; const logger = LogManager.get("viewer/controllers/model/ModelToolBarController"); @@ -49,16 +53,24 @@ export class ModelToolBarController extends Controller { this.store.set_animation_frame(frame); }; - // TODO: notify user of problems. load_file = async (file: File): Promise => { try { const buffer = await read_file(file); const cursor = new ArrayBufferCursor(buffer, Endianness.Little); + let result: Result | undefined; if (file.name.endsWith(".nj")) { - this.store.set_current_nj_object(parse_nj(cursor)[0]); + result = parse_nj(cursor); + + if (result.success) { + this.store.set_current_nj_object(result.value[0]); + } } else if (file.name.endsWith(".xj")) { - this.store.set_current_nj_object(parse_xj(cursor)[0]); + result = parse_xj(cursor); + + if (result.success) { + this.store.set_current_nj_object(result.value[0]); + } } else if (file.name.endsWith(".njm")) { this.store.set_current_animation(undefined); this.store.set_current_nj_motion(undefined); @@ -70,20 +82,59 @@ export class ModelToolBarController extends Controller { 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 ?? []); + result = parse_xvm(cursor); + + if (result.success) { + this.store.set_current_textures(result.value.textures); + } else { + this.store.set_current_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 ?? [], - ); + const rb = result_builder(logger); + + const textures: XvrTexture[] = files.flatMap(file => { + const cursor = new ArrayBufferCursor(file, Endianness.Little); + + if (is_xvm(cursor)) { + const xvm_result = parse_xvm(cursor); + rb.add_result(xvm_result); + return xvm_result.value?.textures ?? []; + } else { + const xvm_result = parse_xvm(prs_decompress(cursor.seek_start(0))); + rb.add_result(xvm_result); + return xvm_result.value?.textures ?? []; + } + }); + + if (textures.length) { + result = rb.success(textures); + } else { + result = rb.failure(); + } this.store.set_current_textures(textures); } else { - logger.error(`Unknown file extension in filename "${file.name}".`); + logger.error(`Unsupported file extension in filename "${file.name}".`); + show_problems_popup("Unsupported file type."); + } + + if (result) { + let description: string; + + if (result.success) { + description = `Encountered some problems while opening "${file.name}".`; + } else { + description = `Couldn't open "${file.name}" because of these problems.`; + } + + if (result.problems.length) { + show_problems_popup(description, result.problems); + } } } catch (e) { logger.error("Couldn't read file.", e); + show_problems_popup(`Couldn't open "${file.name}".`); } }; } diff --git a/src/viewer/loading/CharacterClassAssetLoader.ts b/src/viewer/loading/CharacterClassAssetLoader.ts index a04b6bf0..6137076b 100644 --- a/src/viewer/loading/CharacterClassAssetLoader.ts +++ b/src/viewer/loading/CharacterClassAssetLoader.ts @@ -24,6 +24,7 @@ import { import { parse_xvm, XvrTexture } from "../../core/data_formats/parsing/ninja/texture"; import { parse_afs } from "../../core/data_formats/parsing/afs"; import { SectionId } from "../../core/model"; +import { unwrap } from "../../core/Result"; export class CharacterClassAssetLoader implements Disposable { private readonly nj_object_cache: Map< @@ -126,7 +127,7 @@ export class CharacterClassAssetLoader implements Disposable { return this.http_client .get(character_class_to_url(player_class, body_part, no)) .array_buffer() - .then(buffer => parse_nj(new ArrayBufferCursor(buffer, Endianness.Little))[0]); + .then(buffer => unwrap(parse_nj(new ArrayBufferCursor(buffer, Endianness.Little)))[0]); } /** @@ -174,8 +175,8 @@ export class CharacterClassAssetLoader implements Disposable { for (const file of afs) { const xvm = parse_xvm(new ArrayBufferCursor(file, Endianness.Little)); - if (xvm) { - textures.push(...xvm.textures); + if (xvm.success) { + textures.push(...xvm.value.textures); } }