From 88cba239d754a26c9c0e0b38b73164bc1f5a3555 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:05:29 +0000 Subject: [PATCH] feat(playground): add browser-only IDE for writing and exporting a Forge game Adds /playground, a standalone Vite + Monaco + @typescript/vfs + esbuild-wasm app for writing a small single-file Forge game in the browser and downloading it as a self-contained index.html + game.js zip, entirely client-side. - Monaco is configured with Forge's real type declarations as extra libs, so autocomplete/hover/inline diagnostics work against Forge's actual API. - @typescript/vfs powers an independent, in-browser TypeScript language service used to type-check the game source before every build. - esbuild-wasm bundles the game through a custom resolver plugin that resolves @forge-game-engine/forge/ imports (and Forge's internal imports/seedrandom dependency) against Forge's own compiled dist output, embedded into the app bundle via import.meta.glob, so Forge is inlined into the downloaded game rather than referenced externally. - fflate zips the generated index.html and bundled game.js for download. Verified end-to-end via Playwright: typed a game in the editor, clicked Build, and confirmed the downloaded zip's index.html renders and animates correctly in a real browser. --- .cspell/project-words.txt | 1 + AGENTS.md | 38 + playground/.gitignore | 2 + playground/README.md | 58 ++ playground/index.html | 13 + playground/package-lock.json | 952 +++++++++++++++++++ playground/package.json | 25 + playground/src/build/bundle-game.ts | 69 ++ playground/src/build/download-zip.ts | 31 + playground/src/build/esbuild-setup.ts | 18 + playground/src/build/forge-esbuild-plugin.ts | 146 +++ playground/src/build/html-template.ts | 27 + playground/src/default-source.ts | 86 ++ playground/src/editor/create-editor.ts | 65 ++ playground/src/editor/forge-types.ts | 43 + playground/src/editor/monaco-workers.ts | 27 + playground/src/editor/type-check.ts | 128 +++ playground/src/forge-registry.ts | 106 +++ playground/src/main.ts | 116 +++ playground/src/style.css | 90 ++ playground/tsconfig.json | 19 + playground/vite.config.ts | 8 + 22 files changed, 2068 insertions(+) create mode 100644 playground/.gitignore create mode 100644 playground/README.md create mode 100644 playground/index.html create mode 100644 playground/package-lock.json create mode 100644 playground/package.json create mode 100644 playground/src/build/bundle-game.ts create mode 100644 playground/src/build/download-zip.ts create mode 100644 playground/src/build/esbuild-setup.ts create mode 100644 playground/src/build/forge-esbuild-plugin.ts create mode 100644 playground/src/build/html-template.ts create mode 100644 playground/src/default-source.ts create mode 100644 playground/src/editor/create-editor.ts create mode 100644 playground/src/editor/forge-types.ts create mode 100644 playground/src/editor/monaco-workers.ts create mode 100644 playground/src/editor/type-check.ts create mode 100644 playground/src/forge-registry.ts create mode 100644 playground/src/main.ts create mode 100644 playground/src/style.css create mode 100644 playground/tsconfig.json create mode 100644 playground/vite.config.ts diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt index d4456708..38fed9e9 100644 --- a/.cspell/project-words.txt +++ b/.cspell/project-words.txt @@ -24,6 +24,7 @@ eamodio Erdokovy esbenp Fira +fflate Flaticon fphysics fract diff --git a/AGENTS.md b/AGENTS.md index 310d2049..cf1d1ebc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Human contributors: see [CONTRIBUTING.md](./CONTRIBUTING.md) for a shorter, huma - [Testing](#testing) - [Documentation Site Demos](#documentation-site-demos) - [Documentation Site Blog](#documentation-site-blog) +- [Browser Playground](#browser-playground) - [Common Patterns](#common-patterns) - [Security Considerations](#security-considerations) @@ -59,6 +60,7 @@ Forge is a browser-based, code-only game engine built with TypeScript. It provid /demo # Demo application /documentation-site # Docusaurus documentation +/playground # Browser-only IDE for writing/exporting a Forge game /scripts # Build and utility scripts /assets # Static assets (images, etc.) ``` @@ -605,6 +607,42 @@ Conventions: `documentation-site/`) to catch broken links/MDX errors, the same way a demo change is per the "Documentation Site Demos" section above. +## Browser Playground + +`/playground` is a standalone, browser-only IDE (Vite + Monaco + +`@typescript/vfs` + esbuild-wasm, no React, no backend) for writing a small +single-file Forge game and downloading it as a self-contained `index.html` ++ `game.js` zip. It's a separate npm project (its own `package.json`, +`node_modules`, `tsconfig.json`), the same pattern as `documentation-site`, +so it isn't covered by the root `npm run check-types`/`lint`/`test` +commands - verify it independently with `npm run typecheck` from +`playground/`. + +Like the documentation site's demos, it depends on +`@forge-game-engine/forge` via a `file:..` link resolved through this +repo's `package.json` `exports`, which point at `/dist`. Two registries in +`playground/src/forge-registry.ts` snapshot `/dist` (both its compiled JS +and its `.d.ts` files) into the playground's own app bundle at dev/build +time via Vite's `import.meta.glob`, and derive the set of valid +`@forge-game-engine/forge/` import specifiers directly from the +root `package.json`'s `exports` map rather than hardcoding them. This means: + +- `npm run build` must be run at the repo root first (and again after any + `/src` change) before `playground/`'s dev server or build will reflect + it, the same gotcha as the documentation site's demos. +- The esbuild-wasm plugin (`playground/src/build/forge-esbuild-plugin.ts`) + resolves a game's `@forge-game-engine/forge/` imports - plus + Forge's own internal relative imports and its one runtime dependency + (`seedrandom`) - against that embedded snapshot, so Forge is inlined into + the downloaded `game.js` rather than referenced externally. +- Monaco's extra libs (`playground/src/editor/forge-types.ts`) and the + `@typescript/vfs` pre-build type-check environment + (`playground/src/editor/type-check.ts`) both register the same `.d.ts` + snapshot, plus a tiny re-export shim per subpath (e.g. `fsm` -> + `finite-state-machine`) so `@forge-game-engine/forge/` resolves + under classic Node module resolution even where the public subpath name + doesn't match Forge's internal dist folder name. + ## Common Patterns ### Readonly Fields diff --git a/playground/.gitignore b/playground/.gitignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/playground/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 00000000..b314c58f --- /dev/null +++ b/playground/README.md @@ -0,0 +1,58 @@ +# Forge Playground + +A bare-bones, browser-only IDE for writing a small Forge game and downloading +it as a standalone, self-contained web page - no backend involved. + +- **Monaco** for the editor, with `@forge-game-engine/forge`'s real type + declarations registered as extra libs (via `src/editor/forge-types.ts`), + so autocomplete, hover, and inline diagnostics work against Forge's actual + public API. +- **`@typescript/vfs`** powers an independent, in-browser TypeScript + language service (`src/editor/type-check.ts`) that type-checks the game + source before every build - esbuild only strips types, it never checks + them. +- **esbuild-wasm** bundles the game's TypeScript, with a custom plugin + (`src/build/forge-esbuild-plugin.ts`) that resolves + `@forge-game-engine/forge/` imports against Forge's own compiled + `dist` output (embedded into this app's bundle at build time via + `import.meta.glob`), so Forge itself is inlined into the output rather + than referenced externally. +- **fflate** zips the generated `index.html` and bundled `game.js` and + triggers a browser download. + +## Running + +From this directory: + +```bash +npm install +npm run dev +``` + +Requires `@forge-game-engine/forge`'s own `dist` to already exist - run +`npm run build` in the repo root first (and again after changing `/src`, +since this app's type/JS registries are snapshotted from `dist` at +dev-server start, same as the documentation site's demos). + +## How a build works + +1. **Type-check**: the current editor contents are checked against a + `@typescript/vfs` environment seeded with Forge's real `.d.ts` files. + Errors are shown and the build stops there. +2. **Bundle**: on success, esbuild-wasm bundles the game source (stdin + entry point) through the Forge resolver plugin into a single minified + ES module. +3. **Zip & download**: the bundle is paired with a generated `index.html` + (`
` + ` + + diff --git a/playground/package-lock.json b/playground/package-lock.json new file mode 100644 index 00000000..ff836102 --- /dev/null +++ b/playground/package-lock.json @@ -0,0 +1,952 @@ +{ + "name": "forge-playground", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "forge-playground", + "version": "0.0.0", + "dependencies": { + "@forge-game-engine/forge": "file:..", + "@typescript/vfs": "^1.6.4", + "esbuild-wasm": "^0.28.1", + "fflate": "^0.8.3", + "howler": "^2.2.4", + "monaco-editor": "^0.56.0", + "seedrandom": "^3.0.5" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vite": "^8.1.5" + } + }, + "..": { + "name": "@forge-game-engine/forge", + "version": "0.24.2", + "license": "MIT", + "dependencies": { + "@types/imurmurhash": "^0.1.4", + "imurmurhash": "^0.1.4", + "seedrandom": "^3.0.5" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.2", + "@commitlint/cli": "^21.2.1", + "@commitlint/config-conventional": "^21.2.0", + "@eslint/js": "^10.0.1", + "@playwright/test": "1.62.1", + "@types/howler": "^2.2.13", + "@types/node": "^26.1.2", + "@types/seedrandom": "^3.0.8", + "@vitest/coverage-v8": "^4.1.10", + "@vitest/ui": "^4.0.6", + "cspell": "^10.0.1", + "eslint": "^10.7.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-jest": "^29.16.0", + "eslint-plugin-prettier": "^5.5.6", + "eslint-plugin-sonarjs": "^4.2.0", + "eslint-plugin-sort-exports": "^0.9.1", + "globals": "^17.8.0", + "husky": "^9.1.7", + "jsdom": "^29.1.1", + "prettier": "^3.9.5", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", + "typescript-eslint": "^8.65.0", + "vite": "^8.1.5", + "vite-plugin-dts": "^5.0.3", + "vitest": "^4.1.10" + }, + "peerDependencies": { + "howler": "^2.2.4" + } + }, + "node_modules/@forge-game-engine/forge": { + "resolved": "..", + "link": true + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/esbuild-wasm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.28.1.tgz", + "integrity": "sha512-p/GD4E8oYRjg3kjdKrnMb0s4PzXgJF42e0MF4H0+ACyK/kIlFRp3e0fzOleIG+wBBm6MM3XQrbpe7soEA+vJIA==", + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/howler": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", + "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/monaco-editor": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", + "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", + "license": "MIT", + "dependencies": { + "dompurify": "3.4.8", + "marked": "14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/playground/package.json b/playground/package.json new file mode 100644 index 00000000..f4dad454 --- /dev/null +++ b/playground/package.json @@ -0,0 +1,25 @@ +{ + "name": "forge-playground", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@forge-game-engine/forge": "file:..", + "@typescript/vfs": "^1.6.4", + "esbuild-wasm": "^0.28.1", + "fflate": "^0.8.3", + "howler": "^2.2.4", + "monaco-editor": "^0.56.0", + "seedrandom": "^3.0.5" + }, + "devDependencies": { + "typescript": "^5.9.3", + "vite": "^8.1.5" + } +} diff --git a/playground/src/build/bundle-game.ts b/playground/src/build/bundle-game.ts new file mode 100644 index 00000000..8a8e98fc --- /dev/null +++ b/playground/src/build/bundle-game.ts @@ -0,0 +1,69 @@ +import type { Message } from 'esbuild-wasm'; + +import { createForgeResolverPlugin } from './forge-esbuild-plugin.js'; +import { esbuild, ensureEsbuildInitialized } from './esbuild-setup.js'; + +export interface BundleGameResult { + code: string; + warnings: Message[]; +} + +/** + * Thrown when esbuild-wasm fails to bundle the game (a syntax error or an + * unresolvable import). `errors` mirrors esbuild's own diagnostic format so + * callers can show file/line-accurate messages. + */ +export class BundleGameError extends Error { + public readonly errors: Message[]; + + constructor(errors: Message[]) { + super(errors.map((error) => error.text).join('\n') || 'Build failed.'); + this.name = 'BundleGameError'; + this.errors = errors; + } +} + +/** + * Transpiles and bundles a single-file Forge game's TypeScript source into a + * single, self-contained ES module, with Forge itself inlined. Runs + * entirely in the browser via esbuild-wasm. + * @param source - The game's TypeScript source code. + * @returns The bundled JavaScript and any non-fatal warnings. + * @throws {BundleGameError} If the source fails to parse or an import can't be resolved. + */ +export async function bundleGame(source: string): Promise { + await ensureEsbuildInitialized(); + + try { + const result = await esbuild.build({ + stdin: { + contents: source, + loader: 'ts', + sourcefile: 'game.ts', + }, + bundle: true, + write: false, + format: 'esm', + target: 'es2022', + minify: true, + // `seedrandom` (a Forge dependency, used by `Random`) conditionally + // `require`s Node's `crypto` module purely for extra entropy when + // running under Node - dead code in a browser bundle, but esbuild + // still needs to resolve it statically since the `require` sits in a + // CommonJS module. Marking it external leaves the (never-reached) + // call as-is instead of failing the build. + external: ['crypto'], + plugins: [createForgeResolverPlugin()], + }); + + return { code: result.outputFiles[0].text, warnings: result.warnings }; + } catch (error) { + const errors = (error as { errors?: Message[] }).errors; + + if (errors) { + throw new BundleGameError(errors); + } + + throw error; + } +} diff --git a/playground/src/build/download-zip.ts b/playground/src/build/download-zip.ts new file mode 100644 index 00000000..84782ccd --- /dev/null +++ b/playground/src/build/download-zip.ts @@ -0,0 +1,31 @@ +import { strToU8, zipSync } from 'fflate'; + +/** + * Zips the generated `index.html` and bundled `game.js` and triggers a + * browser download, entirely client-side. + */ +export function downloadGameZip( + htmlSource: string, + jsSource: string, + filename = 'forge-game.zip', +): void { + const zipped = zipSync( + { + 'index.html': strToU8(htmlSource), + 'game.js': strToU8(jsSource), + }, + { level: 6 }, + ); + + const blob = new Blob([zipped.buffer as ArrayBuffer], { + type: 'application/zip', + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + + anchor.href = url; + anchor.download = filename; + anchor.click(); + + setTimeout(() => URL.revokeObjectURL(url), 1000); +} diff --git a/playground/src/build/esbuild-setup.ts b/playground/src/build/esbuild-setup.ts new file mode 100644 index 00000000..cf66fb2d --- /dev/null +++ b/playground/src/build/esbuild-setup.ts @@ -0,0 +1,18 @@ +import * as esbuild from 'esbuild-wasm'; +import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'; + +let initializePromise: Promise | null = null; + +/** + * Boots the esbuild-wasm binary exactly once, no matter how many times a + * build is triggered. + */ +export function ensureEsbuildInitialized(): Promise { + if (!initializePromise) { + initializePromise = esbuild.initialize({ wasmURL: esbuildWasmUrl }); + } + + return initializePromise; +} + +export { esbuild }; diff --git a/playground/src/build/forge-esbuild-plugin.ts b/playground/src/build/forge-esbuild-plugin.ts new file mode 100644 index 00000000..651cbf16 --- /dev/null +++ b/playground/src/build/forge-esbuild-plugin.ts @@ -0,0 +1,146 @@ +import type { Plugin, PluginBuild } from 'esbuild-wasm'; + +import { + forgeDistJsFiles, + forgeSubpaths, + resolveForgeSubpath, + seedrandomFiles, +} from '../forge-registry.js'; + +const forgeNamespace = 'forge-dist'; +const seedrandomNamespace = 'npm-seedrandom'; +const forgeSpecifierPrefix = '@forge-game-engine/forge/'; +const forgeSpecifierPattern = /^@forge-game-engine\/forge\//; + +/** + * Resolves a relative import (`./x`, `../x`) against a flat map of a + * package's files, trying the path as given and then the usual Node + * extension/index fallbacks (Forge's own dist imports are always fully + * `.js`-specified, but its `seedrandom` dependency is CommonJS and omits + * extensions). + */ +function resolveRelative( + files: Map, + fromPath: string, + importPath: string, +): string { + const fromDir = fromPath.includes('/') + ? fromPath.slice(0, fromPath.lastIndexOf('/')) + : ''; + const segments = `${fromDir}/${importPath}`.split('/'); + const resolvedSegments: string[] = []; + + for (const segment of segments) { + if (segment === '' || segment === '.') { + continue; + } + + if (segment === '..') { + resolvedSegments.pop(); + continue; + } + + resolvedSegments.push(segment); + } + + const joined = resolvedSegments.join('/'); + const candidates = [joined, `${joined}.js`, `${joined}/index.js`]; + const match = candidates.find((candidate) => files.has(candidate)); + + if (!match) { + throw new Error(`Unable to resolve "${importPath}" from "${fromPath}".`); + } + + return match; +} + +/** + * An esbuild-wasm plugin that resolves `@forge-game-engine/forge/` + * imports (plus Forge's own internal relative imports and its `seedrandom` + * runtime dependency) against Forge's compiled dist output, which is + * embedded into the playground bundle at build time via + * `import.meta.glob`. This lets a user's game `import` Forge exactly as a + * published package would, with esbuild-wasm bundling everything into a + * single, self-contained file entirely in the browser. + */ +export function createForgeResolverPlugin(): Plugin { + return { + name: 'forge-dist-resolver', + setup(build: PluginBuild) { + build.onResolve({ filter: forgeSpecifierPattern }, (args) => { + const subpath = args.path.slice(forgeSpecifierPrefix.length); + const resolved = resolveForgeSubpath(subpath); + + if (!resolved) { + return { + errors: [ + { + text: `"${args.path}" is not a published Forge module. Available: ${forgeSubpaths + .map((available) => `${forgeSpecifierPrefix}${available}`) + .join(', ')}`, + }, + ], + }; + } + + return { path: resolved.jsPath, namespace: forgeNamespace }; + }); + + // `onResolve` callbacks with no `namespace` only fire for importers in + // the default namespace, so Forge's own internal imports (whose + // importer is always in `forgeNamespace`) need their own callback - + // this one handles both Forge's relative imports and its bare + // `seedrandom` dependency. Anything else (e.g. `seedrandom` itself + // conditionally `require`-ing Node's `crypto`) is left unhandled so + // esbuild's own `external` option gets a chance at it instead. + build.onResolve( + { filter: /.*/, namespace: forgeNamespace }, + (args) => { + if (args.path === 'seedrandom') { + return { path: 'index.js', namespace: seedrandomNamespace }; + } + + if (args.path.startsWith('.')) { + return { + path: resolveRelative( + forgeDistJsFiles, + args.importer, + args.path, + ), + namespace: forgeNamespace, + }; + } + + return undefined; + }, + ); + + build.onResolve( + { filter: /.*/, namespace: seedrandomNamespace }, + (args) => { + if (!args.path.startsWith('.')) { + return undefined; + } + + return { + path: resolveRelative(seedrandomFiles, args.importer, args.path), + namespace: seedrandomNamespace, + }; + }, + ); + + build.onLoad({ filter: /.*/, namespace: forgeNamespace }, (args) => ({ + contents: forgeDistJsFiles.get(args.path), + loader: 'js', + })); + + build.onLoad( + { filter: /.*/, namespace: seedrandomNamespace }, + (args) => ({ + contents: seedrandomFiles.get(args.path), + loader: 'js', + }), + ); + }, + }; +} diff --git a/playground/src/build/html-template.ts b/playground/src/build/html-template.ts new file mode 100644 index 00000000..08c3f6ae --- /dev/null +++ b/playground/src/build/html-template.ts @@ -0,0 +1,27 @@ +/** The DOM element id every generated game bootstraps `createGame` against. */ +export const gameContainerId = 'game'; + +/** + * Builds the `index.html` shipped alongside the bundled `game.js` in the + * downloaded zip. + * @param title - The document title. + */ +export function generateIndexHtml(title: string): string { + return ` + + + + + ${title} + + + +
+ + + +`; +} diff --git a/playground/src/default-source.ts b/playground/src/default-source.ts new file mode 100644 index 00000000..febfa515 --- /dev/null +++ b/playground/src/default-source.ts @@ -0,0 +1,86 @@ +/** + * The game the editor opens with: a square that bounces around the screen. + * It only uses Forge's own APIs plus a tiny inlined white square image, so + * it needs no external assets and runs immediately. + */ +export const defaultSource = `import { createGame } from '@forge-game-engine/forge/utilities'; +import { + addPositionComponent, + addRotationComponent, + positionId, + PositionEcsComponent, +} from '@forge-game-engine/forge/common'; +import { + addSpriteComponent, + calculateVisibleWorldSize, + Color, + createCamera, + createImageSprite, + createRenderEcsSystem, +} from '@forge-game-engine/forge/rendering'; +import { EcsSystem } from '@forge-game-engine/forge/ecs'; + +const verticalWorldUnits = 10; +const renderLayer = 1; +const squareSize = 1; +const speed = 4; + +// An 8x8 white square, stretched and tinted into a colored square below - +// no external image assets needed. +const whiteSquare = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAD0lEQVR4nGP4TwAwjAwFAIS1/wF0QtmAAAAAAElFTkSuQmCC'; + +const { game, world, renderContext, time } = createGame('game'); + +createCamera(world, { verticalWorldUnits }); + +const { x: worldWidth, y: worldHeight } = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + verticalWorldUnits, +); +const halfWidth = worldWidth / 2 - squareSize / 2; +const halfHeight = worldHeight / 2 - squareSize / 2; + +const image = await renderContext.imageCache.getOrLoad(whiteSquare); + +const sprite = createImageSprite(image, renderContext, renderLayer, { + frameDimensions: { x: squareSize, y: squareSize }, +}); +sprite.tintColor = new Color(0.36, 0.78, 0.95); + +const square = world.createEntity(); + +addPositionComponent(world, square, { world: { x: 0, y: 0 } }); +addRotationComponent(world, square, { world: 0 }); +addSpriteComponent(world, square, sprite); + +const velocity = { x: speed, y: speed * 0.75 }; + +function createBounceEcsSystem(): EcsSystem<[PositionEcsComponent]> { + return { + query: [positionId], + update: (_, { components: [positions] }) => { + for (const position of positions) { + position.world.x += velocity.x * time.deltaTimeInSeconds; + position.world.y += velocity.y * time.deltaTimeInSeconds; + + if (Math.abs(position.world.x) > halfWidth) { + velocity.x *= -1; + position.world.x = Math.sign(position.world.x) * halfWidth; + } + + if (Math.abs(position.world.y) > halfHeight) { + velocity.y *= -1; + position.world.y = Math.sign(position.world.y) * halfHeight; + } + } + }, + }; +} + +world.addSystem(createBounceEcsSystem()); +world.addSystem(createRenderEcsSystem(renderContext)); + +game.run(); +`; diff --git a/playground/src/editor/create-editor.ts b/playground/src/editor/create-editor.ts new file mode 100644 index 00000000..bc657882 --- /dev/null +++ b/playground/src/editor/create-editor.ts @@ -0,0 +1,65 @@ +import * as monaco from 'monaco-editor'; +import { + ModuleKind, + ModuleResolutionKind, + ScriptTarget, + typescriptDefaults, +} from 'monaco-editor/languages/features/typescript/register.js'; + +import { registerForgeTypes } from './forge-types.js'; +import { configureMonacoWorkers } from './monaco-workers.js'; + +const gameFileUri = monaco.Uri.parse('file:///game.ts'); + +/** + * Creates the Monaco editor the playground's single game file is edited + * in, configured with TypeScript's module resolution rules and Forge's own + * type declarations so autocomplete, hover, and inline diagnostics work + * against Forge's real public API. + * @param container - The element the editor mounts into. + * @param initialSource - The starting contents of the game file. + */ +export function createGameEditor( + container: HTMLElement, + initialSource: string, +): monaco.editor.IStandaloneCodeEditor { + configureMonacoWorkers(); + + const defaults = typescriptDefaults; + + defaults.setCompilerOptions({ + target: ScriptTarget.ESNext, + module: ModuleKind.ESNext, + moduleResolution: ModuleResolutionKind.NodeJs, + esModuleInterop: true, + allowNonTsExtensions: true, + strict: true, + skipLibCheck: true, + lib: ['esnext', 'dom', 'dom.iterable'], + }); + defaults.setEagerModelSync(true); + + registerForgeTypes(); + + const model = monaco.editor.createModel( + initialSource, + 'typescript', + gameFileUri, + ); + + return monaco.editor.create(container, { + model, + theme: 'vs-dark', + automaticLayout: true, + minimap: { enabled: false }, + fontSize: 13, + tabSize: 2, + }); +} + +/** Reads the current game source out of the editor. */ +export function getEditorSource( + editor: monaco.editor.IStandaloneCodeEditor, +): string { + return editor.getModel()?.getValue() ?? ''; +} diff --git a/playground/src/editor/forge-types.ts b/playground/src/editor/forge-types.ts new file mode 100644 index 00000000..c82db8ff --- /dev/null +++ b/playground/src/editor/forge-types.ts @@ -0,0 +1,43 @@ +import { typescriptDefaults } from 'monaco-editor/languages/features/typescript/register.js'; + +import { + forgeDistDtsFiles, + forgeSubpaths, + resolveForgeSubpath, +} from '../forge-registry.js'; + +const packageRoot = 'file:///node_modules/@forge-game-engine/forge'; + +/** + * Registers Forge's type declarations with Monaco's TypeScript language + * service as extra libs, so the editor gives real completions, hover info, + * and diagnostics against `@forge-game-engine/forge/` imports - + * the same public API a published game would use. + */ +export function registerForgeTypes(): void { + const defaults = typescriptDefaults; + + for (const [relativePath, contents] of forgeDistDtsFiles) { + defaults.addExtraLib(contents, `${packageRoot}/dist/${relativePath}`); + } + + // Node's classic module resolution looks for `//index.d.ts`. + // Forge's public subpaths (declared in its package.json `exports` map) + // don't always share a name with the real dist folder they point at (e.g. + // `fsm` -> `finite-state-machine`), so each gets a tiny re-export shim at + // the path resolution actually expects. + for (const subpath of forgeSubpaths) { + const resolved = resolveForgeSubpath(subpath); + + if (!resolved) { + continue; + } + + const jsPath = resolved.dtsPath.replace(/\.d\.ts$/, '.js'); + + defaults.addExtraLib( + `export * from '../dist/${jsPath}';`, + `${packageRoot}/${subpath}/index.d.ts`, + ); + } +} diff --git a/playground/src/editor/monaco-workers.ts b/playground/src/editor/monaco-workers.ts new file mode 100644 index 00000000..c9718673 --- /dev/null +++ b/playground/src/editor/monaco-workers.ts @@ -0,0 +1,27 @@ +import EditorWorker from 'monaco-editor/editor/editor.worker.js?worker'; +import TypeScriptWorker from 'monaco-editor/language/typescript/ts.worker.js?worker'; + +let configured = false; + +/** + * Wires up Monaco's web workers under Vite (Monaco doesn't do this itself - + * it expects a host-provided `MonacoEnvironment.getWorker`). Must run + * before any editor or model is created. + */ +export function configureMonacoWorkers(): void { + if (configured) { + return; + } + + configured = true; + + self.MonacoEnvironment = { + getWorker(_workerId: string, label: string) { + if (label === 'typescript' || label === 'javascript') { + return new TypeScriptWorker(); + } + + return new EditorWorker(); + }, + }; +} diff --git a/playground/src/editor/type-check.ts b/playground/src/editor/type-check.ts new file mode 100644 index 00000000..cbea37ce --- /dev/null +++ b/playground/src/editor/type-check.ts @@ -0,0 +1,128 @@ +import { + createSystem, + createVirtualTypeScriptEnvironment, + type VirtualTypeScriptEnvironment, +} from '@typescript/vfs'; +import ts from 'typescript'; + +import { + forgeDistDtsFiles, + forgeSubpaths, + resolveForgeSubpath, + typescriptLibFiles, +} from '../forge-registry.js'; + +const gameFileName = '/game.ts'; +const packageRoot = '/node_modules/@forge-game-engine/forge'; + +const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Node10, + esModuleInterop: true, + strict: true, + skipLibCheck: true, + lib: ['lib.es2022.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'], +}; + +function buildFsMap(): Map { + const fsMap = new Map(); + + // TypeScript's own lib files reference each other by name (e.g. + // `lib.es2022.full.d.ts` references `lib.es2022.sharedmemory.d.ts`), and + // which exact set a given `target`/`lib` combination needs is an + // implementation detail that shifts between TypeScript versions. Since + // every real `lib*.d.ts` file is already embedded in the app bundle + // regardless, registering all of them sidesteps having to keep that set + // in sync by hand. + for (const [libFileName, contents] of typescriptLibFiles) { + fsMap.set(`/${libFileName}`, contents); + } + + for (const [relativePath, contents] of forgeDistDtsFiles) { + fsMap.set(`${packageRoot}/dist/${relativePath}`, contents); + } + + for (const subpath of forgeSubpaths) { + const resolved = resolveForgeSubpath(subpath); + + if (!resolved) { + continue; + } + + const jsPath = resolved.dtsPath.replace(/\.d\.ts$/, '.js'); + + fsMap.set( + `${packageRoot}/${subpath}/index.d.ts`, + `export * from '../dist/${jsPath}';`, + ); + } + + // Seeded with a non-empty placeholder: `createVirtualLanguageServiceHost` + // treats an empty-string `readFile` result as "file doesn't exist" (falsy + // check), which would make TypeScript report the root file itself as + // missing. + fsMap.set(gameFileName, '\n'); + + return fsMap; +} + +let environment: VirtualTypeScriptEnvironment | null = null; + +function getEnvironment(): VirtualTypeScriptEnvironment { + if (!environment) { + const system = createSystem(buildFsMap()); + + environment = createVirtualTypeScriptEnvironment( + system, + [gameFileName], + ts, + compilerOptions, + ); + } + + return environment; +} + +export interface TypeCheckDiagnostic { + message: string; + line: number; + column: number; +} + +/** + * Type-checks the game source against Forge's real type declarations using + * an in-browser TypeScript language service (via `@typescript/vfs`), + * independent of Monaco's own worker. esbuild only strips types when + * bundling - it never checks them - so this is what actually catches type + * errors before a build. + * @param source - The game's TypeScript source code. + * @returns Every syntactic and semantic diagnostic, sorted by position. + */ +export function typeCheckGameSource(source: string): TypeCheckDiagnostic[] { + const env = getEnvironment(); + + env.updateFile(gameFileName, source); + + const diagnostics = [ + ...env.languageService.getSyntacticDiagnostics(gameFileName), + ...env.languageService.getSemanticDiagnostics(gameFileName), + ]; + + return diagnostics.map((diagnostic) => { + const message = ts.flattenDiagnosticMessageText( + diagnostic.messageText, + '\n', + ); + + if (diagnostic.file && diagnostic.start !== undefined) { + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition( + diagnostic.start, + ); + + return { message, line: line + 1, column: character + 1 }; + } + + return { message, line: 0, column: 0 }; + }); +} diff --git a/playground/src/forge-registry.ts b/playground/src/forge-registry.ts new file mode 100644 index 00000000..776354c9 --- /dev/null +++ b/playground/src/forge-registry.ts @@ -0,0 +1,106 @@ +/** + * Central registry of Forge's built `dist` output and the handful of raw + * TypeScript files the in-browser tooling needs, all embedded into the + * playground bundle at dev/build time via Vite's `import.meta.glob`. This + * gives the Monaco editor and the esbuild-wasm bundler access to Forge's + * actual compiled JS and type declarations without a backend. + */ +import forgePackageJson from '../../package.json'; + +const forgeDistRoot = '/node_modules/@forge-game-engine/forge/dist/'; +const seedrandomRoot = '/node_modules/seedrandom/'; +const typescriptLibRoot = '/node_modules/typescript/lib/'; + +const rawForgeJsFiles = import.meta.glob( + '/node_modules/@forge-game-engine/forge/dist/**/*.js', + { query: '?raw', import: 'default', eager: true }, +) as Record; + +const rawForgeDtsFiles = import.meta.glob( + '/node_modules/@forge-game-engine/forge/dist/**/*.d.ts', + { query: '?raw', import: 'default', eager: true }, +) as Record; + +const rawSeedrandomFiles = import.meta.glob( + [ + '/node_modules/seedrandom/index.js', + '/node_modules/seedrandom/seedrandom.js', + '/node_modules/seedrandom/lib/*.js', + ], + { query: '?raw', import: 'default', eager: true }, +) as Record; + +const rawTypescriptLibFiles = import.meta.glob( + '/node_modules/typescript/lib/lib*.d.ts', + { query: '?raw', import: 'default', eager: true }, +) as Record; + +function stripPrefix( + files: Record, + prefix: string, +): Map { + const map = new Map(); + + for (const [path, contents] of Object.entries(files)) { + map.set(path.slice(prefix.length), contents); + } + + return map; +} + +/** Forge's compiled JS, keyed by path relative to `dist/` (e.g. `ecs/index.js`). */ +export const forgeDistJsFiles = stripPrefix(rawForgeJsFiles, forgeDistRoot); + +/** Forge's type declarations, keyed by path relative to `dist/` (e.g. `ecs/index.d.ts`). */ +export const forgeDistDtsFiles = stripPrefix(rawForgeDtsFiles, forgeDistRoot); + +/** `seedrandom`'s CommonJS source, keyed by path relative to its package root. */ +export const seedrandomFiles = stripPrefix(rawSeedrandomFiles, seedrandomRoot); + +/** TypeScript's own `lib.*.d.ts` files, keyed by bare filename (e.g. `lib.dom.d.ts`). */ +export const typescriptLibFiles = stripPrefix( + rawTypescriptLibFiles, + typescriptLibRoot, +); + +interface ForgeExportEntry { + import: { + types: string; + default: string; + }; +} + +const forgeExports = forgePackageJson.exports as Record< + string, + ForgeExportEntry +>; + +/** + * The public `@forge-game-engine/forge/` import specifiers a game + * may use (e.g. `ecs`, `rendering`, `fsm`), derived from the package's own + * `exports` map so the playground never drifts from what's actually + * published. + */ +export const forgeSubpaths = Object.keys(forgeExports).map((key) => + key.slice('./'.length), +); + +/** + * Resolves a `@forge-game-engine/forge/` import specifier to its + * dist-relative `.js` and `.d.ts` paths, or `null` if the subpath isn't a + * published export. + */ +export function resolveForgeSubpath( + subpath: string, +): { jsPath: string; dtsPath: string } | null { + const entry = forgeExports[`./${subpath}`]; + + if (!entry) { + return null; + } + + return { + jsPath: entry.import.default.replace(/^\.\/dist\//, ''), + dtsPath: entry.import.types.replace(/^\.\/dist\//, ''), + }; +} diff --git a/playground/src/main.ts b/playground/src/main.ts new file mode 100644 index 00000000..cbf19784 --- /dev/null +++ b/playground/src/main.ts @@ -0,0 +1,116 @@ +import { downloadGameZip } from './build/download-zip.js'; +import { generateIndexHtml } from './build/html-template.js'; +import { BundleGameError, bundleGame } from './build/bundle-game.js'; +import { createGameEditor, getEditorSource } from './editor/create-editor.js'; +import { typeCheckGameSource } from './editor/type-check.js'; +import { defaultSource } from './default-source.js'; +import './style.css'; + +const app = document.getElementById('app'); + +if (!app) { + throw new Error('Missing #app root element.'); +} + +app.innerHTML = ` +
+

Forge Playground

+ + +
+
+
+
Build output appears here.
+
+`; + +const editorContainer = document.getElementById('editor-container')!; +const buildButton = document.getElementById( + 'build-button', +) as HTMLButtonElement; +const status = document.getElementById('status')!; +const log = document.getElementById('log')!; + +const editor = createGameEditor(editorContainer, defaultSource); + +function setStatus(message: string): void { + status.textContent = message; +} + +function renderLog(lines: { text: string; kind: 'error' | 'success' }[]): void { + if (lines.length === 0) { + log.innerHTML = 'Build output appears here.'; + + return; + } + + log.innerHTML = lines + .map((line) => `
${escapeHtml(line.text)}
`) + .join(''); +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + + div.textContent = text; + + return div.innerHTML; +} + +async function handleBuild(): Promise { + buildButton.disabled = true; + setStatus('Type-checking...'); + renderLog([]); + + try { + const source = getEditorSource(editor); + const diagnostics = typeCheckGameSource(source); + + if (diagnostics.length > 0) { + setStatus(`${diagnostics.length} type error(s)`); + renderLog( + diagnostics.map((diagnostic) => ({ + kind: 'error', + text: `game.ts:${diagnostic.line}:${diagnostic.column} - ${diagnostic.message}`, + })), + ); + + return; + } + + setStatus('Bundling...'); + + const { code } = await bundleGame(source); + const html = generateIndexHtml('Forge Game'); + + downloadGameZip(html, code); + + setStatus('Downloaded forge-game.zip'); + renderLog([{ kind: 'success', text: 'Build succeeded.' }]); + } catch (error) { + if (error instanceof BundleGameError) { + setStatus(`${error.errors.length} build error(s)`); + renderLog( + error.errors.map((buildError) => ({ + kind: 'error', + text: buildError.location + ? `game.ts:${buildError.location.line}:${buildError.location.column} - ${buildError.text}` + : buildError.text, + })), + ); + + return; + } + + const message = error instanceof Error ? error.message : String(error); + + setStatus('Build failed'); + renderLog([{ kind: 'error', text: message }]); + } finally { + buildButton.disabled = false; + } +} + +buildButton.addEventListener('click', () => { + void handleBuild(); +}); diff --git a/playground/src/style.css b/playground/src/style.css new file mode 100644 index 00000000..6d50d047 --- /dev/null +++ b/playground/src/style.css @@ -0,0 +1,90 @@ +html, +body { + margin: 0; + height: 100%; + background: #1e1e1e; + color: #d4d4d4; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +#app { + display: flex; + flex-direction: column; + height: 100%; +} + +.toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: #252526; + border-bottom: 1px solid #3c3c3c; + flex: 0 0 auto; +} + +.toolbar h1 { + font-size: 14px; + font-weight: 600; + margin: 0; + color: #d4d4d4; +} + +.build-button { + background: #0e639c; + color: white; + border: none; + border-radius: 4px; + padding: 6px 16px; + font-size: 13px; + cursor: pointer; +} + +.build-button:hover { + background: #1177bb; +} + +.build-button:disabled { + background: #3c3c3c; + cursor: default; +} + +.status { + font-size: 12px; + color: #9d9d9d; +} + +.main { + flex: 1 1 auto; + display: flex; + min-height: 0; +} + +#editor-container { + flex: 1 1 auto; + min-width: 0; +} + +.log { + flex: 0 0 260px; + overflow-y: auto; + background: #1e1e1e; + border-left: 1px solid #3c3c3c; + padding: 8px; + font-family: ui-monospace, 'SF Mono', Consolas, monospace; + font-size: 12px; + white-space: pre-wrap; +} + +.log .error { + color: #f48771; + margin-bottom: 6px; +} + +.log .success { + color: #89d185; +} + +.log .empty { + color: #6a6a6a; +} diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 00000000..e57089f3 --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/playground/vite.config.ts b/playground/vite.config.ts new file mode 100644 index 00000000..6c1da592 --- /dev/null +++ b/playground/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + host: '127.0.0.1', + port: 4400, + }, +});