diff --git a/bun.lock b/bun.lock index e5b4333..8d8a253 100644 --- a/bun.lock +++ b/bun.lock @@ -5,25 +5,25 @@ "": { "name": "ragelayer", "devDependencies": { - "@arethetypeswrong/cli": "^0.18.2", - "@biomejs/biome": "^2.4.0", + "@arethetypeswrong/cli": "^0.18.5", + "@biomejs/biome": "^2.5.11", "@changesets/changelog-github": "^1.0.0", "@changesets/cli": "^3.0.1", - "@happy-dom/global-registrator": "^20.11.2", - "@napi-rs/canvas": "^1.0.5", + "@happy-dom/global-registrator": "^21.0.4", + "@napi-rs/canvas": "^1.0.8", "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.3", - "happy-dom": "^20.11.2", + "@types/react-dom": "^19.2.5", + "happy-dom": "^21.0.4", "html-to-image": "^1.11.13", "playwright-core": "^1.62.1", - "publint": "^0.3.14", + "publint": "^0.3.24", "react": "^19.2.8", "react-dom": "^19.2.8", "tsdown": "^0.22.14", "typescript": "^7.0.2", - "vite": "6.4.3", + "vite": "6.4.5", "vitepress": "^1.6.4", - "vue": "^3.5.41", + "vue": "3.5.44", }, "peerDependencies": { "react": ">=18", @@ -38,7 +38,11 @@ }, }, "overrides": { - "vite": "6.4.3", + "launch-editor": "^2.14.2", + "shell-quote": "^1.11.0", + "source-map-js": "^1.2.2", + "vite": "6.4.5", + "vue": "3.5.44", }, "packages": { "@algolia/abtesting": ["@algolia/abtesting@1.22.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g=="], @@ -83,13 +87,13 @@ "@arethetypeswrong/core": ["@arethetypeswrong/core@0.18.5", "", { "dependencies": { "@andrewbranch/untar.js": "^1.0.3", "@loaderkit/resolve": "^1.0.2", "cjs-module-lexer": "^1.2.3", "fflate": "^0.8.3", "lru-cache": "^11.0.1", "semver": "^7.5.4", "typescript": "5.6.1-rc", "validate-npm-package-name": "^5.0.0" } }, "sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.13", "", {}, "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + "@babel/parser": ["@babel/parser@7.29.13", "", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": "./bin/babel-parser.js" }, "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ=="], - "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@babel/types": ["@babel/types@7.29.11", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="], "@biomejs/biome": ["@biomejs/biome@2.5.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.11", "@biomejs/cli-darwin-x64": "2.5.11", "@biomejs/cli-linux-arm64": "2.5.11", "@biomejs/cli-linux-arm64-musl": "2.5.11", "@biomejs/cli-linux-x64": "2.5.11", "@biomejs/cli-linux-x64-musl": "2.5.11", "@biomejs/cli-win32-arm64": "2.5.11", "@biomejs/cli-win32-x64": "2.5.11" }, "bin": { "biome": "bin/biome" } }, "sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA=="], @@ -209,7 +213,7 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.11.15", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.11.15" } }, "sha512-pr1k24oG6T+tTnc+axwaexmuteylDIjAex/1bH2Tm2eaKUIXmg4sGKlCIAPUtBQOOaL2cXU1NbPxiu5V158VFA=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^21.0.4" } }, "sha512-Bbr0hW9hhDhBf3Jp0EyDlkCbFgbr0z2jd3BMKYOYW+fOkPM4Nx195GMXyCtRCGEgoNn6i09FabXrSkNFm2ONzg=="], "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.93", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw=="], @@ -427,13 +431,13 @@ "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], - "@vue/compiler-core": ["@vue/compiler-core@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.42", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="], - "@vue/compiler-dom": ["@vue/compiler-dom@3.5.42", "", { "dependencies": { "@vue/compiler-core": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA=="], + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="], - "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.42", "@vue/compiler-dom": "3.5.42", "@vue/compiler-ssr": "3.5.42", "@vue/shared": "3.5.42", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw=="], + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="], - "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA=="], + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="], "@vue/devtools-api": ["@vue/devtools-api@7.7.10", "", { "dependencies": { "@vue/devtools-kit": "^7.7.10" } }, "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA=="], @@ -441,13 +445,13 @@ "@vue/devtools-shared": ["@vue/devtools-shared@7.7.10", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ=="], - "@vue/reactivity": ["@vue/reactivity@3.5.42", "", { "dependencies": { "@vue/shared": "3.5.42" } }, "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ=="], + "@vue/reactivity": ["@vue/reactivity@3.5.44", "", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="], - "@vue/runtime-core": ["@vue/runtime-core@3.5.42", "", { "dependencies": { "@vue/reactivity": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA=="], + "@vue/runtime-core": ["@vue/runtime-core@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="], - "@vue/runtime-dom": ["@vue/runtime-dom@3.5.42", "", { "dependencies": { "@vue/reactivity": "3.5.42", "@vue/runtime-core": "3.5.42", "@vue/shared": "3.5.42", "csstype": "^3.2.3" } }, "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g=="], + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="], - "@vue/server-renderer": ["@vue/server-renderer@3.5.42", "", { "dependencies": { "@vue/compiler-ssr": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ=="], + "@vue/server-renderer": ["@vue/server-renderer@3.5.44", "", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="], "@vue/shared": ["@vue/shared@3.5.41", "", {}, "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA=="], @@ -603,7 +607,7 @@ "get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], - "happy-dom": ["happy-dom@20.11.15", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-bj2gQIKzOYB6GAAJ/jrN/IFHQe79MhEOiMAspRRWiKebRoegvGDv2B6eUt8KiZMTEDvx6ri0fAF7Xh/umAUUZA=="], + "happy-dom": ["happy-dom@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -633,7 +637,7 @@ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "launch-editor": ["launch-editor@2.14.1", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.8.4" } }, "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA=="], + "launch-editor": ["launch-editor@2.14.2", "", { "dependencies": { "picocolors": "^1.1.1", "shell-quote": "^1.10.0" } }, "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ=="], "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], @@ -729,7 +733,7 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + "shell-quote": ["shell-quote@1.12.0", "", {}, "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="], "shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="], @@ -737,7 +741,7 @@ "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], @@ -799,11 +803,11 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + "vite": ["vite@6.4.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg=="], "vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3", "postcss"], "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="], - "vue": ["vue@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/server-renderer": "3.5.42", "@vue/shared": "3.5.42" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA=="], + "vue": ["vue@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], @@ -829,29 +833,25 @@ "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], - "@vue/compiler-core/@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], + "@vue/compiler-core/@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], - "@vue/compiler-dom/@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], + "@vue/compiler-dom/@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], - "@vue/compiler-sfc/@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], + "@vue/compiler-sfc/@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], - "@vue/compiler-ssr/@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], + "@vue/compiler-sfc/postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="], - "@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], - - "@vue/reactivity/@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], - - "@vue/runtime-core/@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], + "@vue/compiler-ssr/@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], - "@vue/runtime-dom/@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], + "@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], - "@vue/server-renderer/@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], + "@vue/reactivity/@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], - "@vueuse/core/vue": ["vue@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/server-renderer": "3.5.41", "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg=="], + "@vue/runtime-core/@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], - "@vueuse/integrations/vue": ["vue@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/server-renderer": "3.5.41", "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg=="], + "@vue/runtime-dom/@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], - "@vueuse/shared/vue": ["vue@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/server-renderer": "3.5.41", "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg=="], + "@vue/server-renderer/@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], "marked-terminal/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -859,88 +859,8 @@ "strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "vitepress/vue": ["vue@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/server-renderer": "3.5.41", "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg=="], - - "vue/@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="], - - "@vueuse/core/vue/@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="], - - "@vueuse/core/vue/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="], - - "@vueuse/core/vue/@vue/runtime-dom": ["@vue/runtime-dom@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/runtime-core": "3.5.41", "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw=="], - - "@vueuse/core/vue/@vue/server-renderer": ["@vue/server-renderer@3.5.41", "", { "dependencies": { "@vue/compiler-ssr": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ=="], - - "@vueuse/integrations/vue/@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="], - - "@vueuse/integrations/vue/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="], - - "@vueuse/integrations/vue/@vue/runtime-dom": ["@vue/runtime-dom@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/runtime-core": "3.5.41", "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw=="], - - "@vueuse/integrations/vue/@vue/server-renderer": ["@vue/server-renderer@3.5.41", "", { "dependencies": { "@vue/compiler-ssr": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ=="], - - "@vueuse/shared/vue/@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="], - - "@vueuse/shared/vue/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="], - - "@vueuse/shared/vue/@vue/runtime-dom": ["@vue/runtime-dom@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/runtime-core": "3.5.41", "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw=="], - - "@vueuse/shared/vue/@vue/server-renderer": ["@vue/server-renderer@3.5.41", "", { "dependencies": { "@vue/compiler-ssr": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ=="], - - "vitepress/vue/@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="], - - "vitepress/vue/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="], - - "vitepress/vue/@vue/runtime-dom": ["@vue/runtime-dom@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/runtime-core": "3.5.41", "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw=="], - - "vitepress/vue/@vue/server-renderer": ["@vue/server-renderer@3.5.41", "", { "dependencies": { "@vue/compiler-ssr": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ=="], - - "@vueuse/core/vue/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="], - - "@vueuse/core/vue/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="], - - "@vueuse/core/vue/@vue/compiler-sfc/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="], - - "@vueuse/core/vue/@vue/runtime-dom/@vue/reactivity": ["@vue/reactivity@3.5.41", "", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="], - - "@vueuse/core/vue/@vue/runtime-dom/@vue/runtime-core": ["@vue/runtime-core@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg=="], - - "@vueuse/core/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="], - - "@vueuse/integrations/vue/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="], - - "@vueuse/integrations/vue/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="], - - "@vueuse/integrations/vue/@vue/compiler-sfc/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="], - - "@vueuse/integrations/vue/@vue/runtime-dom/@vue/reactivity": ["@vue/reactivity@3.5.41", "", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="], - - "@vueuse/integrations/vue/@vue/runtime-dom/@vue/runtime-core": ["@vue/runtime-core@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg=="], - - "@vueuse/integrations/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="], - - "@vueuse/shared/vue/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="], - - "@vueuse/shared/vue/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="], - - "@vueuse/shared/vue/@vue/compiler-sfc/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="], - - "@vueuse/shared/vue/@vue/runtime-dom/@vue/reactivity": ["@vue/reactivity@3.5.41", "", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="], - - "@vueuse/shared/vue/@vue/runtime-dom/@vue/runtime-core": ["@vue/runtime-core@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg=="], - - "@vueuse/shared/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="], - - "vitepress/vue/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="], - - "vitepress/vue/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="], - - "vitepress/vue/@vue/compiler-sfc/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="], - - "vitepress/vue/@vue/runtime-dom/@vue/reactivity": ["@vue/reactivity@3.5.41", "", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="], - - "vitepress/vue/@vue/runtime-dom/@vue/runtime-core": ["@vue/runtime-core@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg=="], + "vue/@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], - "vitepress/vue/@vue/server-renderer/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="], + "@vue/compiler-sfc/postcss/nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], } } diff --git a/package.json b/package.json index 4042f99..3572aee 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,11 @@ "access": "public" }, "overrides": { - "vite": "6.4.3" + "vite": "6.4.5", + "launch-editor": "^2.14.2", + "shell-quote": "^1.11.0", + "source-map-js": "^1.2.2", + "vue": "3.5.44" }, "scripts": { "build": "tsdown", @@ -208,11 +212,11 @@ "@biomejs/biome": "^2.5.11", "@changesets/changelog-github": "^1.0.0", "@changesets/cli": "^3.0.1", - "@happy-dom/global-registrator": "^20.11.15", + "@happy-dom/global-registrator": "^21.0.4", "@napi-rs/canvas": "^1.0.8", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", - "happy-dom": "^20.11.15", + "happy-dom": "^21.0.4", "html-to-image": "^1.11.13", "playwright-core": "^1.62.1", "publint": "^0.3.24", @@ -220,8 +224,8 @@ "react-dom": "^19.2.8", "tsdown": "^0.22.14", "typescript": "^7.0.2", - "vite": "6.4.3", + "vite": "6.4.5", "vitepress": "^1.6.4", - "vue": "^3.5.42" + "vue": "3.5.44" } } diff --git a/scripts/browser-test.mjs b/scripts/browser-test.mjs index 30f09ee..ff94982 100644 --- a/scripts/browser-test.mjs +++ b/scripts/browser-test.mjs @@ -126,7 +126,11 @@ const checks = [ name: "the live DOM is captured into a destructible surface", async run(run) { const status = await run("__dd.engine.captureStatus"); - assert(status === "snapshot", `capture status was "${status}", expected "snapshot"`); + // Chrome 157+ ships the live capture API, so "auto" mode may pick it. + assert( + status === "snapshot" || status === "live", + `capture status was "${status}", expected "snapshot" or "live"`, + ); assert(await run("Boolean(__dd.engine.content)"), "no content layer after capture"); assert( await run("__dd.engine.pageOpacityAt(400, 400) === 1"), diff --git a/scripts/check-coverage.mjs b/scripts/check-coverage.mjs index 11a28d4..ee2c97a 100644 --- a/scripts/check-coverage.mjs +++ b/scripts/check-coverage.mjs @@ -40,9 +40,7 @@ const BROWSER_ONLY = new Map([ * the global line ratchet enforced; skip only the corrupted per-file line * value until Bun merges instances correctly. */ -const BUN_LCOV_LINE_EXEMPT = new Map([ - ["src/pointer-input.ts", "direct unit and engine tests; duplicated transpiled-module line map"], -]); +const BUN_LCOV_LINE_EXEMPT = new Map(); function parseLcov(text) { const files = []; diff --git a/src/advanced-toolart.ts b/src/advanced-toolart.ts index feca93a..34fab4a 100644 --- a/src/advanced-toolart.ts +++ b/src/advanced-toolart.ts @@ -235,6 +235,7 @@ export const stickyBombArt: ToolArtFn = (ctx, state) => { ctx.restore(); }; +// Measured icon silhouettes; see the note in ./toolart/base.ts. registerToolIconBounds(gravityGunArt, [40, 45, 140, 150]); registerToolIconBounds(laserCutterArt, [34, 40, 135, 140]); registerToolIconBounds(acidSprayerArt, [55, 57, 119, 139]); diff --git a/src/advanced-tools.ts b/src/advanced-tools.ts index a9b4a4a..5c952e9 100644 --- a/src/advanced-tools.ts +++ b/src/advanced-tools.ts @@ -1,5 +1,5 @@ import { acidSprayerArt, gravityGunArt, laserCutterArt, stickyBombArt } from "./advanced-toolart"; -import { TAU } from "./math"; +import { PARKED_POINTER, TAU } from "./math"; import { createEngineState } from "./tool-kit"; import { includeTopologySegment, surfaceRuns, type TopologyBounds } from "./topology"; import type { RageLayerEngineApi, Tool } from "./types"; @@ -445,7 +445,7 @@ export const stickyBombs: Tool = { } }, backgroundTick(engine, dt) { - stickyBombs.tick?.(engine, dt, false, { x: -1000, y: -1000 }); + stickyBombs.tick?.(engine, dt, false, { x: PARKED_POINTER, y: PARKED_POINTER }); }, }; diff --git a/src/audio.ts b/src/audio.ts index 63d804e..f7ac608 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -1,5 +1,40 @@ import type { SoundApi } from "./types"; +type LoopName = Parameters[0]; + +interface LoopVoice { + filter: BiquadFilterType; + frequency: number; + q?: number; + playbackRate?: number; + /** Re-randomise the filter frequency within `[base, base + span)` on every update. */ + jitter?: { base: number; span: number }; + /** Sounds only while a tool is held, so a released or cancelled gesture fades it. */ + held: boolean; +} + +/** How each continuous loop is filtered out of the shared noise buffer. */ +const LOOP_VOICES: Record = { + // Random crackle keeps the fire organic. + fire: { + filter: "lowpass", + frequency: 500, + playbackRate: 0.35, + jitter: { base: 350, span: 500 }, + held: false, + }, + water: { filter: "highpass", frequency: 2500, held: true }, + saw: { filter: "bandpass", frequency: 1400, q: 6, playbackRate: 1.6, held: true }, + flamethrower: { filter: "bandpass", frequency: 700, q: 0.4, playbackRate: 0.6, held: true }, + // Sub-bass rumble: a singularity should be felt more than heard. + void: { filter: "lowpass", frequency: 110, q: 8, playbackRate: 0.2, held: true }, +}; + +/** The loops a held tool drives, as opposed to the ambient crackle of a burning page. */ +export const HELD_TOOL_LOOPS = (Object.keys(LOOP_VOICES) as LoopName[]).filter( + (name) => LOOP_VOICES[name].held, +); + /** * Fully procedural sound effects — no audio assets, so the package stays a * zero-download dependency. Everything is synthesized from noise buffers and @@ -293,6 +328,9 @@ export class SoundEngine implements SoundApi { } const ctx = this.ensure(); if (!ctx || !this.master || !this.noiseBuffer) return; + // `loop` is reachable from untyped custom tools; an unknown name keeps the + // generic saw voice it always fell back to instead of throwing mid-frame. + const voice = LOOP_VOICES[name] ?? LOOP_VOICES.saw; if (!entry) { const source = ctx.createBufferSource(); source.buffer = this.noiseBuffer; @@ -300,30 +338,10 @@ export class SoundEngine implements SoundApi { const filter = ctx.createBiquadFilter(); const gain = ctx.createGain(); gain.gain.value = 0; - if (name === "fire") { - filter.type = "lowpass"; - filter.frequency.value = 500; - source.playbackRate.value = 0.35; - } else if (name === "water") { - filter.type = "highpass"; - filter.frequency.value = 2500; - } else if (name === "void") { - // Sub-bass rumble: a singularity should be felt more than heard. - filter.type = "lowpass"; - filter.frequency.value = 110; - filter.Q.value = 8; - source.playbackRate.value = 0.2; - } else if (name === "flamethrower") { - filter.type = "bandpass"; - filter.frequency.value = 700; - filter.Q.value = 0.4; - source.playbackRate.value = 0.6; - } else { - filter.type = "bandpass"; - filter.frequency.value = 1400; - filter.Q.value = 6; - source.playbackRate.value = 1.6; - } + filter.type = voice.filter; + filter.frequency.value = voice.frequency; + if (voice.q !== undefined) filter.Q.value = voice.q; + if (voice.playbackRate !== undefined) source.playbackRate.value = voice.playbackRate; source.connect(filter).connect(gain).connect(this.master); source.start(); entry = { source, gain, filter, stopTimer: null }; @@ -334,9 +352,13 @@ export class SoundEngine implements SoundApi { entry.stopTimer = null; } entry.gain.gain.setTargetAtTime(target, ctx.currentTime, 0.08); - if (name === "fire") { - // Random crackle: jitter the filter to keep the loop organic. - entry.filter.frequency.setTargetAtTime(350 + Math.random() * 500, ctx.currentTime, 0.05); + const { jitter } = voice; + if (jitter) { + entry.filter.frequency.setTargetAtTime( + jitter.base + Math.random() * jitter.span, + ctx.currentTime, + 0.05, + ); } } diff --git a/src/bugs-sim.ts b/src/bugs-sim.ts index 154b291..e90105a 100644 --- a/src/bugs-sim.ts +++ b/src/bugs-sim.ts @@ -14,7 +14,6 @@ import { TAU } from "./math"; import type { ContentApi, Flame, Particle, SoundApi } from "./types"; -/** Population cap. */ const MAX_BUGS = 36; /** One crawling bug: position, heading, and an appetite timer. */ @@ -91,7 +90,6 @@ export class BugSwarm { seed: Math.random() * TAU, }); } - // At the population cap nothing was released — so nothing chirps either. if (this.bugs.length === before) return false; host.sound.pop(); return true; @@ -107,8 +105,6 @@ export class BugSwarm { if (dx * dx + dy * dy > r2) continue; this.bugs.splice(i, 1); squashed++; - // The smear a squashed bug leaves. Drawn through surfaceCtx so it - // persists like any other decal. const ctx = host.surfaceCtx; ctx.save(); // The smear is on the page, so it clips to the page — a bug squashed at @@ -152,7 +148,6 @@ export class BugSwarm { if (dx * dx + dy * dy > r2) continue; this.bugs.splice(i, 1); flushed++; - // The bug itself, tumbling downstream and off the heap... host.spawnParticle({ kind: "debris", x: b.x, @@ -168,7 +163,6 @@ export class BugSwarm { bounce: 0.2, restY: b.y + 180 + Math.random() * 220, }); - // ...in a burst of the water that took it. for (let s = 0; s < 5; s++) { const a = Math.random() * TAU; host.spawnParticle({ @@ -242,7 +236,6 @@ export class BugSwarm { this.bugs.splice(i, 1); continue; } - // Skittering: constant jitter plus an occasional decisive turn. b.turn -= dt; if (b.turn <= 0) { b.turn = 0.5 + Math.random() * 1.6; @@ -251,7 +244,6 @@ export class BugSwarm { b.a += (Math.random() - 0.5) * 3.4 * dt; b.x += Math.cos(b.a) * b.speed * dt; b.y += Math.sin(b.a) * b.speed * dt; - // Turn back at the page edge instead of wandering into the void. if (b.x < 4 || b.x > host.width - 4 || b.y < 4 || b.y > host.height - 4) { b.a += Math.PI; b.x = Math.max(4, Math.min(host.width - 4, b.x)); @@ -284,7 +276,6 @@ export class BugSwarm { ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(b.a + wiggle * 0.2); - // Legs first, three per side, alternating with the gait. ctx.strokeStyle = "rgba(20, 14, 8, 0.9)"; ctx.lineWidth = 0.8; ctx.beginPath(); @@ -297,7 +288,6 @@ export class BugSwarm { ctx.lineTo(lx - phase * b.size * 0.4, -b.size * 0.9); } ctx.stroke(); - // Two body segments and a head. ctx.fillStyle = "rgba(38, 26, 14, 0.95)"; ctx.beginPath(); ctx.ellipse(-b.size * 0.35, 0, b.size * 0.62, b.size * 0.45, 0, 0, TAU); diff --git a/src/content.ts b/src/content.ts index 50f5483..eada822 100644 --- a/src/content.ts +++ b/src/content.ts @@ -435,10 +435,9 @@ export class ContentLayer { return; } - // Inset roots still need composition: backdrop across the whole layer, - // then the raster at the root's document offset. Anything outside the - // root's box (body margins, a reserved scrollbar gutter) keeps the page - // backdrop instead of falling through to the void. + // Inset roots: anything outside the root's box (body margins, a reserved + // scrollbar gutter) keeps the page backdrop instead of falling through to + // the void. const snap = document.createElement("canvas"); snap.width = pixelWidth; snap.height = pixelHeight; @@ -571,7 +570,6 @@ export class ContentLayer { ctx.globalCompositeOperation = "source-over"; } ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); - // Every pixel in the band was replaced; upload exactly that band. this.renderer.markDirty(0, y0, w, y1); } @@ -650,7 +648,6 @@ export class ContentLayer { rect.h = h; } - /** Drop the live buffers, releasing their backing stores immediately. */ private releaseLayers() { for (const canvas of [this.wounds, this.decals]) { if (!canvas) continue; @@ -898,8 +895,6 @@ export class ContentLayer { } /** - * Remove `path` from the content. - * * The randomised geometry is built into a `Path2D` once and replayed against * both targets, so the visible canvas (immediate feedback) and the wound mask * (which survives a live refresh) can never drift apart. @@ -926,7 +921,6 @@ export class ContentLayer { if (!this.ready) return; const path = new Path2D(); path.arc(x, y, r, 0, TAU); - // Ragged rim bites, batched into the same path as the core. for (let i = 0; i < 7; i++) { const a = Math.random() * TAU; const d = r * rand(0.7, 1.1); diff --git a/src/decals.ts b/src/decals.ts index 8d7ac0d..bfcb5ab 100644 --- a/src/decals.ts +++ b/src/decals.ts @@ -46,21 +46,26 @@ const STAMPS_PER_SPRITE = 12; const MAX_STAMP_HALF = 160; let stampCache: WeakMap> | null = null; /** - * Device scale (dpr) per context, read once — `getTransform` allocates a - * DOMMatrix, which the per-frame scorch path cannot afford. Damage targets - * keep a fixed dpr transform for their lifetime, and `hypot(a, b)` makes the - * read indifferent to any rotation active at the time. + * Device scale (dpr) per context — `getTransform` allocates a DOMMatrix, which + * the per-frame scorch path cannot afford. A target only changes its dpr by + * resizing its canvas (which also resets the transform), so the cached read is + * keyed on the canvas size too. `hypot(a, b)` makes the read indifferent to any + * rotation active at the time. */ -let ctxScale: WeakMap | null = null; +let ctxScale: WeakMap< + CanvasRenderingContext2D, + { scale: number; width: number; height: number } +> | null = null; function deviceScale(ctx: CanvasRenderingContext2D): number { - let scale = (ctxScale ??= new WeakMap()).get(ctx); - if (scale === undefined) { + const { width, height } = ctx.canvas; + let cached = (ctxScale ??= new WeakMap()).get(ctx); + if (cached === undefined || cached.width !== width || cached.height !== height) { const m = ctx.getTransform(); - scale = Math.hypot(m.a, m.b) || 1; - ctxScale.set(ctx, scale); + cached = { scale: Math.hypot(m.a, m.b) || 1, width, height }; + ctxScale.set(ctx, cached); } - return scale; + return cached.scale; } /** @@ -188,7 +193,6 @@ export function drawCrack( for (let i = 0; i < branches; i++) { const angle = (i / branches) * TAU + rand(-0.6, 0.6); - // Bias lengthens the cracks pointing away from the previous impact. const pull = bias === undefined ? 1 : 1 + 0.85 * Math.cos(angle - bias); const total = span * rand(0.5, 1) * pull; const segs = 6; @@ -203,11 +207,9 @@ export function drawCrack( a += rand(-0.55, 0.55); px += Math.cos(a) * segLen; py += Math.sin(a) * segLen; - // The inner half is the wide fault; the outer half thins into a hairline. const path = s < segs / 2 ? trunk : fine; path.lineTo(px, py); if (s === Math.floor(segs / 2) - 1) fine.moveTo(px, py); - // Forks: a crack splits and the fork wanders off on its own. if (Math.random() < 0.4) { const forkA = a + rand(0.5, 1.1) * (Math.random() < 0.5 ? 1 : -1); const forkLen = segLen * rand(0.5, 1.2); @@ -219,7 +221,6 @@ export function drawCrack( fractureStroke(ctx, trunk, rand(1.6, 2.6) * scale, 0.72, 0.16); fractureStroke(ctx, fine, rand(0.7, 1.1) * scale, 0.55, 0.1); - // Pulverized centre: chips of displaced plaster around the point of impact. for (let i = 0; i < 9; i++) { const a = Math.random() * TAU; const d = rand(2, 13) * scale; @@ -231,47 +232,6 @@ export function drawCrack( ctx.restore(); } -/** - * Radial fracture for brittle sheets such as glass and ice. - * - * Unlike the wandering plaster faults above, a brittle impact carries straight - * rays away from the contact point and joins them with uneven circumferential - * cracks. There is deliberately no opaque dent or powder halo: neither belongs - * on a transparent pane and both obscure the fracture geometry. - */ -export function drawBrittleFracture( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - radius = 42, -) { - const reach = Math.max(12, radius); - const radial = new Path2D(); - const ring = new Path2D(); - - ctx.save(); - ctx.globalCompositeOperation = "source-atop"; - ctx.translate(x, y); - ctx.rotate(Math.random() * TAU); - - for (let index = 0; index < 10; index++) { - const angle = (index / 10) * TAU + rand(-0.12, 0.12); - const length = reach * rand(0.72, 1.08); - radial.moveTo(Math.cos(angle) * 3, Math.sin(angle) * 3); - radial.lineTo(Math.cos(angle) * length, Math.sin(angle) * length); - const ringRadius = reach * rand(0.44, 0.58); - const px = Math.cos(angle) * ringRadius; - const py = Math.sin(angle) * ringRadius; - if (index === 0) ring.moveTo(px, py); - else ring.lineTo(px, py); - } - ring.closePath(); - - fractureStroke(ctx, radial, 1.3, 0.72, 0.38); - fractureStroke(ctx, ring, 0.75, 0.48, 0.3); - ctx.restore(); -} - /** Layered bullet hole: dark core, ragged rim, short plaster cracks around it. */ export function drawBulletHole(ctx: CanvasRenderingContext2D, x: number, y: number, scale = 1) { ctx.save(); @@ -279,7 +239,6 @@ export function drawBulletHole(ctx: CanvasRenderingContext2D, x: number, y: numb ctx.translate(x, y); ctx.rotate(Math.random() * TAU); - // Pale impact halo — powdered page around the entry wound. stamp(ctx, sprites().dust, 0, 0, 26 * scale, 26 * scale, 0.16); ctx.globalAlpha = 1; @@ -298,7 +257,6 @@ export function drawBulletHole(ctx: CanvasRenderingContext2D, x: number, y: numb } fractureStroke(ctx, radial, rand(0.9, 1.7) * scale, 0.6, 0.16); - // Ragged rim — grey ring of overlapping blobs. for (let i = 0; i < 10; i++) { const angle = (i / 10) * TAU; const r = rand(4.5, 6.5) * scale; @@ -308,11 +266,9 @@ export function drawBulletHole(ctx: CanvasRenderingContext2D, x: number, y: numb ctx.fill(); } - // Dark core with soft edge. stamp(ctx, sprites().bulletCore, 0, 0, 6 * scale, 6 * scale, 0.98); ctx.globalAlpha = 1; - // Glint on the rim. ctx.fillStyle = "rgba(255, 255, 255, 0.25)"; ctx.beginPath(); ctx.arc(-2 * scale, -3 * scale, 1.2 * scale, 0, TAU); @@ -379,59 +335,6 @@ export function drawBurnChannel(ctx: CanvasRenderingContext2D, points: number[]) ctx.restore(); } -/** - * A shallow elastic or metallic depression. Unlike `drawCrack`, this keeps the - * surface continuous: rubber compresses and sheet metal buckles before either - * material tears. - */ -export function drawDent(ctx: CanvasRenderingContext2D, x: number, y: number, radius: number) { - ctx.save(); - ctx.globalCompositeOperation = "source-atop"; - ctx.translate(x, y); - ctx.scale(1, 0.64); - const shade = ctx.createRadialGradient(0, 0, radius * 0.08, 0, 0, radius); - shade.addColorStop(0, "rgba(4, 7, 10, 0.34)"); - shade.addColorStop(0.58, "rgba(12, 15, 18, 0.16)"); - shade.addColorStop(0.78, "rgba(255, 255, 255, 0.1)"); - shade.addColorStop(1, "rgba(255, 255, 255, 0)"); - ctx.fillStyle = shade; - ctx.beginPath(); - ctx.arc(0, 0, radius, 0, TAU); - ctx.fill(); - ctx.strokeStyle = "rgba(5, 7, 9, 0.3)"; - ctx.lineWidth = 1.4; - ctx.beginPath(); - ctx.arc(0, 0, radius * 0.72, Math.PI * 0.12, Math.PI * 0.88); - ctx.stroke(); - ctx.restore(); -} - -/** A continuous abrasion where a cutting edge skids across a surface it cannot sever. */ -export function drawSurfaceScore( - ctx: CanvasRenderingContext2D, - x1: number, - y1: number, - x2: number, - y2: number, -) { - ctx.save(); - ctx.globalCompositeOperation = "source-atop"; - ctx.lineCap = "round"; - ctx.strokeStyle = "rgba(18, 20, 22, 0.58)"; - ctx.lineWidth = 2.4; - ctx.beginPath(); - ctx.moveTo(x1, y1); - ctx.lineTo(x2, y2); - ctx.stroke(); - ctx.strokeStyle = "rgba(255, 255, 255, 0.22)"; - ctx.lineWidth = 0.9; - ctx.beginPath(); - ctx.moveTo(x1, y1 + 1.25); - ctx.lineTo(x2, y2 + 1.25); - ctx.stroke(); - ctx.restore(); -} - /** * One chainsaw gash segment between two points. * @@ -483,13 +386,11 @@ export function drawGash( ); ctx.fillStyle = `rgba(228, 222, 212, ${rand(0.3, 0.62)})`; ctx.fill(); - // Shadowed underside of the curl. ctx.strokeStyle = `rgba(16, 12, 9, ${rand(0.35, 0.6)})`; ctx.lineWidth = 1; ctx.stroke(); } - // Dark torn core. ctx.strokeStyle = "rgba(12, 9, 7, 0.9)"; ctx.lineWidth = rand(5, 9); ctx.lineCap = "round"; @@ -498,7 +399,6 @@ export function drawGash( ctx.lineTo(x2, y2); ctx.stroke(); - // Jagged fibres: short ticks perpendicular to the cut. const ticks = Math.max(2, Math.floor(len / 4)); ctx.strokeStyle = "rgba(214, 206, 194, 0.34)"; ctx.lineWidth = 1; @@ -577,7 +477,6 @@ export function drawSplat( ctx.fill(); } - // Opaque core. ctx.fillStyle = base; ctx.globalAlpha = 0.94; for (let i = 0; i < 7; i++) { @@ -588,7 +487,6 @@ export function drawSplat( ctx.fill(); } - // Satellite droplets flung outward, with teardrop tails aimed back at impact. const sats = 12 + Math.floor(Math.random() * 10); for (let i = 0; i < sats; i++) { const a = Math.random() * TAU; @@ -641,7 +539,6 @@ export function drawPaintStreak( ctx.beginPath(); ctx.ellipse(x, y - length / 2, width * 0.5, length / 2, 0, 0, TAU); ctx.fill(); - // Bead at the bottom, where the run pooled and stopped. ctx.beginPath(); ctx.arc(x, y, width * 0.85, 0, TAU); ctx.fill(); diff --git a/src/default-tools.ts b/src/default-tools.ts index b18fcbe..92f9462 100644 --- a/src/default-tools.ts +++ b/src/default-tools.ts @@ -1,12 +1,8 @@ import { advancedTools } from "./advanced-tools"; import { heavyTools } from "./heavy-tools"; +import { officialToolOrder } from "./tool-order"; import { baseTools } from "./tools"; import type { Tool } from "./types"; /** Complete built-in toolset in the order used by the official toolbar. */ -export const defaultTools: Tool[] = [ - ...baseTools.slice(0, -1), - ...heavyTools, - ...advancedTools, - baseTools.at(-1)!, -]; +export const defaultTools: Tool[] = officialToolOrder(baseTools, heavyTools, advancedTools); diff --git a/src/element.ts b/src/element.ts index de82420..5f23797 100644 --- a/src/element.ts +++ b/src/element.ts @@ -16,10 +16,11 @@ import { RAGELAYER_IGNORE_ATTR } from "./capture"; import { defaultTools } from "./default-tools"; -import { RageLayerEngine } from "./engine"; +import type { RageLayerEngine } from "./engine"; import type { RageLayerStrings } from "./strings"; -import { type ToolbarButton, ToolbarModel, type ToolbarState } from "./toolbar"; +import type { ToolbarButton, ToolbarModel, ToolbarState } from "./toolbar"; import { toolbarIconElement } from "./toolbar-icons"; +import { openToolbarSession, rovingFocusTarget, type ToolbarSession } from "./toolbar-session"; import type { RageLayerEngineOptions, Tool } from "./types"; export const TAG_NAME = "rage-layer"; @@ -210,14 +211,12 @@ const ElementBase: typeof HTMLElement = export class RageLayerElement extends ElementBase { static observedAttributes = ["initial-tool", "sound"]; - private engine: RageLayerEngine | null = null; - private model: ToolbarModel | null = null; + private session: ToolbarSession | null = null; private unsubscribe: (() => void) | null = null; private bar!: HTMLDivElement; private guide!: HTMLDivElement; private buttons: HTMLButtonElement[] = []; private restoringFocus = false; - private previousFocus: HTMLElement | null = null; private config: RageLayerElementConfig = {}; /** Set richer options than attributes can express, before connecting. */ @@ -230,7 +229,11 @@ export class RageLayerElement extends ElementBase { } get rageLayerEngine(): RageLayerEngine | null { - return this.engine; + return this.session?.engine ?? null; + } + + private get model(): ToolbarModel | null { + return this.session?.model ?? null; } connectedCallback() { @@ -265,35 +268,23 @@ export class RageLayerElement extends ElementBase { this.guide.setAttribute("aria-hidden", "true"); root.replaceChildren(style, this.guide, this.bar); - const tools = this.config.tools ?? defaultTools; - - const engine = new RageLayerEngine({ - soundEnabled: this.hasAttribute("sound"), - history: true, - ...this.config, - }); - for (const tool of tools) engine.registerTool(tool); - const initialTool = this.getAttribute("initial-tool"); - if (initialTool) engine.setTool(initialTool); - this.engine = engine; - - const model = new ToolbarModel(engine, { + const { tools = defaultTools, strings, ...engineOptions } = this.config; + const session = openToolbarSession({ + engineOptions: { soundEnabled: this.hasAttribute("sound"), history: true, ...engineOptions }, tools, - strings: this.config.strings, - toolStyle: this.config.toolStyle, + strings, onClose: () => this.dispatchEvent(new CustomEvent("ragelayer-close", { bubbles: true })), }); - this.model = model; - this.bar.setAttribute("aria-label", model.state.toolbarLabel); + const initialTool = this.getAttribute("initial-tool"); + if (initialTool) session.engine.setTool(initialTool); + this.session = session; + this.bar.setAttribute("aria-label", session.model.state.toolbarLabel); - this.unsubscribe = model.subscribe((state) => this.render(state)); + this.unsubscribe = session.model.subscribe((state) => this.render(state)); this.bar.addEventListener("keydown", this.onBarKeyDown); - window.addEventListener("keydown", this.onWindowKeyDown); - // Focus moves into the toolbar so keyboard operation can begin at once, - // and returns to wherever it came from when the element goes away. - this.previousFocus = - document.activeElement instanceof HTMLElement ? document.activeElement : null; + // Focus moves into the toolbar so keyboard operation can begin at once; + // closing the session returns it to wherever it came from. this.buttons[0]?.focus(); } @@ -301,34 +292,17 @@ export class RageLayerElement extends ElementBase { this.unsubscribe?.(); this.unsubscribe = null; this.bar?.removeEventListener("keydown", this.onBarKeyDown); - window.removeEventListener("keydown", this.onWindowKeyDown); - this.model?.destroy(); - this.model = null; - this.engine?.dispose(); - this.engine = null; this.buttons = []; - if (this.previousFocus?.isConnected) this.previousFocus.focus(); - this.previousFocus = null; + this.session?.close(); + this.session = null; } - private onWindowKeyDown = (event: KeyboardEvent) => { - if (this.model?.handleKeyDown(event)) event.preventDefault(); - }; - - /** Arrow keys move focus within the bar; they never leave it. */ private onBarKeyDown = (event: KeyboardEvent) => { const model = this.model; if (!model) return; - const count = this.buttons.length; - if (count === 0) return; const current = this.buttons.indexOf(event.target as HTMLButtonElement); if (current < 0) return; - - let next: number | null = null; - if (event.key === "ArrowLeft") next = current - 1; - else if (event.key === "ArrowRight") next = current + 1; - else if (event.key === "Home") next = 0; - else if (event.key === "End") next = count - 1; + const next = rovingFocusTarget(event.key, current, this.buttons.length); if (next === null) return; event.preventDefault(); @@ -336,7 +310,7 @@ export class RageLayerElement extends ElementBase { // document-level shortcut handler. event.stopPropagation(); model.setFocusIndex(next); - this.buttons[((next % count) + count) % count]?.focus(); + this.buttons[next]?.focus(); }; private render(state: ToolbarState) { diff --git a/src/elements.ts b/src/elements.ts index 102566e..d073d0a 100644 --- a/src/elements.ts +++ b/src/elements.ts @@ -103,12 +103,10 @@ export function harvestElements( if (area < MIN_AREA || rect.w < MIN_SIDE || rect.h < MIN_SIDE) return; // A single element covering most of the page is the page, not furniture. if (area > rootArea * 0.55) return; - // Wrapper test: an element whose child fills it adds no new granularity. for (const child of children) { const cr = rects.get(child); if (cr && cr.w * cr.h > area * WRAPPER_CHILD_RATIO) return; } - // Something has to actually be visible inside it. if (children.length === 0 && !el.textContent?.trim()) return; candidates.push(el); }; diff --git a/src/engine.ts b/src/engine.ts index d6b51b1..251528f 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,6 +1,6 @@ -import { SoundEngine } from "./audio"; +import { HELD_TOOL_LOOPS, SoundEngine } from "./audio"; import { BugSwarm } from "./bugs-sim"; -import { defaultCaptureFilter } from "./capture"; +import { defaultCaptureFilter, MAX_CAPTURE_HEIGHT } from "./capture"; import { CaptureController, type CaptureHost } from "./capture-controller"; import { type ComboEvent, ComboTracker, type InteractionKind } from "./combos"; import type { ContentCheckpoint, ContentLayer } from "./content"; @@ -18,11 +18,18 @@ import { shardBudget, voronoiCells, } from "./fracture"; -import { drawAccretionDisc, drawEventHorizon, drawFlame, FxPainter } from "./fx-render"; -import { measuredUploadCostMs, SLOW_UPLOAD_THRESHOLD_MS } from "./gl"; +import { + drawAccretionDisc, + drawEventHorizon, + drawFlame, + FxPainter, + SPAGHETTI_COOL, + SPAGHETTI_WARM, +} from "./fx-render"; +import { uploadsProbedFast } from "./gl"; import { DestructionHistory, type DestructionHistoryEntry, type HistoryState } from "./history"; -import { REST_AIM_X, REST_AIM_Y, TAU } from "./math"; -import { Overlay } from "./overlay"; +import { isParked, PARKED_POINTER, REST_AIM_X, REST_AIM_Y, TAU } from "./math"; +import { Overlay, paintVoid } from "./overlay"; import { ParticleSystem, type ParticleWorld, scratchParticle } from "./particles"; import { detectInitialQuality, @@ -47,7 +54,6 @@ import type { Flame, FractureOptions, Particle, - PerformanceQuality, PerformanceQualityTier, PerformanceSnapshot, RageLayerEngineApi, @@ -61,7 +67,6 @@ import { WOOD } from "./wood"; /** Engines currently alive in this document — refcount for the sprite cache. */ let liveEngines = 0; -const MAX_CAPTURE_HEIGHT = 12000; /** The heat field feeding the shimmer shader, as a fraction of the fx canvas. */ const HEAT_SCALE = 8; /** @@ -180,16 +185,14 @@ export class RageLayerEngine implements RageLayerEngineApi { private artVY = 0; private readonly artAimX = REST_AIM_X; private readonly artAimY = REST_AIM_Y; - private artPrev: Vec2 = { x: -1000, y: -1000 }; + private artPrev: Vec2 = { x: PARKED_POINTER, y: PARKED_POINTER }; private raf = 0; /** True after the loop deliberately stopped, so its next wake starts from a fresh clock. */ private frameClockSleeping = true; private lastTime = 0; private lastRenderedAt = 0; private monitor: PerformanceMonitor; - private qualityMode: PerformanceQuality; private qualityTier: PerformanceQualityTier; - private qualityProfile: QualityProfile; /** Resolved once at mount from the explicit option or the OS preference. */ private reducedMotion = false; /** @@ -250,6 +253,7 @@ export class RageLayerEngine implements RageLayerEngineApi { */ private heatDirty = true; private _singularity: Singularity | null = null; + private singularityHum = false; /** Countdown to the singularity's next bite out of the page. */ private singularityBite = 0; /** Countdown to the next page element the singularity rips loose. */ @@ -273,13 +277,12 @@ export class RageLayerEngine implements RageLayerEngineApi { options.reducedMotion === true || (options.reducedMotion !== false && (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false)); - this.qualityMode = options.quality ?? "auto"; - this.qualityTier = detectInitialQuality(this.qualityMode); - this.qualityProfile = QUALITY_PROFILES[this.qualityTier]; + const qualityMode = options.quality ?? "auto"; + this.qualityTier = detectInitialQuality(qualityMode); this.monitor = new PerformanceMonitor( options.performance, this.qualityTier, - this.qualityMode === "auto", + qualityMode === "auto", ); this.opts = resolveEngineOptions(options); this.physics = new PhysicsWorld({ @@ -287,8 +290,6 @@ export class RageLayerEngine implements RageLayerEngineApi { iterations: this.qualityProfile.physicsIterations, }); this.applyEntityLimits(); - // Asked for live on a browser without the flag: record it up front so the - // toolbar can say *why* it is in snapshot mode. // Registered before the constructor's `captureContent()` call, so a host // that passes `onError` sees capture failures — the most likely failure of // all, and the one that happens before it could subscribe afterwards. @@ -416,6 +417,10 @@ export class RageLayerEngine implements RageLayerEngineApi { get content(): ContentApi | null { return this.contentLayer?.ready ? this.contentLayer : null; } + private get qualityProfile(): QualityProfile { + return QUALITY_PROFILES[this.qualityTier]; + } + get performanceSnapshot(): PerformanceSnapshot { return this.monitor.snapshot; } @@ -685,13 +690,7 @@ export class RageLayerEngine implements RageLayerEngineApi { private createHistoryEntry(label?: string): EngineHistoryEntry { const content = this.contentLayer?.createCheckpoint() ?? null; - let damage: HTMLCanvasElement | null = null; - if (this.overlay.damageReady) { - damage = document.createElement("canvas"); - damage.width = this.overlay.damageCanvas.width; - damage.height = this.overlay.damageCanvas.height; - damage.getContext("2d")?.drawImage(this.overlay.damageCanvas, 0, 0); - } + const damage = this.overlay.snapshotDamage(); const fuel = this.fire.snapshotFuel(); const pixelCost = (content?.pixelCost ?? 0) + @@ -719,9 +718,7 @@ export class RageLayerEngine implements RageLayerEngineApi { private estimateHistoryPixelCost(): number { return ( (this.contentLayer?.checkpointPixelCost ?? 0) + - (this.overlay.damageReady - ? this.overlay.damageCanvas.width * this.overlay.damageCanvas.height - : 0) + + this.overlay.damagePixelCost + this.fieldPixelCost() ); } @@ -736,21 +733,7 @@ export class RageLayerEngine implements RageLayerEngineApi { try { if (entry.content) this.contentLayer?.restoreCheckpoint(entry.content); else this.contentLayer?.restoreAll(); - if (entry.damage) { - this.overlay.ensureDamage(); - this.overlay.damageCtx.save(); - this.overlay.damageCtx.setTransform(1, 0, 0, 1, 0, 0); - this.overlay.damageCtx.clearRect( - 0, - 0, - this.overlay.damageCanvas.width, - this.overlay.damageCanvas.height, - ); - this.overlay.damageCtx.drawImage(entry.damage, 0, 0); - this.overlay.damageCtx.restore(); - } else if (this.overlay.damageReady) { - this.overlay.damageCtx.clearRect(0, 0, this.overlay.width, this.overlay.height); - } + this.overlay.restoreDamage(entry.damage); this.fire.restoreFuel(entry.fuel); this.destruction = entry.destruction; for (let i = 0; i < this.pageElements.length; i++) { @@ -774,8 +757,7 @@ export class RageLayerEngine implements RageLayerEngineApi { // page, without synthesizing an onUp action such as a singularity collapse. this.pointerInput.cancel(); if (!this.restoringHistory) this.checkpoint("clear"); - if (this.overlay.damageReady) - this.overlay.damageCtx.clearRect(0, 0, this.overlay.width, this.overlay.height); + this.overlay.clearDamage(); this.contentLayer?.restoreAll(); this.fire.clear(); this.particles.clear(); @@ -843,28 +825,19 @@ export class RageLayerEngine implements RageLayerEngineApi { const sx = this.scrollX; const sy = Math.max(0, Math.min(this.scrollY, this.overlay.height - h)); - // The void behind the page, so holes read as holes and not as transparency. - const bg = ctx.createRadialGradient(w / 2, 0, 0, w / 2, 0, Math.max(w, h) * 1.2); - bg.addColorStop(0, "#17130f"); - bg.addColorStop(0.55, "#0c0a08"); - bg.addColorStop(1, "#060504"); - ctx.fillStyle = bg; - ctx.fillRect(0, 0, w, h); + paintVoid(ctx, w, h); const layer = this.contentLayer; if (layer?.ready) { const d = layer.dpr; ctx.drawImage(layer.canvas, sx * d, sy * d, w * d, h * d, 0, 0, w, h); } - if (this.overlay.damageReady) { - const d = this.overlay.dpr; - ctx.drawImage(this.overlay.damageCanvas, sx * d, sy * d, w * d, h * d, 0, 0, w, h); - } + this.overlay.drawDamageInto(ctx, sx, sy, w, h); // The effects layer is viewport-parked, so its source rect is relative to // wherever `positionFx` last left it. const presented = this.postfxActive ? this.postfx!.canvas : this.overlay.fxCanvas; if (this.fxPainted && presented.width > 0) { - const d = this.overlay.dpr; + const d = this.overlay.fxDpr; ctx.drawImage( presented, (sx - this.overlay.fxOffsetX) * d, @@ -911,8 +884,7 @@ export class RageLayerEngine implements RageLayerEngineApi { this.physics.clear(); this.postfx?.dispose(); this.postfx = null; - this.overlay.container.remove(); - this.overlay.container.replaceChildren(); + this.overlay.dispose(); this.sound.dispose(); this.history?.clear(); this.emit("dispose"); @@ -922,10 +894,6 @@ export class RageLayerEngine implements RageLayerEngineApi { // `dispose` must release the expensive state even when application code // intentionally keeps the engine object for inspection. In particular, a // detached high-DPI canvas keeps its whole pixel backing until it is reset. - this.overlay.damageCanvas.width = 0; - this.overlay.damageCanvas.height = 0; - this.overlay.fxCanvas.width = 0; - this.overlay.fxCanvas.height = 0; if (this.heatCanvas) { this.heatCanvas.width = 0; this.heatCanvas.height = 0; @@ -989,16 +957,8 @@ export class RageLayerEngine implements RageLayerEngineApi { // Sweeping is the one thing that un-wrecks the page, so it walks the // vignette back too. this.destruction = Math.max(0, this.destruction - 0.004); - // Repair the real content from the pristine snapshot... this.contentLayer?.restore(x, y, radius); - // ...and sweep any overlay decals (also the fallback path). Nothing to - // sweep if the damage canvas was never painted on. - if (!this.overlay.damageReady) return; - const ctx = this.overlay.damageCtx; - ctx.globalCompositeOperation = "destination-out"; - blit(ctx, sprites().erase, x, y, radius, 1); - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; + this.overlay.eraseDamage(x, y, radius, 1); } /** @@ -1008,13 +968,7 @@ export class RageLayerEngine implements RageLayerEngineApi { */ washSurface(x: number, y: number, radius: number, strength = 1) { this.contentLayer?.wash(x, y, radius, strength); - if (this.overlay.damageReady) { - const ctx = this.overlay.damageCtx; - ctx.globalCompositeOperation = "destination-out"; - blit(ctx, sprites().erase, x, y, radius, Math.min(1, strength)); - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - } + this.overlay.eraseDamage(x, y, radius, Math.min(1, strength)); this.markSurface(x, y, radius); this.signalInteraction("water", x, y); } @@ -1148,7 +1102,6 @@ export class RageLayerEngine implements RageLayerEngineApi { } fracture(x: number, y: number, radius: number, options: FractureOptions = {}): number { - // Anything crawling in the struck region is crushed along with it. if (this.bugs.count > 0) this.squashBugs(x, y, radius); const src = this.chunkSource; if (!src) return 0; @@ -1171,7 +1124,7 @@ export class RageLayerEngine implements RageLayerEngineApi { cell, { density: 0.0018 * WOOD.density, - restitution: Math.max(0.14, WOOD.restitution), + restitution: 0.14, friction: 0.62, ttl: options.ttl ?? 10 + Math.random() * 8, }, @@ -1218,7 +1171,6 @@ export class RageLayerEngine implements RageLayerEngineApi { if (options.fracture !== false) { this.fracture(x, y, radius * 0.9, { power: power * 0.8, count: shardBudget(radius, 1.7) }); } - // Everything already loose gets thrown, including debris from earlier hits. this.physics.blast(x, y, radius * 2.2, power * 1.1); this.particles.blast(x, y, radius, power * 2.2); this.contentLayer?.char(x, y, radius * 1.7, 0.55); @@ -1599,10 +1551,7 @@ export class RageLayerEngine implements RageLayerEngineApi { /** Loops driven by a held tool, distinct from persistent ambient fire. */ private silenceToolLoops() { - this.sound.loop("water", 0); - this.sound.loop("saw", 0); - this.sound.loop("flamethrower", 0); - this.sound.loop("void", 0); + for (const name of HELD_TOOL_LOOPS) this.sound.loop(name, 0); } private emit(event: EngineEvent) { @@ -1614,7 +1563,6 @@ export class RageLayerEngine implements RageLayerEngineApi { private applyQuality(tier: PerformanceQualityTier) { if (tier === this.qualityTier) return; this.qualityTier = tier; - this.qualityProfile = QUALITY_PROFILES[tier]; this.applyEntityLimits(); this.setPostFXEnabled(this.opts.postFX && this.qualityProfile.postFX); @@ -1640,16 +1588,24 @@ export class RageLayerEngine implements RageLayerEngineApi { postfx.counters = this.monitor; this.heatCanvas = document.createElement("canvas"); this.heatCtx = this.heatCanvas.getContext("2d", { willReadFrequently: false }); - postfx.resize( - this.overlay.fxCanvas.width, - this.overlay.fxCanvas.height, - this.overlay.fxWidth, - this.overlay.fxHeight, + this.fitPostFXToOverlay(); + } + + /** Match the post-FX chain and its heat field to the fx canvas geometry. */ + private fitPostFXToOverlay() { + const overlay = this.overlay; + this.postfx?.resize( + overlay.fxCanvas.width, + overlay.fxCanvas.height, + overlay.fxWidth, + overlay.fxHeight, ); - this.heatCanvas.width = Math.max(1, Math.round(this.overlay.fxWidth / HEAT_SCALE)); - this.heatCanvas.height = Math.max(1, Math.round(this.overlay.fxHeight / HEAT_SCALE)); - // A resized canvas is transparent again; the next `resetHeat` must fill it. - this.heatDirty = true; + if (this.heatCanvas) { + this.heatCanvas.width = Math.max(1, Math.round(overlay.fxWidth / HEAT_SCALE)); + this.heatCanvas.height = Math.max(1, Math.round(overlay.fxHeight / HEAT_SCALE)); + // A resized canvas is transparent again; the next `resetHeat` must fill it. + this.heatDirty = true; + } } private setPostFXEnabled(enabled: boolean) { @@ -1671,7 +1627,7 @@ export class RageLayerEngine implements RageLayerEngineApi { outgoing.replaceWith(incoming); this.postfxActive = next; if (!next) postfx?.clear(); - this.overlay.fxOffsetX = this.overlay.fxOffsetY = -1; + this.overlay.invalidateFxTransform(); } private docSize() { @@ -1698,20 +1654,8 @@ export class RageLayerEngine implements RageLayerEngineApi { */ private resize() { const { width, height } = this.docSize(); - const overlay = this.overlay; - overlay.resize(width, height, this.opts.effectsPixelRatio); - this.postfx?.resize( - overlay.fxCanvas.width, - overlay.fxCanvas.height, - overlay.fxWidth, - overlay.fxHeight, - ); - if (this.heatCanvas) { - this.heatCanvas.width = Math.max(1, Math.round(overlay.fxWidth / HEAT_SCALE)); - this.heatCanvas.height = Math.max(1, Math.round(overlay.fxHeight / HEAT_SCALE)); - // A resized canvas is transparent again; the next `resetHeat` must fill it. - this.heatDirty = true; - } + this.overlay.resize(width, height, this.opts.effectsPixelRatio); + this.fitPostFXToOverlay(); } private onScroll = () => { @@ -1760,12 +1704,7 @@ export class RageLayerEngine implements RageLayerEngineApi { this.raf = 0; this.frameClockSleeping = false; this.monitor.observeRaf(now); - // The 60 fps rate cap borrows the balanced tier's frame-skip interval: - // full visual quality, half the cadence on >90 Hz displays. - const minFrameInterval = - this.qualityTier === "high" && this.monitor.rateCap60 - ? QUALITY_PROFILES.balanced.minFrameIntervalMs - : this.qualityProfile.minFrameIntervalMs; + const minFrameInterval = this.monitor.minFrameIntervalMs(this.qualityTier); if (minFrameInterval > 0 && now - this.lastRenderedAt < minFrameInterval) { this.requestFrame(); return; @@ -1841,11 +1780,6 @@ export class RageLayerEngine implements RageLayerEngineApi { this.updateLoops(); const frameMs = performance.now() - frameStartedAt; - const nativeTarget = this.monitor.nativeTargetFps; - const targetFps = - this.qualityTier === "high" && !this.monitor.rateCap60 - ? nativeTarget - : Math.min(60, nativeTarget); const recommendation = this.monitor.record({ cadenceMs: dt * 1_000, frameMs, @@ -1869,9 +1803,9 @@ export class RageLayerEngine implements RageLayerEngineApi { quality: this.qualityTier, pixelRatio: this.overlay.dpr, effectsPixelRatio: this.overlay.fxDpr, - targetFps, + targetFps: this.monitor.targetFps(this.qualityTier), }); - if (recommendation && this.qualityMode === "auto") this.applyQuality(recommendation); + if (recommendation) this.applyQuality(recommendation); if (this.hasActiveWork(toolWorkPending)) this.requestFrame(); else this.frameClockSleeping = true; @@ -1972,12 +1906,12 @@ export class RageLayerEngine implements RageLayerEngineApi { */ private stepToolArt(dt: number) { const p = this.pointer; - if (p.x <= -999) { + if (isParked(p)) { this.artVX = this.artVY = 0; - this.artPrev.x = this.artPrev.y = -1000; + this.artPrev.x = this.artPrev.y = PARKED_POINTER; return; } - if (this.artPrev.x > -999 && dt > 0) { + if (!isParked(this.artPrev) && dt > 0) { const k = Math.min(1, dt * 14); this.artVX += ((p.x - this.artPrev.x) / dt - this.artVX) * k; this.artVY += ((p.y - this.artPrev.y) / dt - this.artVY) * k; @@ -2065,12 +1999,16 @@ export class RageLayerEngine implements RageLayerEngineApi { // Clearing is idempotent and cheap; the renderer repaints the warped // region once so the page straightens back out. this.contentLayer?.setWarp(null); - this.sound.loop("void", 0); + // Only on the frame the singularity ends: the gravity gun drives the + // same loop while held, and an unconditional fade would mute it. + if (this.singularityHum) this.sound.loop("void", 0); + this.singularityHum = false; return; } s.charge = Math.min(1, s.charge + dt * 1.8); const r = s.radius * s.charge; this.sound.loop("void", 0.45 * s.charge); + this.singularityHum = true; // Spacetime around the hole: gravitational lensing + frame-dragging swirl // in the surface shader. Pure re-shading — no texture upload — so driving @@ -2100,7 +2038,6 @@ export class RageLayerEngine implements RageLayerEngineApi { if (nearest) this.dropElement(nearest, s.x, s.y); } - // Bugs unlucky enough to crawl inside the horizon are simply gone. this.bugs.vanish(s.x, s.y, r); // Eat the page. Throttled because each bite repaints a document-sized @@ -2111,7 +2048,6 @@ export class RageLayerEngine implements RageLayerEngineApi { this.contentLayer.punch(s.x, s.y, r * 0.92); } - // Loose particles spiral in. this.particles.attract(s.x, s.y, s.power, r * 0.55, dt); // A held singularity is a vacuum for wreckage: everything the page has @@ -2157,7 +2093,7 @@ export class RageLayerEngine implements RageLayerEngineApi { maxLife: 0.5 + Math.random() * 0.7, size: 1.4 + Math.random() * 2.6, gravity: 0, - color: Math.random() < 0.4 ? "#c98bff" : "#ffb066", + color: Math.random() < 0.4 ? SPAGHETTI_COOL : SPAGHETTI_WARM, }); } this.heat(s.x, s.y, r * 2.4, 0.55 * s.charge); @@ -2193,7 +2129,7 @@ export class RageLayerEngine implements RageLayerEngineApi { // can stay as the last retained canvas image without keeping rAF alive. // Held gestures and live effects still animate through `hasActiveWork`. const artVisible = - this.opts.toolStyle === "3d" && !!this.activeTool?.art && this.pointer.x > -999; + this.opts.toolStyle === "3d" && !!this.activeTool?.art && !isParked(this.pointer); const idle = this.particles.count === 0 && this.fire.count === 0 && @@ -2207,13 +2143,13 @@ export class RageLayerEngine implements RageLayerEngineApi { // Nothing to draw: clear once after the last active frame, then leave the // canvas (and the compositor) completely alone while idle. if (this.fxPainted) { - this.clearFxCanvas(ctx); + this.overlay.clearFx(); if (this.postfxActive) this.postfx?.clear(); this.fxPainted = false; } return; } - this.clearFxCanvas(ctx); + this.overlay.clearFx(); this.fxPainted = true; // Bugs crawl *on* the page, under every particle and piece of debris. @@ -2276,31 +2212,17 @@ export class RageLayerEngine implements RageLayerEngineApi { const sing = this._singularity; if (sing) drawEventHorizon(ctx, sing); - // Additive pass: the accretion disc, then every glowing particle. ctx.globalCompositeOperation = "lighter"; if (sing) drawAccretionDisc(ctx, sing, time); this.fx.drawHot(ctx, time); ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; - // The tool in hand, over everything it just did. if (artVisible) this.renderToolArt(ctx, time); this.postFXFrameMs = this.present(time); } - /** Clear every backing-store pixel regardless of the document-space transform. */ - private clearFxCanvas(ctx: CanvasRenderingContext2D) { - // Re-apply `positionFx`'s document-space transform directly instead of - // paying a save/restore state push per frame. Callers only run after - // `positionFx`, so the overlay's parked offsets are always current. - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); - const overlay = this.overlay; - const dpr = overlay.fxDpr; - ctx.setTransform(dpr, 0, 0, dpr, -overlay.fxOffsetX * dpr, -overlay.fxOffsetY * dpr); - } - /** Scratch for `maskFxToPage`'s per-frame hole-run query; reused, never grows hot. */ private readonly maskRectScratch: number[] = []; @@ -2362,8 +2284,7 @@ export class RageLayerEngine implements RageLayerEngineApi { // optimal and the hole cache below is pure overhead; a slow probe marks // the browsers whose canvas the `destination-in` family deopts. An // unprobed environment (no WebGL at all) is treated as slow. - const uploadCost = measuredUploadCostMs(); - if (uploadCost !== null && uploadCost <= SLOW_UPLOAD_THRESHOLD_MS) { + if (uploadsProbedFast()) { ctx.globalCompositeOperation = "destination-in"; ctx.drawImage( layer.surface, diff --git a/src/flames.ts b/src/flames.ts index 23c15ec..4554cdb 100644 --- a/src/flames.ts +++ b/src/flames.ts @@ -24,7 +24,6 @@ import { TAU } from "./math"; // of, synchronously) instead of allocating a literal per ember and puff. import { scratchParticle } from "./particles"; import type { ContentApi, Flame, Particle, SoundApi } from "./types"; -import { WOOD } from "./wood.js"; /** * Wood-fuel grid resolution, CSS px per cell. Coarse cells are enough — the @@ -148,7 +147,6 @@ export class FlameField { /** Returns true when a new flame was actually lit (the caller repaints). */ spawn(host: FlameHost, x: number, y: number, intensity = 0.35): boolean { this.fuel.ensure(host.width, host.height); - intensity *= WOOD.flammability; if (intensity <= 0.015) return false; // Fire needs a page to burn. Where the content is mostly gone the void // shows through, and the void is not a place — a flame floating on it @@ -156,8 +154,6 @@ export class FlameField { // ground barely holds a flame's footprint, and the render mask would clip // most of it away anyway. if (host.content?.ready && host.pageOpacityAt(x, y) < 0.35) return false; - // Merge into a nearby flame instead of stacking duplicates. Same shape as - // the dowse scan: axis reject, then squared distance — no square root. for (const f of this.list) { const reach = f.radius * 0.6; const dx = f.x - x; @@ -309,12 +305,12 @@ export class FlameField { // Stage 2 — burning: the char deepens and erosion begins. layer.char(f.x, f.y + 2, f.radius * 0.85, 0.16); layer.burn(f.x, f.y + 2, f.radius * 0.22); - this.consumeFuel(host, f.x, f.y, (9 + 13 * f.intensity) * WOOD.burnRate); + this.consumeFuel(host, f.x, f.y, 9 + 13 * f.intensity); } else { // Stage 3 — deepening: the fire is inside the material now, eating // fast toward breakthrough, and the rim glows with thrown embers. layer.burn(f.x, f.y + 2, f.radius * (0.3 + f.intensity * 0.35)); - this.consumeFuel(host, f.x, f.y, (13 + 16 * f.intensity) * WOOD.burnRate); + this.consumeFuel(host, f.x, f.y, 13 + 16 * f.intensity); if (Math.random() < 0.5) { const a = Math.random() * TAU; const p = scratchParticle( @@ -356,8 +352,6 @@ export class FlameField { let localHeat = f.intensity * (0.55 + fuel * 0.45); for (const neighbour of this.list) { if (neighbour === f) continue; - // Only flames actually in contact contribute, so reject the rest - // before the square root: axis test first, then squared distance. const reach = f.radius + neighbour.radius * 1.4; const dx = neighbour.x - f.x; if (dx > reach || dx < -reach) continue; @@ -474,7 +468,6 @@ export class FlameField { } if (f.intensity <= 0.02) { - // Died — final char + a puff of smoke. if (host.content?.ready) { host.content.char(f.x, f.y, f.radius, 0.35); } else { diff --git a/src/fracture.ts b/src/fracture.ts index 90b39e8..853e54b 100644 --- a/src/fracture.ts +++ b/src/fracture.ts @@ -83,7 +83,6 @@ export function bakeChunk( const h = maxY - minY; const cap = options.maxSize ?? MAX_CHUNK; if (!(w > MIN_CHUNK) || !(h > MIN_CHUNK) || w > cap || h > cap) return null; - // Fully outside the captured page: there are no pixels to carry. if (maxX < 0 || maxY < 0 || minX > source.width || minY > source.height) return null; const sourceDpr = source.dpr; @@ -144,7 +143,6 @@ export function bakeChunk( sctx.setTransform(d, 0, 0, d, 0, 0); sctx.fillStyle = "#4a3826"; sctx.fill(path); - // Horizontal grain streaks, clipped to the silhouette. sctx.globalCompositeOperation = "source-atop"; sctx.strokeStyle = "rgba(24, 17, 10, 0.5)"; sctx.lineWidth = 1; diff --git a/src/fx-render.ts b/src/fx-render.ts index 55cb233..02e6f9d 100644 --- a/src/fx-render.ts +++ b/src/fx-render.ts @@ -13,6 +13,7 @@ */ import { TAU } from "./math"; +import type { FxView } from "./overlay"; import { blit, blitRect, blitStreak, sprites } from "./sprites"; import type { Flame, Particle, Singularity } from "./types"; @@ -71,13 +72,13 @@ function dustClusterPath(): Path2D | null { return dustCluster; } -/** The document band the fx canvas currently covers. */ -export interface FxView { - left: number; - top: number; - right: number; - bottom: number; -} +/** + * The two tints of accretion-disc "spaghetti". The cool one draws with the + * water streak and the warm one with the hot streak, so the disc reads as + * having temperature structure rather than one glowing smear. + */ +export const SPAGHETTI_COOL = "#c98bff"; +export const SPAGHETTI_WARM = "#ffb066"; export class FxPainter { /** Per-frame render buckets, reused to keep the render path allocation-free. */ @@ -130,9 +131,9 @@ export class FxPainter { /** Sort the visible particles into the four passes below. */ classify(particles: readonly Particle[], view: FxView) { - // One classification pass. Particles live in document space and the page - // can be far taller than the screen, so anything outside the fx band is - // dropped here instead of being submitted and clipped by the canvas. + // Particles live in document space and the page can be far taller than + // the screen, so anything outside the fx band is dropped here instead of + // being submitted and clipped by the canvas. const { wet, puff, bit, hot } = this; wet.length = puff.length = bit.length = hot.length = 0; for (const p of particles) { @@ -366,8 +367,6 @@ export class FxPainter { const bucket = buckets[q]; ctx.globalAlpha = q / 128; if (cluster) { - // The whole three-lobe cluster scales rigidly with swell, so one - // uniform-scale stamp of the baked cluster replaces three appends. for (const p of bucket) { const t = p.life / p.maxLife; const swell = Math.min(10, p.size * (0.72 + t * 0.48)); @@ -588,7 +587,6 @@ export class FxPainter { break; } case "spark": - // Fast sparks smear into a streak; slow ones stay points. if (Math.abs(p.vx) + Math.abs(p.vy) > 260) { blitStreak( ctx, @@ -642,7 +640,7 @@ export class FxPainter { const speed = Math.hypot(p.vx, p.vy); blitStreak( ctx, - p.color === "#c98bff" ? sprite.streakWater : sprite.streakHot, + p.color === SPAGHETTI_COOL ? sprite.streakWater : sprite.streakHot, p.x, p.y, Math.atan2(p.vy, p.vx), diff --git a/src/gl.ts b/src/gl.ts index 1a593c1..01cc3c0 100644 --- a/src/gl.ts +++ b/src/gl.ts @@ -189,6 +189,19 @@ export function canvasUploadCostMs(gl: WebGLRenderingContext | WebGL2RenderingCo return uploadCostMs; } +/** Whether per-frame canvas→GL pipelines lose more in transfer than they gain. Probes once. */ +export function canvasUploadsAreSlow(gl: WebGLRenderingContext | WebGL2RenderingContext): boolean { + return canvasUploadCostMs(gl) > SLOW_UPLOAD_THRESHOLD_MS; +} + +/** + * Whether an earlier probe showed canvas uploads to be fast. Without WebGL + * nothing ever probes, and those environments count as slow. + */ +export function uploadsProbedFast(): boolean { + return uploadCostMs !== null && uploadCostMs <= SLOW_UPLOAD_THRESHOLD_MS; +} + // ── GPU pass timing ───────────────────────────────────────────────────────── /** The constants both disjoint timer-query extensions share. */ diff --git a/src/heavy-tools.ts b/src/heavy-tools.ts index 9187804..e5ce170 100644 --- a/src/heavy-tools.ts +++ b/src/heavy-tools.ts @@ -12,9 +12,9 @@ import { emojiCursor } from "./cursors"; import { drawBurnChannel } from "./decals"; -import { emit, rand, TAU } from "./math"; +import { emit, isParked, PARKED_POINTER, rand, TAU } from "./math"; import { createEngineState } from "./tool-kit"; -import { blackHoleArt, bugsArt, demolitionArt, lightningArt, rocketArt } from "./toolart"; +import { blackHoleArt, bugsArt, demolitionArt, lightningArt, rocketArt } from "./toolart/heavy"; import type { RageLayerEngineApi, Tool, Vec2 } from "./types"; import { WOOD } from "./wood"; @@ -49,7 +49,7 @@ export const blackHole: Tool = { const state = blackHoleStates.get(engine); const s = engine.singularity; if (!s) return; - if (!held || pointer.x <= -100) { + if (!held || isParked(pointer)) { collapseSingularity(engine); return; } @@ -134,8 +134,6 @@ export const rocketLauncher: Tool = { life: 0, smokeDebt: 0, }); - // Launch signature: a flash at the muzzle, backblast smoke out the rear - // of the tube, and the recoil shoving the page the other way. engine.spawnParticle({ kind: "flash", x: e.x, @@ -185,8 +183,6 @@ export const rocketLauncher: Tool = { r.y += r.vy * dt; const angle = Math.atan2(r.vy, r.vx); - // The rocket is drawn entirely out of particles: a hot core, a motor - // flare behind it, and a smoke column that hangs in the air afterwards. engine.spawnParticle({ kind: "flash", x: r.x, @@ -256,7 +252,7 @@ export const rocketLauncher: Tool = { } }, backgroundTick(engine, dt) { - rocketLauncher.tick?.(engine, dt, false, { x: -1000, y: -1000 }); + rocketLauncher.tick?.(engine, dt, false, { x: PARKED_POINTER, y: PARKED_POINTER }); }, }; @@ -368,7 +364,6 @@ export const lightning: Tool = { const branch = boltPath(bx, by, bx + Math.cos(a) * len, by + Math.sin(a) * len, 7, 48); drawBolt(engine, branch, 5, 0.15); drawBolt(engine, branch, 2, 0.2); - // Sub-forks off the branch's midpoint. if (Math.random() < 0.7) { const j = Math.floor(branch.length / 4) * 2; const fa = a + rand(-1.1, 1.1); @@ -404,8 +399,6 @@ export const lightning: Tool = { engine.content?.cut(main[i], main[i + 1], main[i + 2], main[i + 3]); } - // Ground crawlers: short arcs that skitter outward from the strike point - // along the surface, the way a strike grounds itself in every direction. for (let c = 0; c < 6 + Math.round(WOOD.conductivity * 4); c++) { const a = rand(0, TAU); const len = rand(40, 130); @@ -421,8 +414,6 @@ export const lightning: Tool = { } } - // Sky flash — the whole viewport blinks — then, if the bolt found page to - // strike, the crater. engine.spawnParticle({ kind: "flash", x: e.x, @@ -441,18 +432,14 @@ export const lightning: Tool = { // seed fires duplicated the persistent flame population on every hit. incendiary: false, }); - // Fires scattered up the strike path — the bolt lit everything it touched. for (let i = 8; i < main.length - 2; i += 4) { if (Math.random() < 0.45) engine.spawnFlame(main[i], main[i + 1], 0.4); } engine.heat(e.x, e.y, 260, 1); } - // Thunder either way — but a bolt that found nothing to hit shakes the - // page with sound alone, not with an impact. engine.shake(grounded ? 26 : 10, 0, 1); engine.sound.zap(); - // Queue the flicker: two restrikes down the same channel. restrikes.push( { pts: main, delay: 0.07 + rand(0, 0.05), impactX: e.x, impactY: e.y, grounded }, { pts: main, delay: 0.17 + rand(0, 0.07), impactX: e.x, impactY: e.y, grounded }, @@ -485,7 +472,7 @@ export const lightning: Tool = { } }, backgroundTick(engine, dt) { - lightning.tick?.(engine, dt, false, { x: -1000, y: -1000 }); + lightning.tick?.(engine, dt, false, { x: PARKED_POINTER, y: PARKED_POINTER }); }, }; @@ -550,7 +537,6 @@ export const bugs: Tool = { cursor: emojiCursor("🐛"), art: bugsArt, onDown(engine, e) { - // One click, one meaning: squash what's underfoot, otherwise release one. if (engine.squashBugs(e.x, e.y, 24) === 0) { engine.spawnBugs(e.x, e.y, 1); } diff --git a/src/lazy.ts b/src/lazy.ts index 7410b3e..264d487 100644 --- a/src/lazy.ts +++ b/src/lazy.ts @@ -1,3 +1,4 @@ +import { officialToolOrder } from "./tool-order"; import type { Tool } from "./types"; /** Load the seven everyday tools without downloading the heavy ordnance module. */ @@ -12,7 +13,7 @@ export async function loadHeavyTools(): Promise { return [...heavyTools]; } -/** Load gravity, precision cutting, corrosion, and sticky explosives on demand. */ +/** Load precision cutting, corrosion, and sticky explosives on demand. */ export async function loadAdvancedTools(): Promise { const { advancedTools } = await import("./advanced-tools"); return [...advancedTools]; @@ -25,5 +26,5 @@ export async function loadDefaultTools(): Promise { import("./heavy-tools"), import("./advanced-tools"), ]); - return [...baseTools.slice(0, -1), ...heavyTools, ...advancedTools, baseTools.at(-1)!]; + return officialToolOrder(baseTools, heavyTools, advancedTools); } diff --git a/src/live.ts b/src/live.ts index d2a2b09..01957fb 100644 --- a/src/live.ts +++ b/src/live.ts @@ -367,19 +367,6 @@ export class LiveContentSource { if (supportsPaintEvents()) syncAnimationClocks(root, clone); // Everything below is in *device* pixels, under an identity transform. - // - // `drawElementImage` does its own device-scaling: it rasterizes the element - // at the host canvas's backing-store-to-CSS-size ratio, which is already - // `dpr` here, and only then applies the CTM. Measured in Chrome 149 — a - // 100x50 CSS element in a 300x200 CSS / 600x400 backing canvas comes out - // 200x100 device px under an identity transform, and 400x200 under - // `setTransform(2,…)`. So a dpr transform doesn't scale the raster to the - // backing store, it double-scales it: exactly `dpr`x too big, which is - // invisible on a 1x display and a 2x zoom on a retina one. - // - // The destination coordinates are in the current user space, so with the - // transform gone they are device pixels too. Rounding them keeps the - // raster on the pixel grid, matching the snapshot path's blit. this.last = { dx: Math.round(options.source.x * dpr), dy: Math.round(options.source.y * dpr), diff --git a/src/math.ts b/src/math.ts index 0f7b60a..fbf0e30 100644 --- a/src/math.ts +++ b/src/math.ts @@ -22,6 +22,17 @@ export const TAU = Math.PI * 2; export const REST_AIM_X = -0.55; export const REST_AIM_Y = -0.835; +/** + * Where the pointer sits while it is off the page (left, cancelled, or a + * background tick with no pointer at all): far outside anything drawable. + */ +export const PARKED_POINTER = -1000; + +/** Whether a pointer position is the parked one rather than a real point. */ +export function isParked(point: { x: number }): boolean { + return point.x === PARKED_POINTER; +} + /** Uniform random in `[min, max)`. */ export function rand(min: number, max: number): number { return min + Math.random() * (max - min); diff --git a/src/opacity-map.ts b/src/opacity-map.ts index ebef70b..1b09dc4 100644 --- a/src/opacity-map.ts +++ b/src/opacity-map.ts @@ -233,7 +233,6 @@ export class OpacityMap { this.maskCells.set(this.pristineMask); const removed = this.removed; if (removed) { - // Re-flag every cell the surviving (post-restore) wounds still touch. for (let my = 0; my < this.mapHeight; my++) { const tileRow = Math.min(this.maskRows - 1, Math.floor(my / this.scaleY / 128)); for (let mx = 0, src = my * this.mapWidth; mx < this.mapWidth; mx++, src++) { @@ -328,7 +327,6 @@ export class OpacityMap { return true; } - /** Flag every 128px cell whose downscaled plane has alpha below `minOpaque`. */ private seedMaskFromAlpha(target: Uint8Array, alpha: Uint8Array, minOpaque: number) { for (let my = 0; my < this.mapHeight; my++) { const tileRow = Math.min(this.maskRows - 1, Math.floor(my / this.scaleY / 128)); @@ -441,7 +439,6 @@ export class OpacityMap { } ctx.restore(); - // Mirror the flattened region into the CPU plane `sample()` reads. const mx0 = Math.max(0, Math.floor(cellX * this.scaleX)); const my0 = Math.max(0, Math.floor(cellY * this.scaleY)); const mx1 = Math.min(this.mapWidth, Math.ceil((cellX + w) * this.scaleX)); @@ -503,7 +500,6 @@ export class OpacityMap { this.topology = topology; } - /** Apply one structural operation to the compact connectivity grid once. */ private rasterizeTopology(operation: OpacityOperation) { if (this.topology.length === 0) return; const b = operation.bounds; diff --git a/src/overlay.ts b/src/overlay.ts index 20260e1..1c77987 100644 --- a/src/overlay.ts +++ b/src/overlay.ts @@ -24,10 +24,26 @@ */ import { pickPixelRatio, RAGELAYER_IGNORE_ATTR } from "./capture"; +import { blit, sprites } from "./sprites"; /** Extra margin (CSS px) drawn beyond the viewport so nothing pops at the edge. */ export const FX_MARGIN = 120; +/** The void behind the page, so holes read as holes and not as transparency. */ +const VOID_STOPS = [ + [0, "#17130f"], + [55, "#0c0a08"], + [100, "#060504"], +] as const; + +/** Paint the void onto a 2D canvas in the void layer's colours, for snapshots. */ +export function paintVoid(ctx: CanvasRenderingContext2D, w: number, h: number) { + const bg = ctx.createRadialGradient(w / 2, 0, 0, w / 2, 0, Math.max(w, h) * 1.2); + for (const [percent, color] of VOID_STOPS) bg.addColorStop(percent / 100, color); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, w, h); +} + /** The document band the fx canvas currently covers. */ export interface FxView { left: number; @@ -117,8 +133,9 @@ export class Overlay { position: "absolute", inset: "0", display: "none", - background: - "radial-gradient(ellipse 120% 60% at 50% 0%, #17130f 0%, #0c0a08 55%, #060504 100%)", + background: `radial-gradient(ellipse 120% 60% at 50% 0%, ${VOID_STOPS.map( + ([percent, color]) => `${color} ${percent}%`, + ).join(", ")})`, } satisfies Partial); this.container.appendChild(this.voidLayer); @@ -172,11 +189,6 @@ export class Overlay { target.appendChild(this.container); } - /** Whether anything has ever been painted on the damage canvas. */ - get damageReady(): boolean { - return this.damaged; - } - /** Reveal (or hide) the void backdrop behind the page. */ showVoid(visible: boolean) { this.voidLayer.style.display = visible ? "block" : "none"; @@ -248,7 +260,6 @@ export class Overlay { this.fxCanvas.height = Math.round(height * dpr); this.fxCanvas.style.width = `${width}px`; this.fxCanvas.style.height = `${height}px`; - // Force the transform to be re-applied against the new size. this.invalidateFxTransform(); return true; } @@ -273,6 +284,65 @@ export class Overlay { if (this.damaged) this.damageCtx.clearRect(0, 0, this.width, this.height); } + /** Rub decals out under a soft round brush, if any were ever drawn. */ + eraseDamage(x: number, y: number, radius: number, alpha: number) { + if (!this.damaged) return; + const ctx = this.damageCtx; + ctx.globalCompositeOperation = "destination-out"; + blit(ctx, sprites().erase, x, y, radius, alpha); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + } + + /** Backing-store pixels a damage snapshot holds; 0 when nothing was drawn. */ + get damagePixelCost(): number { + return this.damaged ? this.damageCanvas.width * this.damageCanvas.height : 0; + } + + /** Copy the decals for a history checkpoint, or null when nothing was drawn. */ + snapshotDamage(): HTMLCanvasElement | null { + if (!this.damaged) return null; + const copy = document.createElement("canvas"); + copy.width = this.damageCanvas.width; + copy.height = this.damageCanvas.height; + copy.getContext("2d")?.drawImage(this.damageCanvas, 0, 0); + return copy; + } + + /** Copy the decals in a document rect onto `ctx` at (0, 0), if any were drawn. */ + drawDamageInto(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { + if (!this.damaged) return; + const d = this.dpr; + ctx.drawImage(this.damageCanvas, x * d, y * d, w * d, h * d, 0, 0, w, h); + } + + /** Replace the decals with a `snapshotDamage` copy, or clear them for null. */ + restoreDamage(snapshot: HTMLCanvasElement | null) { + if (!snapshot) { + this.clearDamage(); + return; + } + this.ensureDamage(); + const ctx = this.damageCtx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, this.damageCanvas.width, this.damageCanvas.height); + ctx.drawImage(snapshot, 0, 0); + ctx.restore(); + } + + /** + * Clear every fx backing-store pixel and re-apply the document transform + * the last `positionFx` set, without a per-frame save/restore. + */ + clearFx() { + const ctx = this.fxCtx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, this.fxCanvas.width, this.fxCanvas.height); + const dpr = this.fxDpr; + ctx.setTransform(dpr, 0, 0, dpr, -this.fxOffsetX * dpr, -this.fxOffsetY * dpr); + } + /** The next `positionFx` re-applies the transform even if nothing scrolled. */ invalidateFxTransform() { this.fxOffsetX = this.fxOffsetY = -1; diff --git a/src/particles.ts b/src/particles.ts index 6e9e9b4..2e700d3 100644 --- a/src/particles.ts +++ b/src/particles.ts @@ -120,7 +120,6 @@ export class ParticleSystem { this.list.push(fill(this.free.pop() ?? createParticle(), p)); } - /** Return a dead particle object to the free list for the next spawn. */ private recycle(p: Particle) { // Drop snapshot handles so an idle pool never pins a page capture alive. p.img = undefined; @@ -172,8 +171,6 @@ export class ParticleSystem { this.recycle(p); continue; } - // Runs trail behind whatever is sliding down the page — water rivulets - // and paint drips both leave a tail as long as the distance they covered. if (p.kind === "rivulet" || p.kind === "paint" || p.kind === "acid") p.len = (p.len ?? 0) + Math.max(0, p.vy) * dt; @@ -198,7 +195,6 @@ export class ParticleSystem { } } - // Water droplets extinguish flames they touch. if (p.kind === "water") { if (world.flameCount() > 0 && world.dowse(p.x, p.y, 10, 0.12) > 0) { this.recycle(p); @@ -273,11 +269,9 @@ export class ParticleSystem { p.drag = 0.6; this.spawn(p); } - // Lingering wet mark. const wet = scratchParticle("wet", x, y, 0, 12, 2.5 + Math.random() * 2, 5 + Math.random() * 9); wet.gravity = 0; this.spawn(wet); - // Every few splashes, one gathers into a run that streaks down the page. if (Math.random() < 0.22) { const p = scratchParticle( "rivulet", diff --git a/src/performance.ts b/src/performance.ts index 55faf50..982e986 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -273,9 +273,21 @@ export class PerformanceMonitor implements PerfCounterSink { return Math.max(30, Math.min(120, Math.round(1_000 / this.displayIntervalMs))); } - /** True while the high tier is holding full quality at a 60 fps cap. */ - get rateCap60(): boolean { - return this.rateCapped; + /** + * The shortest gap between rendered frames at a tier. The 60 fps rate cap + * borrows the balanced tier's frame skip: full visual quality, half the + * cadence on >90 Hz displays. + */ + minFrameIntervalMs(tier: PerformanceQualityTier): number { + return tier === "high" && this.rateCapped + ? QUALITY_PROFILES.balanced.minFrameIntervalMs + : QUALITY_PROFILES[tier].minFrameIntervalMs; + } + + /** The frame rate a tier is judged against: native only for uncapped high. */ + targetFps(tier: PerformanceQualityTier): number { + const native = this.nativeTargetFps; + return tier === "high" && !this.rateCapped ? native : Math.min(60, native); } onSample(callback: (snapshot: PerformanceSnapshot) => void): () => void { @@ -366,11 +378,6 @@ export class PerformanceMonitor implements PerfCounterSink { this.captureMs = durationMs; } - setQualityReason(reason: string) { - if (this.disposed) return; - this.qualityReason = reason; - } - observeRaf(now: number) { if (this.disposed) return; if (this.lastRafAt > 0) { diff --git a/src/physics.ts b/src/physics.ts index 85855bc..4d7df3d 100644 --- a/src/physics.ts +++ b/src/physics.ts @@ -109,9 +109,6 @@ export class Body { constructor(init: BodyInit) { const pts = init.points; const n = pts.length >> 1; - // Shoelace: area and centroid in one pass. A degenerate sliver gets a - // fallback centroid so it still behaves like a (tiny) body rather than - // dividing by zero. let area2 = 0; let cx = 0; let cy = 0; @@ -301,7 +298,6 @@ export class PhysicsWorld { bodies: Body[] = []; gravity: number; private maxBodies = MAX_BODIES; - /** Velocity/position solver passes per step (see `WorldOptions.iterations`). */ private iterations: number; /** Contact storage is pooled so a tumbling heap does not allocate every frame. */ private manifolds: Manifold[] = []; @@ -385,7 +381,6 @@ export class PhysicsWorld { }); this.statics = [this.floor, this.leftWall, this.rightWall]; } - // Statics are repositioned by moving the centroid; the local shape is fixed. this.floor.x = width / 2; this.floor.y = floorY + 1500; this.leftWall!.x = -1500; @@ -575,7 +570,6 @@ export class PhysicsWorld { const bodies = this.bodies; this.impacts.length = 0; - // Retire finished bodies (faded out, eaten, or fallen far past the floor). let write = 0; for (let i = 0; i < bodies.length; i++) { const b = bodies[i]; @@ -658,7 +652,6 @@ export class PhysicsWorld { const m = manifolds[manifoldCount] ?? (manifolds[manifoldCount] = createManifold(a, b)); if (collide(a, b, m)) { manifoldCount++; - // Anything touching a moving body joins the simulation again. a.wake(); b.wake(); } diff --git a/src/pointer-input.ts b/src/pointer-input.ts index c381868..57af08f 100644 --- a/src/pointer-input.ts +++ b/src/pointer-input.ts @@ -1,3 +1,4 @@ +import { isParked, PARKED_POINTER } from "./math"; import type { PointerInputController, PointerInputHost } from "./pointer-input-host"; import type { ToolPointerEvent, Vec2 } from "./types"; @@ -7,7 +8,6 @@ import type { ToolPointerEvent, Vec2 } from "./types"; * total displacement without letting an event storm allocate indefinitely. */ const POINTER_RING_CAP = 32; -const PARKED_POINTER = -1000; /** * Own the complete pointer gesture lifecycle for an engine. @@ -77,8 +77,8 @@ export function createPointerInput(host: PointerInputHost): PointerInputControll const move = pendingMoves[i]; moveScratch.x = move.x; moveScratch.y = move.y; - moveScratch.dx = previous.x < -100 ? 0 : move.x - previous.x; - moveScratch.dy = previous.y < -100 ? 0 : move.y - previous.y; + moveScratch.dx = isParked(previous) ? 0 : move.x - previous.x; + moveScratch.dy = isParked(previous) ? 0 : move.y - previous.y; moveScratch.buttons = move.buttons; previous.x = move.x; previous.y = move.y; @@ -149,8 +149,8 @@ export function createPointerInput(host: PointerInputHost): PointerInputControll const toolEvent = { x, y, - dx: previous.x < -100 ? 0 : x - previous.x, - dy: previous.y < -100 ? 0 : y - previous.y, + dx: isParked(previous) ? 0 : x - previous.x, + dy: isParked(previous) ? 0 : y - previous.y, buttons: event.buttons, }; previous.x = x; diff --git a/src/postfx.ts b/src/postfx.ts index 320b637..371aa5f 100644 --- a/src/postfx.ts +++ b/src/postfx.ts @@ -27,7 +27,7 @@ * in the DOM. Nothing else in the engine knows the difference. */ -import { canvasUploadCostMs, GpuTimer, SLOW_UPLOAD_THRESHOLD_MS } from "./gl"; +import { canvasUploadsAreSlow, GpuTimer } from "./gl"; import type { PerfCounterSink } from "./perf-counters"; const VERT = ` @@ -254,7 +254,7 @@ export class PostFX { // transfer is slow (Gecko/WebKit, per the probe) the bloom would cost // several frames of budget on its own. Stay unavailable — the engine then // presents the plain 2D fx canvas — and hand the context straight back. - if (canvasUploadCostMs(gl) > SLOW_UPLOAD_THRESHOLD_MS) { + if (canvasUploadsAreSlow(gl)) { gl.getExtension("WEBGL_lose_context")?.loseContext(); return; } diff --git a/src/react/RageLayer.tsx b/src/react/RageLayer.tsx index bb85a4c..f4c3275 100644 --- a/src/react/RageLayer.tsx +++ b/src/react/RageLayer.tsx @@ -3,9 +3,10 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { defaultTools } from "../default-tools"; -import { RageLayerEngine } from "../engine"; +import type { RageLayerEngine } from "../engine"; import type { RageLayerStrings } from "../strings"; -import { type ToolbarButton as ButtonState, ToolbarModel, type ToolbarState } from "../toolbar"; +import type { ToolbarButton as ButtonState, ToolbarModel, ToolbarState } from "../toolbar"; +import { openToolbarSession, rovingFocusTarget } from "../toolbar-session"; import type { RageLayerEngineOptions, Tool, ToolStyle } from "../types"; import { acquireStyles, @@ -110,37 +111,25 @@ export function RageLayer({ const resolvedToolStyle = engineOptions?.toolStyle ?? toolStyle; useEffect(() => { - const engine = new RageLayerEngine({ - soundEnabled: soundDefault, - ...engineOptions, - toolStyle: resolvedToolStyle, - }); - for (const tool of toolset) engine.registerTool(tool); - - const model = new ToolbarModel(engine, { + const session = openToolbarSession({ + engineOptions: { soundEnabled: soundDefault, ...engineOptions, toolStyle: resolvedToolStyle }, tools: toolset, strings, - toolStyle: resolvedToolStyle, onClose, }); + const { engine, model } = session; engineRef.current = engine; modelRef.current = model; acquireStyles(); const unsubscribe = model.subscribe(setState); - const onWindowKeyDown = (event: KeyboardEvent) => { - if (model.handleKeyDown(event)) event.preventDefault(); - }; - window.addEventListener("keydown", onWindowKeyDown); const debugWindow = window as unknown as { __rageLayer?: RageLayerEngine }; if (debugGlobal) debugWindow.__rageLayer = engine; return () => { - window.removeEventListener("keydown", onWindowKeyDown); unsubscribe(); - model.destroy(); - engine.dispose(); + session.close(); if (debugWindow.__rageLayer === engine) delete debugWindow.__rageLayer; releaseStyles(); modelRef.current = null; @@ -153,12 +142,7 @@ export function RageLayer({ const ready = state !== null; useEffect(() => { - if (!ready) return; - const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; - toolbarRef.current?.querySelector("button.rl-tool")?.focus(); - return () => { - if (previous?.isConnected) previous.focus(); - }; + if (ready) toolbarRef.current?.querySelector("button.rl-tool")?.focus(); }, [ready]); if (!state) return null; @@ -167,21 +151,15 @@ export function RageLayer({ Array.from(toolbarRef.current?.querySelectorAll("button.rl-tool") ?? []); const onToolbarKeyDown = (event: React.KeyboardEvent) => { - const count = state.buttons.length; - let next: number | null = null; - if (event.key === "ArrowLeft") next = state.focusIndex - 1; - else if (event.key === "ArrowRight") next = state.focusIndex + 1; - else if (event.key === "Home") next = 0; - else if (event.key === "End") next = count - 1; - if (next === null || count === 0) return; + const next = rovingFocusTarget(event.key, state.focusIndex, state.buttons.length); + if (next === null) return; event.preventDefault(); // Arrows inside the toolbar navigate the toolbar; they must not reach // the document-level shortcut handler. event.stopPropagation(); - const normalized = ((next % count) + count) % count; - modelRef.current?.setFocusIndex(normalized); - buttons()[normalized]?.focus(); + modelRef.current?.setFocusIndex(next); + buttons()[next]?.focus(); }; const chip = state.status; diff --git a/src/react/toolbar-styles.ts b/src/react/toolbar-styles.ts index a1a8ea3..f12e9a5 100644 --- a/src/react/toolbar-styles.ts +++ b/src/react/toolbar-styles.ts @@ -1,4 +1,5 @@ import type React from "react"; +import { sharedStyle } from "../shared-style"; /** * Every static style the toolbar uses. @@ -115,8 +116,6 @@ export const dividerStyle: React.CSSProperties = { flexShrink: 0, }; -const STYLE_ATTR = "data-ragelayer-toolbar-styles"; - const KEYFRAMES = ` @keyframes rl-rise { from { opacity: 0; transform: translateY(24px) scale(0.92); } @@ -196,24 +195,8 @@ const KEYFRAMES = ` } `; -// The