mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:50:19 +00:00
Reduce main bundle size by ~44% gzipped (732 KB → 412 KB) (#4229)
## Summary Cuts the main JS chunk from **2,891 KB (732 KB gzip)** to **1,679 KB (412 KB gzip)** by fixing two bundling issues and removing/replacing heavy dependencies. Measured with a per-module `renderedLength` analysis of the rolldown output (its prod sourcemaps are malformed, so sourcemap-based tools misattribute sizes). | Chunk | Before | After | |---|---|---| | `index-*.js` (min) | 2,891 KB | 1,679 KB | | `index-*.js` (gzip) | 732 KB | **412 KB** | ## Changes - **Sim worker moved out of the main bundle (~512 KB).** The `?worker&inline` payload is now reached through a dynamic `import()`, so it lands in its own lazy chunk fetched when a game starts. The worker itself still uses Vite's inline Blob mechanism (with its `data:` URL fallback) — runtime instantiation is byte-for-byte unchanged. - **Replaced `lit-markdown` with `marked` + the already-bundled DOMPurify (~380 KB).** lit-markdown transitively pulled sanitize-html, htmlparser2, postcss, and two copies of entities into the client just to render news markdown. New `src/client/Markdown.ts` matches its image-stripping default. - **Dropped `colorjs.io` (~114 KB).** It was only used for ΔE2000 distance in `ColorAllocator`; colord's lab plugin (already imported there) provides the same CIEDE2000 via `.delta()`. Only relative magnitudes are compared, so allocation behavior is unchanged. - **`msdf-atlas.json` (~319 KB) fetched at runtime** like the atlas PNG, preloaded in parallel with worker init in `ClientGameRunner` so game-load latency is unaffected. - **Tailwind CSS no longer shipped twice (~158 KB).** `o-modal` imported `styles.css?inline`, duplicating the emitted stylesheet as a JS string. It now adopts a constructed stylesheet built from the document's own CSS (HTTP-cache hit in prod, `<style>` tags + HMR re-sync in dev) via `SharedStyles.ts`. - **Debug GUI lazy-loaded.** lil-gui + `gl/debug/*` now load on first toggle (46 KB lazy chunk) instead of shipping in the main bundle. Also looked at the `import * as d3` in RadialMenu (~84 KB) but left it: rolldown tree-shakes the metapackage well and all but ~2 KB is the genuine dependency closure of the selection/transition/shape/color APIs in use. ## Test plan - [x] `tsc --noEmit` clean - [x] ESLint clean - [x] Full test suite passes (1,374 + 65 tests) - [x] `npm run build-prod` succeeds; worker/debug chunks present in `asset-manifest.json` for the R2 upload - [ ] Manual smoke test in dev: start a game (worker dev path), open a modal (shared stylesheet), open news modal (markdown rendering) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+5
-165
@@ -18,7 +18,6 @@
|
||||
"@opentelemetry/winston-transport": "^0.26.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"colord": "^2.9.3",
|
||||
"colorjs.io": "^0.6.1",
|
||||
"compression": "^1.8.1",
|
||||
"dompurify": "^3.4.2",
|
||||
"dotenv": "^17.4.2",
|
||||
@@ -77,7 +76,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lit": "^3.3.2",
|
||||
"lit-markdown": "^1.3.2",
|
||||
"marked": "^18.0.5",
|
||||
"mrmime": "^2.0.1",
|
||||
"pixi-filters": "^6.1.5",
|
||||
"pixi.js": "^8.18.1",
|
||||
@@ -3896,16 +3895,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorjs.io": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.6.1.tgz",
|
||||
"integrity": "sha512-8lyR2wHzuIykCpqHKgluGsqQi5iDm3/a2IgP2GBZrasn2sBRkE4NOGsglZxWLs/jZQoNkmA/KM/8NV16rLUdBg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/color"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
@@ -4599,13 +4588,6 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.20",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -4663,16 +4645,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
@@ -5892,39 +5864,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -6121,16 +6060,6 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
@@ -6376,16 +6305,6 @@
|
||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/launder": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
|
||||
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.7"
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -6769,62 +6688,6 @@
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-markdown": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/lit-markdown/-/lit-markdown-1.3.2.tgz",
|
||||
"integrity": "sha512-51M4QRR2UmJIKck3kRQClD8CAM2Ox7BBZ9qzQLA1ppkr5tFfyNJDXnM0A2xmpNNfFrELZrnTmD5ST6VEdcemPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^2.6.1",
|
||||
"marked": "^4.2.12",
|
||||
"sanitize-html": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-markdown/node_modules/@lit/reactive-element": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
|
||||
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-markdown/node_modules/lit": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz",
|
||||
"integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^1.6.0",
|
||||
"lit-element": "^3.3.0",
|
||||
"lit-html": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-markdown/node_modules/lit-element": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
|
||||
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.1.0",
|
||||
"@lit/reactive-element": "^1.3.0",
|
||||
"lit-html": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lit-markdown/node_modules/lit-html": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
|
||||
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -6993,16 +6856,16 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
|
||||
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
|
||||
"version": "18.0.5",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
@@ -7412,13 +7275,6 @@
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse-svg-path": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
|
||||
@@ -8185,22 +8041,6 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.4",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz",
|
||||
"integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"launder": "^1.7.1",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
|
||||
+1
-2
@@ -64,7 +64,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lit": "^3.3.2",
|
||||
"lit-markdown": "^1.3.2",
|
||||
"marked": "^18.0.5",
|
||||
"mrmime": "^2.0.1",
|
||||
"pixi-filters": "^6.1.5",
|
||||
"pixi.js": "^8.18.1",
|
||||
@@ -93,7 +93,6 @@
|
||||
"@opentelemetry/winston-transport": "^0.26.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"colord": "^2.9.3",
|
||||
"colorjs.io": "^0.6.1",
|
||||
"compression": "^1.8.1",
|
||||
"dompurify": "^3.4.2",
|
||||
"dotenv": "^17.4.2",
|
||||
|
||||
@@ -71,9 +71,9 @@ import { createRenderer, GameRenderer } from "./hud/GameRenderer";
|
||||
import {
|
||||
applyDarkModeOverride,
|
||||
applyGraphicsOverrides,
|
||||
createDebugGui,
|
||||
createRenderSettings,
|
||||
deepAssign,
|
||||
preloadAtlasData,
|
||||
GameView as WebGLGameView,
|
||||
} from "./render/gl";
|
||||
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
|
||||
@@ -451,8 +451,12 @@ async function createClientGame(
|
||||
mapLoader,
|
||||
);
|
||||
}
|
||||
// Kick off the font-atlas fetch so it overlaps with worker init; the
|
||||
// render passes need it parsed before createWebGLView runs.
|
||||
const atlasDataLoad = preloadAtlasData();
|
||||
const worker = new WorkerClient(lobbyConfig.gameStartInfo, clientID);
|
||||
await worker.initialize();
|
||||
await atlasDataLoad;
|
||||
const gameView = new GameView(
|
||||
worker,
|
||||
config,
|
||||
@@ -519,11 +523,21 @@ async function createClientGame(
|
||||
{ signal: graphicsListenerAbort.signal },
|
||||
);
|
||||
|
||||
let debugGui: ReturnType<typeof createDebugGui> | null = null;
|
||||
// Loaded on demand so lil-gui and the debug GUI stay out of the main bundle.
|
||||
let debugGui: { open(): void; destroy(): void } | null = null;
|
||||
let debugGuiLoading = false;
|
||||
eventBus.on(ToggleRenderDebugGuiEvent, () => {
|
||||
if (debugGui === null) {
|
||||
debugGui = createDebugGui(view.getSettings());
|
||||
debugGui.open();
|
||||
if (debugGuiLoading) return;
|
||||
debugGuiLoading = true;
|
||||
import("./render/gl/debug/index")
|
||||
.then(({ createDebugGui }) => {
|
||||
debugGui = createDebugGui(view.getSettings());
|
||||
debugGui.open();
|
||||
})
|
||||
.finally(() => {
|
||||
debugGuiLoading = false;
|
||||
});
|
||||
} else {
|
||||
debugGui.destroy();
|
||||
debugGui = null;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { marked } from "marked";
|
||||
|
||||
/**
|
||||
* Render markdown to a sanitized lit template value. Images are stripped
|
||||
* unless `includeImages` is set (matching the lit-markdown default this
|
||||
* replaces — lit-markdown pulled in sanitize-html (~325 KB min); DOMPurify is
|
||||
* already in the bundle).
|
||||
*/
|
||||
export function renderMarkdown(
|
||||
rawMarkdown: string,
|
||||
options?: { includeImages?: boolean },
|
||||
) {
|
||||
const rawHTML = marked.parse(rawMarkdown, { async: false });
|
||||
const cleanHTML = DOMPurify.sanitize(
|
||||
rawHTML,
|
||||
options?.includeImages ? {} : { FORBID_TAGS: ["img"] },
|
||||
);
|
||||
return unsafeHTML(cleanHTML);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { resolveMarkdown } from "lit-markdown";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import version from "resources/version.txt?raw";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { assetUrl } from "../core/AssetUrls";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { renderMarkdown } from "./Markdown";
|
||||
import { normalizeNewsMarkdown } from "./NewsMarkdown";
|
||||
|
||||
@customElement("news-modal")
|
||||
@@ -36,10 +36,7 @@ export class NewsModal extends BaseModal {
|
||||
[&_li]:text-gray-300 [&_li]:leading-relaxed
|
||||
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold"
|
||||
>
|
||||
${resolveMarkdown(this.markdown, {
|
||||
includeImages: true,
|
||||
includeCodeBlockClassNames: true,
|
||||
})}
|
||||
${renderMarkdown(this.markdown, { includeImages: true })}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { resolveMarkdown } from "lit-markdown";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import type { NewsItem } from "../../core/ApiSchemas";
|
||||
import { getNews } from "../Api";
|
||||
import { renderMarkdown } from "../Markdown";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
export type { NewsItem };
|
||||
@@ -140,7 +140,7 @@ export class NewsBox extends LitElement {
|
||||
>`}
|
||||
<span
|
||||
class="text-xs text-white/50 block [&_a]:text-blue-300 [&_a:hover]:text-blue-200"
|
||||
>${resolveMarkdown(
|
||||
>${renderMarkdown(
|
||||
item.descriptionTranslationKey
|
||||
? translateText(item.descriptionTranslationKey)
|
||||
: (item.description ?? ""),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import tailwindStyles from "../../styles.css?inline";
|
||||
import { documentStylesSheet } from "./SharedStyles";
|
||||
|
||||
export type OModalTab = { key: string; label: string };
|
||||
|
||||
@customElement("o-modal")
|
||||
export class OModal extends LitElement {
|
||||
static styles = [unsafeCSS(tailwindStyles)];
|
||||
static styles = [documentStylesSheet()];
|
||||
|
||||
@state() public isModalOpen = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Shared constructed stylesheet mirroring the document's global CSS, for
|
||||
* shadow-DOM components that want the page's Tailwind styles. Importing
|
||||
* styles.css?inline instead would ship the full Tailwind CSS a second time
|
||||
* (~160 KB) inside the JS bundle.
|
||||
*
|
||||
* In production the page stylesheet <link> is fetched (same URL the browser
|
||||
* already loaded, so it resolves from HTTP cache); in dev Vite injects
|
||||
* <style> tags whose text is read directly and re-read on HMR updates.
|
||||
*/
|
||||
|
||||
let sheet: CSSStyleSheet | null = null;
|
||||
|
||||
async function populate(target: CSSStyleSheet): Promise<void> {
|
||||
const parts: string[] = [];
|
||||
for (const style of Array.from(document.querySelectorAll("style"))) {
|
||||
parts.push(style.textContent ?? "");
|
||||
}
|
||||
const links = Array.from(
|
||||
document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
|
||||
);
|
||||
await Promise.all(
|
||||
links.map(async (link) => {
|
||||
try {
|
||||
const response = await fetch(link.href);
|
||||
if (response.ok) {
|
||||
parts.push(await response.text());
|
||||
}
|
||||
} catch {
|
||||
// Unreachable stylesheet — skip; the component renders unstyled
|
||||
// rather than breaking.
|
||||
}
|
||||
}),
|
||||
);
|
||||
await target.replace(parts.join("\n"));
|
||||
}
|
||||
|
||||
export function documentStylesSheet(): CSSStyleSheet {
|
||||
if (sheet === null) {
|
||||
sheet = new CSSStyleSheet();
|
||||
void populate(sheet);
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
// Keep the copy in sync when Vite hot-replaces CSS in dev.
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on("vite:afterUpdate", () => {
|
||||
if (sheet !== null) {
|
||||
void populate(sheet);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export type { AttackRingInput } from "../types";
|
||||
export { createDebugGui } from "./debug/index";
|
||||
// createDebugGui is intentionally not re-exported here — it pulls lil-gui and
|
||||
// the debug GUI into the main bundle; dynamically import "./debug/index".
|
||||
export type {
|
||||
GameViewEventMap,
|
||||
GameViewEventType,
|
||||
@@ -11,6 +12,7 @@ export type {
|
||||
export { GameView } from "./GameView";
|
||||
export { GraphicsOverridesSchema } from "./GraphicsOverrides";
|
||||
export type { GraphicsOverrides } from "./GraphicsOverrides";
|
||||
export { preloadAtlasData } from "./passes/name-pass/AtlasData";
|
||||
export type { SpawnCenter } from "./passes/SpawnOverlayPass";
|
||||
export {
|
||||
applyDarkModeOverride,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json";
|
||||
import atlasData from "resources/atlases/msdf-atlas.json";
|
||||
import { assetUrl } from "src/core/AssetUrls";
|
||||
import type { BMChar, BMKerning, ParsedAtlas } from "./Types";
|
||||
import { CHAR_RANGE } from "./Types";
|
||||
|
||||
@@ -12,15 +12,45 @@ import { CHAR_RANGE } from "./Types";
|
||||
// Atlas parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RawMsdfAtlas {
|
||||
info: { size: number };
|
||||
common: { base: number; scaleW: number; scaleH: number };
|
||||
distanceField?: { distanceRange: number };
|
||||
chars: BMChar[];
|
||||
kernings?: BMKerning[];
|
||||
}
|
||||
|
||||
// Fetched at game-load time rather than statically imported — the JSON is
|
||||
// ~320 KB minified and would otherwise sit in the main bundle.
|
||||
let atlasData: RawMsdfAtlas | null = null;
|
||||
let atlasDataPromise: Promise<void> | null = null;
|
||||
|
||||
export function preloadAtlasData(): Promise<void> {
|
||||
atlasDataPromise ??= fetch(assetUrl("atlases/msdf-atlas.json"))
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch msdf-atlas.json: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
atlasData = json as RawMsdfAtlas;
|
||||
});
|
||||
return atlasDataPromise;
|
||||
}
|
||||
|
||||
export function parseAtlasData(): ParsedAtlas {
|
||||
if (atlasData === null) {
|
||||
throw new Error("Atlas data not loaded; await preloadAtlasData() first");
|
||||
}
|
||||
return {
|
||||
fontSize: atlasData.info.size,
|
||||
base: atlasData.common.base,
|
||||
scaleW: atlasData.common.scaleW,
|
||||
scaleH: atlasData.common.scaleH,
|
||||
distanceRange: (atlasData as any).distanceField?.distanceRange ?? 4,
|
||||
chars: atlasData.chars as BMChar[],
|
||||
kernings: (atlasData.kernings ?? []) as BMKerning[],
|
||||
distanceRange: atlasData.distanceField?.distanceRange ?? 4,
|
||||
chars: atlasData.chars,
|
||||
kernings: atlasData.kernings ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Colord, extend } from "colord";
|
||||
import labPlugin from "colord/plugins/lab";
|
||||
import lchPlugin from "colord/plugins/lch";
|
||||
import Color from "colorjs.io";
|
||||
import { PseudoRandom } from "../../core/PseudoRandom";
|
||||
import { simpleHash } from "../../core/Util";
|
||||
extend([lchPlugin]);
|
||||
@@ -75,14 +74,12 @@ export function selectDistinctColorIndex(
|
||||
throw new Error("No assigned colors");
|
||||
}
|
||||
|
||||
const assignedLabColors = assignedColors.map(toColor);
|
||||
|
||||
let maxDeltaE = 0;
|
||||
let maxIndex = 0;
|
||||
|
||||
for (let i = 0; i < availableColors.length; i++) {
|
||||
const color = availableColors[i];
|
||||
const deltaE = minDeltaE(toColor(color), assignedLabColors);
|
||||
const deltaE = minDeltaE(color, assignedColors);
|
||||
if (deltaE > maxDeltaE) {
|
||||
maxDeltaE = deltaE;
|
||||
maxIndex = i;
|
||||
@@ -91,20 +88,11 @@ export function selectDistinctColorIndex(
|
||||
return maxIndex;
|
||||
}
|
||||
|
||||
/** Smallest delta-E 2000 distance from `lab1` to any of the assigned colors. */
|
||||
function minDeltaE(lab1: Color, assignedLabColors: Color[]) {
|
||||
return assignedLabColors.reduce((min, assigned) => {
|
||||
return Math.min(min, deltaE2000(lab1, assigned));
|
||||
/** Smallest delta-E 2000 distance from `color` to any of the assigned colors. */
|
||||
function minDeltaE(color: Colord, assignedColors: Colord[]) {
|
||||
return assignedColors.reduce((min, assigned) => {
|
||||
// colord's lab plugin .delta() is CIEDE2000 normalized to 0..1; only
|
||||
// relative magnitudes matter here.
|
||||
return Math.min(min, color.delta(assigned));
|
||||
}, Infinity);
|
||||
}
|
||||
|
||||
/** Perceptual distance between two colors using the CIEDE2000 formula. */
|
||||
function deltaE2000(c1: Color, c2: Color): number {
|
||||
return c1.deltaE(c2, "2000");
|
||||
}
|
||||
|
||||
/** Convert a colord color to a colorjs.io LAB color for delta-E math. */
|
||||
function toColor(colord: Colord): Color {
|
||||
const lab = colord.toLab();
|
||||
return new Color("lab", [lab.l, lab.a, lab.b]);
|
||||
}
|
||||
|
||||
@@ -13,13 +13,20 @@ import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
import { generateID } from "../Util";
|
||||
import { WorkerMessage } from "./WorkerMessages";
|
||||
// Inlined into the main bundle as a same-origin Blob, sidestepping the
|
||||
|
||||
// Inlined as a same-origin Blob (Vite's `?worker&inline`), sidestepping the
|
||||
// cross-origin `new Worker(url)` restriction that would otherwise apply when
|
||||
// the worker bundle is served from the CDN.
|
||||
import GameWorker from "./Worker.worker.ts?worker&inline";
|
||||
// the worker bundle is served from the CDN. The dynamic import keeps the
|
||||
// ~700 KB base64 payload in its own chunk, fetched when a game starts,
|
||||
// instead of inside the main bundle.
|
||||
async function createGameWorker(): Promise<Worker> {
|
||||
const { default: GameWorker } =
|
||||
await import("./Worker.worker.ts?worker&inline");
|
||||
return new GameWorker();
|
||||
}
|
||||
|
||||
export class WorkerClient {
|
||||
private worker: Worker;
|
||||
private worker: Worker | null = null;
|
||||
private isInitialized = false;
|
||||
private messageHandlers: Map<string, (message: WorkerMessage) => void>;
|
||||
private gameUpdateCallback?: (
|
||||
@@ -30,14 +37,7 @@ export class WorkerClient {
|
||||
private gameStartInfo: GameStartInfo,
|
||||
private clientID: ClientID | undefined,
|
||||
) {
|
||||
this.worker = new GameWorker();
|
||||
this.messageHandlers = new Map();
|
||||
|
||||
// Set up global message handler
|
||||
this.worker.addEventListener(
|
||||
"message",
|
||||
this.handleWorkerMessage.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
private handleWorkerMessage(event: MessageEvent<WorkerMessage>) {
|
||||
@@ -73,7 +73,11 @@ export class WorkerClient {
|
||||
}
|
||||
}
|
||||
|
||||
initialize(): Promise<void> {
|
||||
async initialize(): Promise<void> {
|
||||
const worker = await createGameWorker();
|
||||
this.worker = worker;
|
||||
worker.addEventListener("message", this.handleWorkerMessage.bind(this));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageId = generateID();
|
||||
|
||||
@@ -84,7 +88,7 @@ export class WorkerClient {
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
worker.postMessage({
|
||||
type: "init",
|
||||
id: messageId,
|
||||
gameStartInfo: this.gameStartInfo,
|
||||
@@ -113,7 +117,7 @@ export class WorkerClient {
|
||||
throw new Error("Worker not initialized");
|
||||
}
|
||||
|
||||
this.worker.postMessage({
|
||||
this.worker!.postMessage({
|
||||
type: "turn",
|
||||
turn,
|
||||
});
|
||||
@@ -137,7 +141,7 @@ export class WorkerClient {
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
this.worker!.postMessage({
|
||||
type: "player_profile",
|
||||
id: messageId,
|
||||
playerID: playerID,
|
||||
@@ -163,7 +167,7 @@ export class WorkerClient {
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
this.worker!.postMessage({
|
||||
type: "player_border_tiles",
|
||||
id: messageId,
|
||||
playerID: playerID,
|
||||
@@ -194,7 +198,7 @@ export class WorkerClient {
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
this.worker!.postMessage({
|
||||
type: "player_actions",
|
||||
id: messageId,
|
||||
playerID,
|
||||
@@ -228,7 +232,7 @@ export class WorkerClient {
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
this.worker!.postMessage({
|
||||
type: "player_buildables",
|
||||
id: messageId,
|
||||
playerID,
|
||||
@@ -274,7 +278,7 @@ export class WorkerClient {
|
||||
);
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
this.worker!.postMessage({
|
||||
type: "attack_clustered_positions",
|
||||
id: messageId,
|
||||
playerID,
|
||||
@@ -304,7 +308,7 @@ export class WorkerClient {
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.postMessage({
|
||||
this.worker!.postMessage({
|
||||
type: "transport_ship_spawn",
|
||||
id: messageId,
|
||||
playerID: playerID,
|
||||
@@ -314,7 +318,7 @@ export class WorkerClient {
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.worker.terminate();
|
||||
this.worker?.terminate();
|
||||
this.messageHandlers.clear();
|
||||
this.gameUpdateCallback = undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user