1
0
mirror of https://github.com/tobspr/shapez.io.git synced 2025-12-09 16:21:51 +00:00

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.
This commit is contained in:
Даниїл Григор'єв 2025-07-01 03:54:38 +03:00
parent c184d08ece
commit 515c07c067
No known key found for this signature in database
GPG Key ID: B890DF16341D8C1D
6 changed files with 264 additions and 1 deletions

View File

@ -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;

View File

@ -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;
}
}

View File

@ -13,6 +13,7 @@
@import "common";
@import "game_state";
@import "textual_game_state";
@import "error_handler";
@import "states/preload";
@import "states/main_menu";

View File

@ -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);

View File

@ -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 = <button>{T.errorHandler.actions.copy}</button>;
const btnRestart = <button>{T.errorHandler.actions.restart}</button>;
btnCopy.addEventListener("click", this.copyErrorLog.bind(this));
btnRestart.addEventListener("click", this.restart.bind(this));
return (
<>
<div class="header">
<h1>{T.errorHandler.title}</h1>
<div class="source">{this.source}</div>
</div>
<pre class="stackTrace">{this.recursiveStack}</pre>
<div class="loadedMods">
{T.errorHandler.labels.loadedMods}
<pre>{this.loadedMods}</pre>
</div>
<div class="buildInformation">
{T.errorHandler.labels.buildInformation}
<pre>{this.buildInformation}</pre>
</div>
<div class="actions">
{btnCopy}
{btnRestart}
</div>
</>
);
}
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");
}
}

View File

@ -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