From 515c07c0676c375237be515718b8c79fe30f0a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D1=97=D0=BB=20=D0=93=D1=80=D0=B8?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=27=D1=94=D0=B2?= Date: Tue, 1 Jul 2025 03:54:38 +0300 Subject: [PATCH] Re-implement error handler shapez used to have a nice error handler, but later it was removed. To help debug game in both development and release cases, add a simple error handler/screen that displays the error stack trace, lists the installed mods and shows some build information. It also allows copying this information to the system clipboard. Having such an error screen should make mod development easier once the local mod error handling is removed. --- gulp/preloader/preloader.css | 2 +- src/css/error_handler.scss | 89 +++++++++++++++++++ src/css/main.scss | 1 + src/js/application.js | 3 + src/js/core/error_handler.tsx | 160 ++++++++++++++++++++++++++++++++++ translations/base-en.yaml | 10 +++ 6 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/css/error_handler.scss create mode 100644 src/js/core/error_handler.tsx diff --git a/gulp/preloader/preloader.css b/gulp/preloader/preloader.css index 4ff3e84b..3faba330 100644 --- a/gulp/preloader/preloader.css +++ b/gulp/preloader/preloader.css @@ -26,7 +26,7 @@ html { body { color: #555; user-select: none; - background: inherit !important; + background: inherit; overflow-wrap: break-word; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; diff --git a/src/css/error_handler.scss b/src/css/error_handler.scss new file mode 100644 index 00000000..19a57b1c --- /dev/null +++ b/src/css/error_handler.scss @@ -0,0 +1,89 @@ +:root { + // Provide a fallback font-size for UI scale calculations before the app loads + // TODO: perhaps use this as the primary source of UI scale? + font-size: #{"round(clamp(1px, calc(min(100vw, 100vh) * 0.019), 100px), 0.01px)"}; +} + +#errorHandler { + --background-primary: hsl(0deg 40% 18%); + --background-container: hsl(0deg 40% 10%); + --foreground: hsl(0deg 100% 95%); + + --background-button: rgba(255 255 255 / 0.05); + --background-button-hover: rgba(255 255 255 / 0.03); + --background-button-active: hsl(from var(--background-container) h s l / 0.4); + --background-button-success: hsl(130deg 40% 25%); + + background: var(--background-primary); + color: var(--foreground); + + font-size: 1.2rem; + line-height: 1.3; + + display: grid; + padding: 3rem; + gap: 0.8rem; + + grid-template-rows: auto 1fr 1fr auto; + grid-template-columns: auto minmax(20rem, 20%); + + pre { + background: var(--background-container); + border-radius: 0.3rem; + + font-size: 85%; + padding: 0.2rem 0.4rem; + overflow-y: auto; + white-space: pre-wrap; + overflow-wrap: break-word; + pointer-events: all; + } + + button { + background: var(--background-button); + border-radius: 0.3rem; + color: inherit; + transition: 0.15s ease-out background-color; + + padding: 0.6rem 0.6rem 0.8rem; + min-width: 10rem; + width: 15%; + font-size: inherit; + + &:hover { + background: var(--background-button-hover); + } + + &:active { + background: var(--background-button-active); + } + + &.success { + background: var(--background-button-success); + } + } + + .header, + .actions { + grid-column: 1 / 3; + } + + .stackTrace { + grid-row: 2 / 4; + } + + .loadedMods, + .buildInformation { + display: grid; + grid-template-rows: auto 1fr; + gap: 0.2rem; + overflow: auto; + } + + .actions { + display: flex; + padding-top: 1rem; + gap: 1rem; + justify-content: end; + } +} diff --git a/src/css/main.scss b/src/css/main.scss index 9592666c..74ad265c 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -13,6 +13,7 @@ @import "common"; @import "game_state"; @import "textual_game_state"; +@import "error_handler"; @import "states/preload"; @import "states/main_menu"; diff --git a/src/js/application.js b/src/js/application.js index 1204ad0a..98f3718c 100644 --- a/src/js/application.js +++ b/src/js/application.js @@ -1,5 +1,6 @@ import { AnimationFrame } from "./core/animation_frame"; import { BackgroundResourcesLoader } from "./core/background_resources_loader"; +import { ErrorHandler } from "./core/error_handler"; import { GameState } from "./core/game_state"; import { setGlobalApp } from "./core/globals"; import { InputDistributor } from "./core/input_distributor"; @@ -41,6 +42,8 @@ export class Application { async boot() { console.log("Booting ..."); + this.errorHandler = new ErrorHandler(); + logger.log("Creating application, platform =", getPlatformName()); setGlobalApp(this); diff --git a/src/js/core/error_handler.tsx b/src/js/core/error_handler.tsx new file mode 100644 index 00000000..e2e3bd38 --- /dev/null +++ b/src/js/core/error_handler.tsx @@ -0,0 +1,160 @@ +import { MODS } from "@/mods/modloader"; +import { T } from "@/translations"; +import copy from "clipboard-copy"; +import { BUILD_OPTIONS } from "./globals"; +import { removeAllChildren } from "./utils"; + +export class ErrorHandler { + isActive = true; + + constructor() { + window.addEventListener("error", this.onError.bind(this)); + window.addEventListener("unhandledrejection", this.onUnhandledRejection.bind(this)); + } + + private onError(ev: ErrorEvent) { + if (!this.isActive) { + return; + } + + // Don't trigger more than once + this.isActive = false; + + const screen = new ErrorScreen(ev.error, ev.filename, ev.lineno, ev.colno); + screen.show(); + } + + private onUnhandledRejection(ev: PromiseRejectionEvent) { + const error = ev.reason instanceof Error ? ev.reason : new Error(ev.reason); + + // Avoid logging the error twice + ev.preventDefault(); + + // Turn the unhandled rejection into a regular error event + throw new Error("Unhandled Promise rejection", { cause: error }); + } +} + +export class ErrorScreen { + private error: Error; + private file?: string; + private line?: number; + private column?: number; + + constructor(error: Error, file?: string, line?: number, column?: number) { + this.error = error; + this.file = file; + this.line = line; + this.column = column; + } + + show() { + // Set the global to stop future callback handlers + window.APP_ERROR_OCCURED = true; + + removeAllChildren(document.body); + document.body.id = "errorHandler"; + document.body.className = ""; + + const layout = this.createLayout(); + if (Array.isArray(layout)) { + document.body.append(...layout); + } else { + document.body.append(layout); + } + } + + private createLayout(): HTMLElement | HTMLElement[] { + const btnCopy = ; + const btnRestart = ; + + btnCopy.addEventListener("click", this.copyErrorLog.bind(this)); + btnRestart.addEventListener("click", this.restart.bind(this)); + + return ( + <> +
+

{T.errorHandler.title}

+
{this.source}
+
+
{this.recursiveStack}
+
+ {T.errorHandler.labels.loadedMods} +
{this.loadedMods}
+
+
+ {T.errorHandler.labels.buildInformation} +
{this.buildInformation}
+
+
+ {btnCopy} + {btnRestart} +
+ + ); + } + + private copyErrorLog(ev: MouseEvent) { + let log = `shapez Error Log - ${new Date().toISOString()}\n\n`; + + log += this.recursiveStack; + log += `\n\nLoaded Mods:\n${this.loadedMods}`; + log += `\n\nBuild Information:\n${this.buildInformation}`; + + copy(log); + + if (ev.target instanceof HTMLButtonElement) { + ev.target.innerText = T.errorHandler.actions.copyDone; + ev.target.classList.add("success"); + } + } + + private restart() { + // performRestart may not be available yet + location.reload(); + } + + private get source(): string { + return `${this.file} (${this.line}:${this.column})`; + } + + private get recursiveStack(): string { + // Follow the error cause chain + let current = this.error; + let stack = current.stack; + + while (current.cause instanceof Error) { + current = current.cause; + stack += `\nCaused by: ${current.stack}`; + } + + return stack; + } + + private get loadedMods(): string { + const mods: string[] = []; + const activeMods = MODS.activeMods; + + for (const mod of MODS.allMods) { + const isActive = activeMods.includes(mod); + const prefix = isActive ? "*" : ""; + + const id = mod.mod.id; + const version = mod.mod.metadata.version; + + mods.push(`${prefix}${id}@${version} (${mod.source})`); + } + + return mods.join("\n"); + } + + private get buildInformation(): string { + const info: string[] = []; + + for (const [key, value] of Object.entries(BUILD_OPTIONS)) { + info.push(`${key}: ${JSON.stringify(value)}`); + } + + return info.join("\n"); + } +} diff --git a/translations/base-en.yaml b/translations/base-en.yaml index f45969ff..30d9c228 100644 --- a/translations/base-en.yaml +++ b/translations/base-en.yaml @@ -1424,3 +1424,13 @@ tips: - Press F4 to show your FPS and Tick Rate. - Press F4 twice to show the tile of your mouse and camera. - You can click a pinned shape on the left side to unpin it. + +errorHandler: + title: Unhandled Error! + labels: + buildInformation: "Build Information:" + loadedMods: "Loaded Mods:" + actions: + copy: Copy + copyDone: Copied! + restart: Restart