diff --git a/Dockerfile b/Dockerfile index e5e01590e6..3fb9420a87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,7 +77,7 @@ ENV GIT_COMMIT="$GIT_COMMIT" RUN <<'EOF' tee /usr/local/bin/start.sh #!/bin/sh if [ "$DOMAIN" = openfront.dev ] && [ "$SUBDOMAIN" != main ]; then - exec timeout 25h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf + exec timeout 720h /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf else exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf fi diff --git a/eslint.config.js b/eslint.config.js index 3168893d5b..e75c2f38c4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,6 +26,7 @@ export default [ allowDefaultProject: [ "__mocks__/fileMock.js", "eslint.config.js", + "scripts/build-namelayer-assets.mjs", "scripts/sync-assets.mjs", ], }, diff --git a/index.html b/index.html index b196749fde..c5c1a622e2 100644 --- a/index.html +++ b/index.html @@ -343,7 +343,9 @@ + + diff --git a/package-lock.json b/package-lock.json index 6667c04938..5e57dc4450 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "lit": "^3.3.1", "lit-markdown": "^1.3.2", "mrmime": "^2.0.0", + "msdf-bmfont-xml": "^2.8.0", "pixi-filters": "^6.1.5", "pixi.js": "^8.18.1", "prettier": "^3.5.3", @@ -93,6 +94,7 @@ "protobufjs": "^7.5.5", "sinon": "^21.0.1", "sinon-chai": "^4.0.0", + "skia-canvas": "^3.0.8", "tailwindcss": "^4.1.18", "tsconfig-paths": "^4.2.0", "typescript": "^6.0.3", @@ -1105,6 +1107,16 @@ "node": ">=18" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -2036,6 +2048,562 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jimp/core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.1.tgz", + "integrity": "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==", + "dev": true, + "dependencies": { + "@jimp/file-ops": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "await-to-js": "^3.0.0", + "exif-parser": "^0.1.12", + "file-type": "^21.3.3", + "mime": "3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/diff": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.1.tgz", + "integrity": "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ==", + "dev": true, + "dependencies": { + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "pixelmatch": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/file-ops": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.1.tgz", + "integrity": "sha512-T+gX6osHjprbDRad0/B71Evyre7ZdVY1z/gFGEG9Z8KOtZPKboWvPeP2UjbZYWQLy9UKCPQX1FNAnDiOPkJL7w==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-bmp": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.1.tgz", + "integrity": "sha512-xzWzNT4/u5zGrTT3Tme9sGU7YzIKxi13+BCQwLqACbt5DXf9SAfdzRkopZQnmDko+6In5nqaT89Gjs43/WdnYQ==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "bmp-ts": "^1.0.9" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-gif": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.1.tgz", + "integrity": "sha512-YjY2W26rQa05XhanYhRZ7dingCiNN+T2Ymb1JiigIbABY0B28wHE3v3Cf1/HZPWGu0hOg36ylaKgV5KxF2M58w==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "gifwrap": "^0.10.1", + "omggif": "^1.0.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-jpeg": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.1.tgz", + "integrity": "sha512-HT9H3yOmlOFzYmdI15IYdfy6ggQhSRIaHeA+OTJSEORXBqEo97sUZu/DsgHIcX5NJ7TkJBTgZ9BZXsV6UbsyMg==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "jpeg-js": "^0.4.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-png": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.1.tgz", + "integrity": "sha512-SZ/KVhI5UjcSzzlXsXdIi/LhJ7UShf2NkMOtVrbZQcGzsqNtynAelrOXeoTxcanfVqmNhAoVHg8yR2cYoqrYjA==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "pngjs": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-tiff": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.1.tgz", + "integrity": "sha512-jDG/eJquID1M4MBlKMmDRBmz2TpXMv7TUyu2nIRUxhlUc2ogC82T+VQUkca9GJH1BBJ9dx5sSE5dGkWNjIbZxw==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "utif2": "^4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.1.tgz", + "integrity": "sha512-MwnI7C7K81uWddY9FLw1fCOIy6SsPIUftUz36Spt7jisCn8/40DhQMlSxpxTNelnZb/2SnloFimQfRZAmHLOqQ==", + "dev": true, + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.1.tgz", + "integrity": "sha512-lIo7Tzp5jQu30EFFSK/phXANK3citKVEjepDjQ6ljHoIFtuMRrnybnmI2Md24ulvWlDaz+hh3n6qrMb8ydwhZQ==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/utils": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.1.tgz", + "integrity": "sha512-kK1PavY6cKHNNKce37vdV4Tmpc1/zDKngGoeOV3j+EMatoHFZUinV3s6F9aWryPs3A0xhCLZgdJ6Zeea1d5LCQ==", + "dev": true, + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.1.tgz", + "integrity": "sha512-LtUN1vAP+LRlZAtTNVhDRSiXx+26Kbz3zJaG6a5k59gQ95jgT5mknnF8lxkHcqJthM4MEk3/tPxkdJpEybyF/A==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "tinycolor2": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-color/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.1.tgz", + "integrity": "sha512-m0qhrfA8jkTqretGv4w+T/ADFR4GwBpE0sCOC2uJ0dzr44/ddOMsIdrpi89kabqYiPYIrxkgdCVCLm3zn1Vkkg==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-contain/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.1.tgz", + "integrity": "sha512-hZytnsth0zoll6cPf434BrT+p/v569Wr5tyO6Dp0dH1IDPhzhB5F38sZGMLDo7bzQiN9JFVB3fxkcJ/WYCJ3Mg==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-cover/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.1.tgz", + "integrity": "sha512-EerRSLlclXyKDnYc/H9w/1amZW7b7v3OGi/VlerPd2M/pAu5X8TkyYWtfqYCXnNp1Ixtd8oCo9zGfY9zoXT4rg==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-crop/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.1.tgz", + "integrity": "sha512-K07QVl7xQwIfD6KfxRV/c3E9e7ZBXxUXdWuvoTWcKHL2qV48MOF5Nqbz/aJW4ThnQARIsxvYlZjPFiqkCjlU+g==", + "dev": true, + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-displace/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.1.tgz", + "integrity": "sha512-+2V+GCV2WycMoX1/z977TkZ8Zq/4MVSKElHYatgUqtwXMi2fDK2gKYU2g9V39IqFvTJsTIsK0+58VFz/ROBVew==", + "dev": true, + "dependencies": { + "@jimp/types": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.1.tgz", + "integrity": "sha512-XtS5ZyoZ0vxZxJ6gkqI63SivhtI58vX95foMPM+cyzYkRsJXMOYCr8DScxF5bp4Xr003NjYm/P+7+08tibwzHA==", + "dev": true, + "dependencies": { + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.1.tgz", + "integrity": "sha512-ws38W/sGj7LobNRayQ83garxiktOyWxM5vO/y4a/2cy9v65SLEUzVkrj+oeAaUSSObdz4HcCEla7XtGlnAGAaA==", + "dev": true, + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-flip/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-hash": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.1.tgz", + "integrity": "sha512-sZt6ZcMX6i8vFWb4GYnw0pR/o9++ef0dTVcboTB5B/g7nrxCODIB4wfEkJ/YqZM5wUvol77K1qeS0/rVO6z21A==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "any-base": "^1.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.1.tgz", + "integrity": "sha512-SIG0/FcmEj3tkwFxc7fAGLO8o4uNzMpSOdQOhbCgxefQKq5wOVMk9BQx/sdMPBwtMLr9WLq0GzLA/rk6t2v20A==", + "dev": true, + "dependencies": { + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.1.tgz", + "integrity": "sha512-BYVz/X3Xzv8XYilVeDy11NOp0h7BTDjlOtu0BekIFHP1yHVd24AXNzbOy52XlzYZWQ0Dl36HOHEpl/nSNrzc6w==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/types": "1.6.1", + "parse-bmfont-ascii": "^1.0.6", + "parse-bmfont-binary": "^1.0.6", + "parse-bmfont-xml": "^1.1.6", + "simple-xml-to-json": "^1.2.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-print/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-quantize": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.1.tgz", + "integrity": "sha512-J2En9PLURfP+vwYDtuZ9T8yBW6BWYZBScydAjRiPBmJfEhTcNQqiiQODrZf7EqbbX/Sy5H6dAeRiqkgoV9N6Ww==", + "dev": true, + "dependencies": { + "image-q": "^4.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-quantize/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.1.tgz", + "integrity": "sha512-CLkrtJoIz2HdWnpYiN6p8KYcPc00rCH/SUu6o+lfZL05Q4uhecJlnvXuj9x+U6mDn3ldPmJj6aZqMHuUJzdVqg==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/types": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-resize/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.1.tgz", + "integrity": "sha512-nOjVjbbj705B02ksysKnh0POAwEBXZtJ9zQ5qC+X7Tavl3JNn+P3BzQovbBxLPSbUSld6XID9z5ijin4PtOAUg==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-rotate/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.1.tgz", + "integrity": "sha512-JOKv9F8s6tnVLf4sB/2fF0F339EFnHvgEdFYugO6VhowKLsap0pEZmLyE/DlRnYtIj2RddHZVxVMp/eKJ04l2Q==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-threshold/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/types": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.1.tgz", + "integrity": "sha512-leI7YbveTNi565m910XgIOwXyuu074H5qazAD1357HImJSv2hqxnWXpwxQbadGWZ7goZRYBDZy5lpqud0p7q5w==", + "dev": true, + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/types/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@jimp/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-veFPRd93FCnS7AgmCkPgARVGoDRrJ9cm1ujuNyA+UfQ5VKbED2002sm5XfFLFwTsKC8j04heTrwe+tU1dluXOw==", + "dev": true, + "dependencies": { + "@jimp/types": "1.6.1", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -2576,6 +3144,47 @@ "dev": true, "license": "MIT" }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "dev": true, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dev": true, + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", + "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "dev": true, + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -4133,6 +4742,29 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "dev": true + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -5337,6 +5969,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "dependencies": { + "string-width": "^4.1.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -5363,6 +6004,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "dev": true + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -5377,6 +6024,12 @@ "node": ">= 8" } }, + "node_modules/arabic-persian-reshaper": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arabic-persian-reshaper/-/arabic-persian-reshaper-1.0.1.tgz", + "integrity": "sha512-VYBjkhz6o4W1Xt4mD2LAReljJpLSw5CUZMqSBDIQRvFgUSlTKEYghapgBWvkeMWF4W+KF3Fm+/z8EywJU4PBeg==", + "dev": true + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5428,6 +6081,16 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "dev": true, + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -5466,6 +6129,15 @@ "postcss": "^8.1.0" } }, + "node_modules/await-to-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz", + "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5540,6 +6212,12 @@ "readable-stream": "^4.2.0" } }, + "node_modules/bmp-ts": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", + "integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==", + "dev": true + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -5592,6 +6270,90 @@ "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", "license": "MIT" }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "dev": true, + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true + }, + "node_modules/boxen/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/brace-expansion": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", @@ -5738,6 +6500,18 @@ "tslib": "^2.0.3" } }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001762", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", @@ -5876,6 +6650,18 @@ "node": ">= 10.0" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -5892,6 +6678,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cli-truncate": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", @@ -5970,44 +6768,12 @@ "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">=8" + "node": ">=12" } }, "node_modules/cliui/node_modules/wrap-ansi": { @@ -6223,6 +6989,34 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/configstore": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", + "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", + "dev": true, + "dependencies": { + "atomically": "^2.0.3", + "dot-prop": "^9.0.0", + "graceful-fs": "^4.2.11", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/consola": { "version": "2.15.3", "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", @@ -6916,11 +7710,10 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=8" } @@ -7010,6 +7803,21 @@ "tslib": "^2.0.3" } }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "dev": true, + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -7081,6 +7889,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, "node_modules/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", @@ -7232,6 +8046,18 @@ "node": ">=6" } }, + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -7550,6 +8376,12 @@ "node": ">=0.8.x" } }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==", + "dev": true + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -7760,6 +8592,24 @@ "node": ">=16.0.0" } }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "dev": true, + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -7842,6 +8692,26 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8001,6 +8871,16 @@ "js-binary-schema-parser": "^2.0.3" } }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "dev": true, + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -8078,6 +8958,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/globals": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", @@ -8117,6 +9021,27 @@ "dev": true, "license": "ISC" }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8294,6 +9219,21 @@ "node": ">= 4" } }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "dev": true, + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "dev": true + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8439,6 +9379,58 @@ "node": ">=0.10.0" } }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "dev": true, + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", + "dev": true, + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-invalid-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-invalid-path/-/is-invalid-path-1.0.2.tgz", + "integrity": "sha512-6KLcFrPCEP3AFXMfnWrIFkZpYNBVzZAoBJJDEZKtI3LXkaDjM3uFMJQjxiizUuZTZ9Oh9FNv/soXbx5TcpaDmA==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/is-npm": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -8449,6 +9441,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", @@ -8569,6 +9573,44 @@ "node": ">=10" } }, + "node_modules/jimp": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.1.tgz", + "integrity": "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==", + "dev": true, + "dependencies": { + "@jimp/core": "1.6.1", + "@jimp/diff": "1.6.1", + "@jimp/js-bmp": "1.6.1", + "@jimp/js-gif": "1.6.1", + "@jimp/js-jpeg": "1.6.1", + "@jimp/js-png": "1.6.1", + "@jimp/js-tiff": "1.6.1", + "@jimp/plugin-blit": "1.6.1", + "@jimp/plugin-blur": "1.6.1", + "@jimp/plugin-circle": "1.6.1", + "@jimp/plugin-color": "1.6.1", + "@jimp/plugin-contain": "1.6.1", + "@jimp/plugin-cover": "1.6.1", + "@jimp/plugin-crop": "1.6.1", + "@jimp/plugin-displace": "1.6.1", + "@jimp/plugin-dither": "1.6.1", + "@jimp/plugin-fisheye": "1.6.1", + "@jimp/plugin-flip": "1.6.1", + "@jimp/plugin-hash": "1.6.1", + "@jimp/plugin-mask": "1.6.1", + "@jimp/plugin-print": "1.6.1", + "@jimp/plugin-quantize": "1.6.1", + "@jimp/plugin-resize": "1.6.1", + "@jimp/plugin-rotate": "1.6.1", + "@jimp/plugin-threshold": "1.6.1", + "@jimp/types": "1.6.1", + "@jimp/utils": "1.6.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -8588,6 +9630,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "dev": true + }, "node_modules/js-binary-schema-parser": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", @@ -8607,6 +9655,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/js2xmlparser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-5.0.0.tgz", + "integrity": "sha512-ckXs0Fzd6icWurbeAXuqo+3Mhq2m8pOPygsQjTPh8K5UWgKaUgDSHrdDxAfexmT11xvBKOQ6sgYwPkYc5RW/bg==", + "dev": true, + "dependencies": { + "xmlcreate": "^2.0.4" + } + }, "node_modules/jsdom": { "version": "27.4.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", @@ -8710,6 +9767,33 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/ky": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/latest-version": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", + "dev": true, + "dependencies": { + "package-json": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -9019,143 +10103,58 @@ "micromatch": "^4.0.8", "nano-spawn": "^1.0.2", "pidtree": "^0.6.0", - "string-argv": "^0.3.2", - "yaml": "^2.8.0" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/lint-staged/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/commander": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", - "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/listr2": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", - "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", + "string-argv": "^0.3.2", + "yaml": "^2.8.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, "engines": { - "node": ">=12" + "node": ">=20.17" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/lint-staged" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, "engines": { - "node": ">=18" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/listr2/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=20" } }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=18.0.0" } }, "node_modules/lit": { @@ -9321,13 +10320,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, "node_modules/log-update/node_modules/is-fullwidth-code-point": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", @@ -9361,24 +10353,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/log-update/node_modules/strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", @@ -9395,24 +10369,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -9507,6 +10463,24 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "license": "ISC" }, + "node_modules/map-limit": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", + "integrity": "sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==", + "dev": true, + "dependencies": { + "once": "~1.3.0" + } + }, + "node_modules/map-limit/node_modules/once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", @@ -9529,6 +10503,12 @@ "node": ">= 0.4" } }, + "node_modules/maxrects-packer": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/maxrects-packer/-/maxrects-packer-2.7.3.tgz", + "integrity": "sha512-bG6qXujJ1QgttZVIH4WDanhoJtvbud/xP/XPyf6A69C9RdA61BM4TomFALCq2nrTa+tARRIBB4LuIFsnUQU2wA==", + "dev": true + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -9581,6 +10561,18 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -9709,6 +10701,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msdf-bmfont-xml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/msdf-bmfont-xml/-/msdf-bmfont-xml-2.8.0.tgz", + "integrity": "sha512-VK6US7QqNhY9K5sq6TKlpKNlbBch1M2P1vLirf8mZLHK3j7X86fM4sqEyVnwCBYwZ/xiTbJeUWMEv5Ji+jQQMQ==", + "dev": true, + "dependencies": { + "arabic-persian-reshaper": "^1.0.1", + "cli-progress": "^3.12.0", + "commander": "^14.0.0", + "handlebars": "^4.7.8", + "is-invalid-path": "^1.0.2", + "jimp": "^1.6.0", + "js2xmlparser": "^5.0.0", + "map-limit": "0.0.1", + "maxrects-packer": "^2.7.3", + "opentype.js": "^1.3.4", + "update-notifier": "^7.3.1" + }, + "bin": { + "msdf-bmfont": "cli.js" + } + }, + "node_modules/msdf-bmfont-xml/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "engines": { + "node": ">=20" + } + }, "node_modules/nano-spawn": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", @@ -9763,6 +10786,12 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -9868,6 +10897,12 @@ ], "license": "MIT" }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "dev": true + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9907,6 +10942,22 @@ "fn.name": "1.x.x" } }, + "node_modules/opentype.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", + "integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==", + "dev": true, + "dependencies": { + "string.prototype.codepointat": "^0.2.1", + "tiny-inflate": "^1.0.3" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9954,6 +11005,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", + "dev": true, + "dependencies": { + "ky": "^1.2.0", + "registry-auth-token": "^5.0.2", + "registry-url": "^6.0.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -9978,6 +11053,34 @@ "node": ">=6" } }, + "node_modules/parenthesis": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", + "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", + "dev": true + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "dev": true + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==", + "dev": true + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "dev": true, + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -10183,6 +11286,27 @@ "node": ">=0.10" } }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "dev": true, + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "dev": true, + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/pixi-filters": { "version": "6.1.5", "resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.5.tgz", @@ -10230,6 +11354,15 @@ "dev": true, "license": "MIT" }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -10409,6 +11542,12 @@ "node": ">= 0.6.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true + }, "node_modules/protobufjs": { "version": "7.5.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", @@ -10467,6 +11606,21 @@ "node": ">=6" } }, + "node_modules/pupa": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", + "dev": true, + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -10599,6 +11753,33 @@ "node": ">=8.10.0" } }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "dev": true, + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "dev": true, + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", @@ -10868,6 +12049,15 @@ "entities": "^4.4.0" } }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -11157,6 +12347,15 @@ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", "license": "MIT" }, + "node_modules/simple-xml-to-json": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.7.tgz", + "integrity": "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==", + "dev": true, + "engines": { + "node": ">=20.12.2" + } + }, "node_modules/sinon": { "version": "21.0.1", "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.1.tgz", @@ -11201,6 +12400,19 @@ "node": ">=18" } }, + "node_modules/skia-canvas": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/skia-canvas/-/skia-canvas-3.0.8.tgz", + "integrity": "sha512-FSYKxp8Ng2vOeeOBiyPhnn6ui6FirPJXMyjk4PKl8N/OWzVrkMawUgY9zubIWHMdYtyWFn0gfX3QlRwg6HBmdg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "detect-libc": "^2.1.1", + "follow-redirects": "^1.15.11", + "https-proxy-agent": "^7.0.6", + "string-split-by": "^1.0.0" + } + }, "node_modules/slice-ansi": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", @@ -11302,6 +12514,44 @@ "node": ">=0.6.19" } }, + "node_modules/string-split-by": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", + "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", + "dev": true, + "dependencies": { + "parenthesis": "^3.1.5" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "dev": true + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -11340,6 +12590,37 @@ ], "license": "MIT" }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dev": true, + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11520,6 +12801,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "dev": true + }, "node_modules/tiny-lru": { "version": "11.4.7", "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", @@ -11537,6 +12824,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "dev": true + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -11647,6 +12940,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -11859,6 +13170,18 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -11910,6 +13233,31 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -11966,6 +13314,42 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/update-notifier": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", + "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", + "dev": true, + "dependencies": { + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "configstore": "^7.0.0", + "is-in-ci": "^1.0.0", + "is-installed-globally": "^1.0.0", + "is-npm": "^6.0.0", + "latest-version": "^9.0.0", + "pupa": "^3.1.0", + "semver": "^7.6.3", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -11976,6 +13360,15 @@ "punycode": "^2.1.0" } }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "dev": true, + "dependencies": { + "pako": "^1.0.11" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -12282,6 +13675,21 @@ } } }, + "node_modules/vite-tsconfig-paths/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -12939,6 +14347,12 @@ "node": ">=20" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12972,6 +14386,71 @@ "node": ">=8" } }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "dev": true, + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/winston": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", @@ -13046,6 +14525,91 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -13073,6 +14637,18 @@ } } }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -13083,6 +14659,34 @@ "node": ">=18" } }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==", + "dev": true + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -13090,6 +14694,12 @@ "dev": true, "license": "MIT" }, + "node_modules/xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", + "dev": true + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -13155,38 +14765,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index e0e3cecc21..523d3bb081 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "docs:map-generator": "cd map-generator && go doc -cmd -u -all", "tunnel": "npm run build-prod && npm run start:server", "test": "vitest run && vitest run tests/server", + "build:namelayer-assets": "node scripts/build-namelayer-assets.mjs", "perf": "npx tsx tests/perf/run-all.ts", "test:coverage": "vitest run --coverage", "format": "prettier --ignore-unknown --write .", @@ -70,12 +71,14 @@ "lit": "^3.3.1", "lit-markdown": "^1.3.2", "mrmime": "^2.0.0", + "msdf-bmfont-xml": "^2.8.0", "pixi-filters": "^6.1.5", "pixi.js": "^8.18.1", "prettier": "^3.5.3", "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-sh": "^0.17.4", "protobufjs": "^7.5.5", + "skia-canvas": "^3.0.8", "sinon": "^21.0.1", "sinon-chai": "^4.0.0", "tailwindcss": "^4.1.18", diff --git a/resources/fonts/namelayer_overpass.png b/resources/fonts/namelayer_overpass.png new file mode 100644 index 0000000000..2dfb1db7b5 Binary files /dev/null and b/resources/fonts/namelayer_overpass.png differ diff --git a/resources/fonts/namelayer_overpass.xml b/resources/fonts/namelayer_overpass.xml new file mode 100644 index 0000000000..506c88f576 --- /dev/null +++ b/resources/fonts/namelayer_overpass.xml @@ -0,0 +1,1158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/fonts/overpass-OFL.txt b/resources/fonts/overpass-OFL.txt new file mode 100644 index 0000000000..2739ed0cfc --- /dev/null +++ b/resources/fonts/overpass-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Overpass Project Authors (https://github.com/RedHatOfficial/Overpass) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/resources/fonts/overpass-regular.otf b/resources/fonts/overpass-regular.otf new file mode 100644 index 0000000000..3a7c095fab Binary files /dev/null and b/resources/fonts/overpass-regular.otf differ diff --git a/resources/fonts/twemoji-colr-OFL.txt b/resources/fonts/twemoji-colr-OFL.txt new file mode 100644 index 0000000000..787fc8ffd4 --- /dev/null +++ b/resources/fonts/twemoji-colr-OFL.txt @@ -0,0 +1,121 @@ +# License for the font file + +SIL OPEN FONT LICENSE +Version 1.1 - 26 February 2007 + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting — in part or in whole — any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +# License for the Visual Design + +The emoji art comes from [Twemoji](https://twitter.github.io/twemoji), +and is used and redistributed under the CC-BY-4.0 [license terms](https://github.com/twitter/twemoji#license) +offered by the Twemoji project. + +### Creative Commons Attribution 4.0 International (CC BY 4.0) +https://creativecommons.org/licenses/by/4.0/legalcode +or for the human readable summary: https://creativecommons.org/licenses/by/4.0/ + + +#### You are free to: +**Share** — copy and redistribute the material in any medium or format + +**Adapt** — remix, transform, and build upon the material for any purpose, even commercially. + +The licensor cannot revoke these freedoms as long as you follow the license terms. + + +#### Under the following terms: +**Attribution** — You must give appropriate credit, provide a link to the license, +and indicate if changes were made. +You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. + +**No additional restrictions** — You may not apply legal terms or **technological measures** +that legally restrict others from doing anything the license permits. + +#### Notices: +You do not have to comply with the license for elements of the material in the public domain +or where your use is permitted by an applicable exception or limitation. No warranties are given. +The license may not give you all of the permissions necessary for your intended use. +For example, other rights such as publicity, privacy, or moral rights may limit how you use the material. + diff --git a/resources/fonts/twemoji-colr.woff2 b/resources/fonts/twemoji-colr.woff2 new file mode 100644 index 0000000000..6cc58b6aa8 Binary files /dev/null and b/resources/fonts/twemoji-colr.woff2 differ diff --git a/resources/images/namelayer-emojis.json b/resources/images/namelayer-emojis.json new file mode 100644 index 0000000000..bd7f38fc8e --- /dev/null +++ b/resources/images/namelayer-emojis.json @@ -0,0 +1,1214 @@ +{ + "frames": { + "😀": { + "frame": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "😊": { + "frame": { + "x": 128, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🥰": { + "frame": { + "x": 256, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "😇": { + "frame": { + "x": 384, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "😎": { + "frame": { + "x": 512, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "😞": { + "frame": { + "x": 640, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🥺": { + "frame": { + "x": 768, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "😭": { + "frame": { + "x": 896, + "y": 0, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "😱": { + "frame": { + "x": 0, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "😡": { + "frame": { + "x": 128, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "😈": { + "frame": { + "x": 256, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🤡": { + "frame": { + "x": 384, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🥱": { + "frame": { + "x": 512, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🫡": { + "frame": { + "x": 640, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🖕": { + "frame": { + "x": 768, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "👋": { + "frame": { + "x": 896, + "y": 128, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "👏": { + "frame": { + "x": 0, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "✋": { + "frame": { + "x": 128, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🙏": { + "frame": { + "x": 256, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "💪": { + "frame": { + "x": 384, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "👍": { + "frame": { + "x": 512, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "👎": { + "frame": { + "x": 640, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🫴": { + "frame": { + "x": 768, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🤌": { + "frame": { + "x": 896, + "y": 256, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🤦‍♂️": { + "frame": { + "x": 0, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🤝": { + "frame": { + "x": 128, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🆘": { + "frame": { + "x": 256, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🕊️": { + "frame": { + "x": 384, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🏳️": { + "frame": { + "x": 512, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "⏳": { + "frame": { + "x": 640, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🔥": { + "frame": { + "x": 768, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "💥": { + "frame": { + "x": 896, + "y": 384, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "💀": { + "frame": { + "x": 0, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "☢️": { + "frame": { + "x": 128, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "⚠️": { + "frame": { + "x": 256, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "↖️": { + "frame": { + "x": 384, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "⬆️": { + "frame": { + "x": 512, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "↗️": { + "frame": { + "x": 640, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "👑": { + "frame": { + "x": 768, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🥇": { + "frame": { + "x": 896, + "y": 512, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "⬅️": { + "frame": { + "x": 0, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🎯": { + "frame": { + "x": 128, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "➡️": { + "frame": { + "x": 256, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🥈": { + "frame": { + "x": 384, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🥉": { + "frame": { + "x": 512, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "↙️": { + "frame": { + "x": 640, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "⬇️": { + "frame": { + "x": 768, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "↘️": { + "frame": { + "x": 896, + "y": 640, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "❤️": { + "frame": { + "x": 0, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "💔": { + "frame": { + "x": 128, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "💰": { + "frame": { + "x": 256, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "⚓": { + "frame": { + "x": 384, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "⛵": { + "frame": { + "x": 512, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🏡": { + "frame": { + "x": 640, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🛡️": { + "frame": { + "x": 768, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🏭": { + "frame": { + "x": 896, + "y": 768, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🚂": { + "frame": { + "x": 0, + "y": 896, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "❓": { + "frame": { + "x": 128, + "y": 896, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🐔": { + "frame": { + "x": 256, + "y": 896, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + }, + "🐀": { + "frame": { + "x": 384, + "y": 896, + "w": 128, + "h": 128 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 128, + "h": 128 + }, + "sourceSize": { + "w": 128, + "h": 128 + } + } + }, + "meta": { + "app": "scripts/build-namelayer-assets.mjs", + "image": "namelayer-emojis.png", + "format": "RGBA8888", + "size": { + "w": 1024, + "h": 1024 + }, + "scale": "1" + } +} diff --git a/resources/images/namelayer-emojis.png b/resources/images/namelayer-emojis.png new file mode 100644 index 0000000000..304fc0bead Binary files /dev/null and b/resources/images/namelayer-emojis.png differ diff --git a/resources/images/namelayer-icons.json b/resources/images/namelayer-icons.json new file mode 100644 index 0000000000..f7cec5ee98 --- /dev/null +++ b/resources/images/namelayer-icons.json @@ -0,0 +1,274 @@ +{ + "frames": { + "AllianceIcon.svg": { + "frame": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "AllianceIconFaded.svg": { + "frame": { + "x": 256, + "y": 0, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "AllianceRequestBlackIcon.svg": { + "frame": { + "x": 512, + "y": 0, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "AllianceRequestWhiteIcon.svg": { + "frame": { + "x": 768, + "y": 0, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "CrownIcon.svg": { + "frame": { + "x": 0, + "y": 256, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "DisconnectedIcon.svg": { + "frame": { + "x": 256, + "y": 256, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "EmbargoBlackIcon.svg": { + "frame": { + "x": 512, + "y": 256, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "EmbargoWhiteIcon.svg": { + "frame": { + "x": 768, + "y": 256, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "NukeIconRed.svg": { + "frame": { + "x": 0, + "y": 512, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "NukeIconWhite.svg": { + "frame": { + "x": 256, + "y": 512, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "QuestionMarkIcon.svg": { + "frame": { + "x": 512, + "y": 512, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "TargetIcon.svg": { + "frame": { + "x": 768, + "y": 512, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + }, + "TraitorIcon.svg": { + "frame": { + "x": 0, + "y": 768, + "w": 256, + "h": 256 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 256, + "h": 256 + }, + "sourceSize": { + "w": 256, + "h": 256 + } + } + }, + "meta": { + "app": "scripts/build-namelayer-assets.mjs", + "image": "namelayer-icons.png", + "format": "RGBA8888", + "size": { + "w": 1024, + "h": 1024 + }, + "scale": "1" + } +} diff --git a/resources/images/namelayer-icons.png b/resources/images/namelayer-icons.png new file mode 100644 index 0000000000..cf61307b53 Binary files /dev/null and b/resources/images/namelayer-icons.png differ diff --git a/resources/lang/en.json b/resources/lang/en.json index 26eb27c294..887fb7cd63 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -703,6 +703,10 @@ "coordinate_grid_desc": "Toggle the alphanumeric grid overlay", "attacking_troops_overlay_label": "Attacking Troops Overlay", "attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.", + "territory_border_mode_label": "Territory Borders", + "territory_border_mode_desc": "Select border rendering style (visual only)", + "renderer_label": "Renderer", + "renderer_desc": "Choose territory rendering backend. Auto uses WebGPU, then WebGL, then Classic.", "performance_overlay_label": "Performance Overlay", "performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.", "easter_writing_speed_label": "Writing Speed Multiplier", @@ -1116,7 +1120,8 @@ "legendary": "Legendary", "adfree": "ad-free for life!", "hard": "Plutonium", - "soft": "Caps" + "soft": "Caps", + "free": "+{numFree} BONUS!" }, "flag_input": { "title": "Select Flag", diff --git a/scripts/build-namelayer-assets.mjs b/scripts/build-namelayer-assets.mjs new file mode 100644 index 0000000000..a0110149e9 --- /dev/null +++ b/scripts/build-namelayer-assets.mjs @@ -0,0 +1,398 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Canvas, FontLibrary, loadImage } from "skia-canvas"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const root = path.resolve(__dirname, ".."); +const fontsDir = path.join(root, "resources", "fonts"); +const imagesDir = path.join(root, "resources", "images"); + +const fontPng = "namelayer_overpass.png"; +const fontXml = "namelayer_overpass.xml"; +const fontFace = "namelayer_overpass"; +const emojiFontFamily = "NameLayerEmoji"; +const emojiFontPath = path.join(fontsDir, "twemoji-colr.woff2"); +const emojiFontSize = 96; +const atlasFramePaddingRatio = 1 / 16; +const colorDetectionThreshold = 12; +const fontSourceCandidates = [ + "overpass-regular.otf", + "overpass-regular.ttf", + "overpass.otf", + "overpass.ttf", + "overpass.woff", +]; +const glyphs = Array.from( + new Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_ \u00fc\u00dc.[]+-=(),':!?/@#$%&\"".split( + "", + ), + ), +); + +const iconSources = [ + "AllianceIcon.svg", + "AllianceIconFaded.svg", + "AllianceRequestBlackIcon.svg", + "AllianceRequestWhiteIcon.svg", + "CrownIcon.svg", + "DisconnectedIcon.svg", + "EmbargoBlackIcon.svg", + "EmbargoWhiteIcon.svg", + "NukeIconRed.svg", + "NukeIconWhite.svg", + "QuestionMarkIcon.svg", + "TargetIcon.svg", + "TraitorIcon.svg", +]; + +fs.mkdirSync(fontsDir, { recursive: true }); +fs.mkdirSync(imagesDir, { recursive: true }); + +const overpassFontPath = findFontSource(); +FontLibrary.use(emojiFontFamily, [emojiFontPath]); + +await buildMsdfFont(); +await buildIconAtlas(); +await buildEmojiAtlas(); + +async function buildMsdfFont() { + if (!overpassFontPath) { + const fallbackXml = fs + .readFileSync(path.join(fontsDir, "round_6x6_modified.xml"), "utf8") + .replace(/face="round_6x6_modified"/g, `face="${fontFace}"`) + .replace(/file="round_6x6_modified\.png"/g, `file="${fontPng}"`); + fs.writeFileSync( + path.join(fontsDir, fontPng), + fs.readFileSync(path.join(fontsDir, "round_6x6_modified.png")), + ); + fs.writeFileSync(path.join(fontsDir, fontXml), fallbackXml); + return; + } + + const generateBMFont = require("msdf-bmfont-xml"); + const { textures, font } = await new Promise((resolve, reject) => { + generateBMFont( + overpassFontPath, + { + filename: path.join(fontsDir, path.basename(fontPng, ".png")), + outputType: "xml", + charset: glyphs, + fontSize: 64, + textureSize: [2048, 2048], + texturePadding: 2, + distanceRange: 8, + fieldType: "msdf", + smartSize: true, + pot: true, + roundDecimal: 0, + }, + (error, textures, font) => { + if (error) { + reject(error); + return; + } + resolve({ textures, font }); + }, + { + log: () => {}, + warn: (message) => console.warn(`NameLayer MSDF font: ${message}`), + error: (message) => console.error(`NameLayer MSDF font: ${message}`), + }, + ); + }); + + for (const texture of textures) { + fs.writeFileSync(`${texture.filename}.png`, texture.texture); + } + + const xml = String(font.data).replace( + /(]*face=")[^"]+(")/, + `$1${fontFace}$2`, + ); + fs.writeFileSync(path.join(fontsDir, fontXml), xml); +} + +async function buildIconAtlas() { + const cell = 256; + const cols = 4; + const rows = Math.ceil(iconSources.length / cols); + const canvas = new Canvas(cols * cell, rows * cell); + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + const frames = {}; + + for (let i = 0; i < iconSources.length; i++) { + const source = iconSources[i]; + const col = i % cols; + const row = Math.floor(i / cols); + const x = col * cell; + const y = row * cell; + try { + const img = await loadIconImage(path.join(imagesDir, source)); + drawPackedAtlasFrame(ctx, x, y, cell, (scratchCtx, scratchSize) => { + drawContainedImage(scratchCtx, img, 0, 0, scratchSize, scratchSize); + }); + } catch (error) { + console.warn( + `Could not pack ${source}; leaving empty atlas frame`, + error, + ); + } + frames[source] = { + frame: { x, y, w: cell, h: cell }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: cell, h: cell }, + sourceSize: { w: cell, h: cell }, + }; + } + + validateAtlasFramesPixels(ctx, canvas.width, canvas.height, frames, { + label: "icon", + requireColor: false, + }); + + fs.writeFileSync( + path.join(imagesDir, "namelayer-icons.png"), + await canvas.toBuffer("png"), + ); + fs.writeFileSync( + path.join(imagesDir, "namelayer-icons.json"), + `${JSON.stringify( + { + frames, + meta: { + app: "scripts/build-namelayer-assets.mjs", + image: "namelayer-icons.png", + format: "RGBA8888", + size: { w: canvas.width, h: canvas.height }, + scale: "1", + }, + }, + null, + 2, + )}\n`, + ); +} + +async function loadIconImage(sourcePath) { + if (path.extname(sourcePath).toLowerCase() !== ".svg") { + return loadImage(sourcePath); + } + + let svg = fs.readFileSync(sourcePath, "utf8"); + if (!/]*\swidth=/i.test(svg) || !/]*\sheight=/i.test(svg)) { + const viewBoxMatch = svg.match( + /viewBox=["']\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*["']/i, + ); + const width = viewBoxMatch?.[3] ?? 64; + const height = viewBoxMatch?.[4] ?? 64; + svg = svg.replace(/ { + const col = index % cols; + const row = Math.floor(index / cols); + const x = col * cell; + const y = row * cell; + drawPackedAtlasFrame(ctx, x, y, cell, (scratchCtx, scratchSize) => { + drawEmojiText(scratchCtx, scratchSize, emoji); + }); + frames[emoji] = { + frame: { x, y, w: cell, h: cell }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: cell, h: cell }, + sourceSize: { w: cell, h: cell }, + }; + }); + + validateAtlasFramesPixels(ctx, canvas.width, canvas.height, frames, { + label: "emoji", + requireColor: true, + }); + + fs.writeFileSync( + path.join(imagesDir, "namelayer-emojis.png"), + await canvas.toBuffer("png"), + ); + fs.writeFileSync( + path.join(imagesDir, "namelayer-emojis.json"), + `${JSON.stringify( + { + frames, + meta: { + app: "scripts/build-namelayer-assets.mjs", + image: "namelayer-emojis.png", + format: "RGBA8888", + size: { w: canvas.width, h: canvas.height }, + scale: "1", + }, + }, + null, + 2, + )}\n`, + ); +} + +function drawEmojiText(ctx, size, emoji) { + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = `${emojiFontSize}px ${emojiFontFamily}`; + ctx.fillText(emoji, size / 2, size / 2); +} + +function drawPackedAtlasFrame(targetCtx, x, y, cell, drawSource) { + const scratchSize = cell * 2; + const scratch = new Canvas(scratchSize, scratchSize); + const scratchCtx = scratch.getContext("2d"); + scratchCtx.clearRect(0, 0, scratchSize, scratchSize); + drawSource(scratchCtx, scratchSize); + + const bounds = findAlphaBounds( + scratchCtx.getImageData(0, 0, scratchSize, scratchSize).data, + scratchSize, + scratchSize, + ); + if (!bounds) { + throw new Error("NameLayer atlas frame source rendered empty"); + } + + const sourceWidth = bounds.maxX - bounds.minX + 1; + const sourceHeight = bounds.maxY - bounds.minY + 1; + const padding = Math.round(cell * atlasFramePaddingRatio); + const maxSize = cell - padding * 2; + const scale = Math.min(maxSize / sourceWidth, maxSize / sourceHeight, 1); + const drawWidth = Math.ceil(sourceWidth * scale); + const drawHeight = Math.ceil(sourceHeight * scale); + const drawX = x + Math.floor((cell - drawWidth) / 2); + const drawY = y + Math.floor((cell - drawHeight) / 2); + + targetCtx.drawImage( + scratch, + bounds.minX, + bounds.minY, + sourceWidth, + sourceHeight, + drawX, + drawY, + drawWidth, + drawHeight, + ); +} + +function drawContainedImage(ctx, image, x, y, width, height) { + const sourceWidth = image.width ?? width; + const sourceHeight = image.height ?? height; + const scale = Math.min(width / sourceWidth, height / sourceHeight); + const drawWidth = sourceWidth * scale; + const drawHeight = sourceHeight * scale; + ctx.drawImage( + image, + x + (width - drawWidth) / 2, + y + (height - drawHeight) / 2, + drawWidth, + drawHeight, + ); +} + +function findAlphaBounds(data, width, height) { + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (data[(y * width + x) * 4 + 3] === 0) { + continue; + } + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + + return maxX >= minX && maxY >= minY ? { minX, minY, maxX, maxY } : null; +} + +function validateAtlasFramesPixels( + ctx, + width, + height, + frames, + { label, requireColor }, +) { + const data = ctx.getImageData(0, 0, width, height).data; + let colorfulPixels = 0; + + for (const [key, { frame }] of Object.entries(frames)) { + let alphaPixels = 0; + for (let y = frame.y; y < frame.y + frame.h; y++) { + for (let x = frame.x; x < frame.x + frame.w; x++) { + const offset = (y * width + x) * 4; + const r = data[offset]; + const g = data[offset + 1]; + const b = data[offset + 2]; + const a = data[offset + 3]; + if (a === 0) { + continue; + } + alphaPixels++; + if (Math.max(r, g, b) - Math.min(r, g, b) > colorDetectionThreshold) { + colorfulPixels++; + } + } + } + + if (alphaPixels === 0) { + throw new Error(`NameLayer ${label} atlas frame is empty: ${key}`); + } + } + + if (requireColor && colorfulPixels === 0) { + throw new Error(`NameLayer ${label} atlas rendered without color pixels`); + } +} + +function readEmojiTable() { + const utilPath = path.join(root, "src", "core", "Util.ts"); + const utilSource = fs.readFileSync(utilPath, "utf8"); + const match = utilSource.match( + /export const emojiTable = \[([\s\S]*?)\] as const;/, + ); + if (!match?.[1]) { + throw new Error( + `emojiTable not found in utilSource (${utilPath}). Start of file: ${utilSource.slice( + 0, + 160, + )}`, + ); + } + + return Array.from(match[1].matchAll(/"([^"]+)"/g), (match) => match[1]); +} + +function findFontSource() { + return fontSourceCandidates + .map((fileName) => path.join(fontsDir, fileName)) + .find((candidate) => fs.existsSync(candidate)); +} diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 7fd16a3063..35ef8238b7 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -142,9 +142,9 @@ export function joinLobby( terrainLoad, terrainMapFileLoader, ) - .then((r) => { + .then(async (r) => { currentGameRunner = r; - r.start(); + await r.start(); }) .catch((e) => { console.error("error creating client game", e); @@ -367,7 +367,7 @@ export class ClientGameRunner { endGame(record); } - public start() { + public async start() { this.soundManager.playBackgroundMusic(); console.log("starting client game"); @@ -400,7 +400,7 @@ export class ClientGameRunner { this.doBreakAllianceUnderCursor.bind(this), ); - this.renderer.initialize(); + await this.renderer.initialize(); this.input.initialize(); this.worker.start((gu: GameUpdateViewData | ErrorUpdate) => { if (this.lobby.gameStartInfo === undefined) { diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 1c12cf61b3..b24671d96f 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -193,6 +193,10 @@ export class TickMetricsEvent implements GameEvent { ) {} } +export class WebGPUComputeMetricsEvent implements GameEvent { + constructor(public readonly computeMs: number) {} +} + export class InputHandler { private lastPointerX: number = 0; private lastPointerY: number = 0; diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index 60d434dbf4..fafb8e0e27 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -300,6 +300,24 @@ export class UserSettingModal extends BaseModal { this.requestUpdate(); } + private changeTerritoryBorderMode( + e: CustomEvent<{ value: number | string }>, + ) { + const rawValue = e.detail?.value; + const value = + typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10); + if (!Number.isFinite(value)) return; + + this.userSettings.setInt("settings.territoryBorderMode", Math.round(value)); + this.requestUpdate(); + } + + private changeTerritoryRenderer(e: CustomEvent<{ value: number | string }>) { + const value = String(e.detail?.value ?? "auto"); + this.userSettings.setTerritoryRenderer(value); + this.requestUpdate(); + } + private toggleTerritoryPatterns() { this.userSettings.toggleTerritoryPatterns(); @@ -752,6 +770,35 @@ export class UserSettingModal extends BaseModal { > + + + + ${icon} ${translateText(currencyKey)} + ${pack.bonusAmount > 0 + ? html`
+ ${translateText("cosmetics.free", { + numFree: pack.bonusAmount.toLocaleString(), + })} +
` + : nothing} `; } diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 7b149df948..5786c80578 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -124,7 +124,7 @@ export class DesktopNavBar extends LitElement { data-i18n="main.leaderboard" > diff --git a/src/client/components/MobileNavBar.ts b/src/client/components/MobileNavBar.ts index aeaeb67228..b6b682911e 100644 --- a/src/client/components/MobileNavBar.ts +++ b/src/client/components/MobileNavBar.ts @@ -115,7 +115,7 @@ export class MobileNavBar extends LitElement { data-i18n="main.leaderboard" > diff --git a/src/client/components/baseComponents/setting/SettingSelect.ts b/src/client/components/baseComponents/setting/SettingSelect.ts index c36a8e1d7d..290d5b6154 100644 --- a/src/client/components/baseComponents/setting/SettingSelect.ts +++ b/src/client/components/baseComponents/setting/SettingSelect.ts @@ -1,7 +1,7 @@ -import { LitElement, html } from "lit"; +import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; -type SelectOption = { +export type SettingSelectOption = { value: number | string; label: string; }; @@ -10,8 +10,10 @@ type SelectOption = { export class SettingSelect extends LitElement { @property() label = "Setting"; @property() description = ""; - @property({ type: Array }) options: SelectOption[] = []; + @property() id = "setting-select-input"; + @property({ type: Array }) options: SettingSelectOption[] = []; @property({ type: String }) value = ""; + @property({ type: Boolean }) easter = false; createRenderRoot() { return this; @@ -35,14 +37,18 @@ export class SettingSelect extends LitElement { } render() { + const rainbowClass = this.easter + ? "bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)] bg-[length:1400%_1400%] animate-rainbow-bg text-white hover:bg-[linear-gradient(270deg,#990033,#996600,#336600,#008080,#1c3f99,#5e0099,#990033)]" + : ""; + return html`
@@ -51,7 +57,7 @@ export class SettingSelect extends LitElement {
+ ${TERRITORY_RENDERER_OPTIONS.map( + (option) => + html``, + )} + +
+ ${note ? html`
${note}
` : null} +
+
+ `; + } +} diff --git a/src/client/graphics/layers/SegmentMotionSample.ts b/src/client/graphics/layers/SegmentMotionSample.ts new file mode 100644 index 0000000000..15e11a1625 --- /dev/null +++ b/src/client/graphics/layers/SegmentMotionSample.ts @@ -0,0 +1,103 @@ +import { TileRef } from "../../../core/game/GameMap"; +import type { GameView } from "../../../core/game/GameView"; + +export type GridSegmentMotionPlanView = { + planId: number; + startTick: number; + ticksPerStep: number; + points: Uint32Array; + segmentSteps: Uint32Array; + segCumSteps: Uint32Array; +}; + +export type SampledMotionPosition = { + x: number; + y: number; + isComplete: boolean; + tile0: TileRef; + tile1: TileRef; +}; + +function clamp01(v: number): number { + if (v <= 0) return 0; + if (v >= 1) return 1; + return v; +} + +export function sampleGridSegmentPlan( + game: GameView, + plan: GridSegmentMotionPlanView, + tickFloat: number, +): SampledMotionPosition | null { + const points = plan.points; + if (points.length === 0) { + return null; + } + if (points.length === 1 || plan.segmentSteps.length === 0) { + const t = points[0] as TileRef; + return { x: game.x(t), y: game.y(t), isComplete: true, tile0: t, tile1: t }; + } + + const ticksPerStep = Math.max(1, plan.ticksPerStep); + const stepFloat = (tickFloat - plan.startTick) / ticksPerStep; + + const segCum = plan.segCumSteps; + const totalSteps = segCum.length === 0 ? 0 : segCum[segCum.length - 1] >>> 0; + if (totalSteps <= 0) { + const t = points[points.length - 1] as TileRef; + return { x: game.x(t), y: game.y(t), isComplete: true, tile0: t, tile1: t }; + } + + if (stepFloat <= 0) { + const t = points[0] as TileRef; + const t1 = points[1] as TileRef; + return { + x: game.x(t), + y: game.y(t), + isComplete: false, + tile0: t, + tile1: t1, + }; + } + if (stepFloat >= totalSteps) { + const t = points[points.length - 1] as TileRef; + return { x: game.x(t), y: game.y(t), isComplete: true, tile0: t, tile1: t }; + } + + // Find the segment containing stepFloat. + let seg = 0; + let lo = 0; + let hi = plan.segmentSteps.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + const start = segCum[mid] >>> 0; + const end = segCum[mid + 1] >>> 0; + if (stepFloat < start) { + hi = mid - 1; + } else if (stepFloat >= end) { + lo = mid + 1; + } else { + seg = mid; + break; + } + } + + const segStart = segCum[seg] >>> 0; + const steps = Math.max(1, plan.segmentSteps[seg] >>> 0); + const u = clamp01((stepFloat - segStart) / steps); + + const tile0 = points[seg] as TileRef; + const tile1 = points[seg + 1] as TileRef; + const x0 = game.x(tile0); + const y0 = game.y(tile0); + const x1 = game.x(tile1); + const y1 = game.y(tile1); + + return { + x: x0 + (x1 - x0) * u, + y: y0 + (y1 - y0) * u, + isComplete: false, + tile0, + tile1, + }; +} diff --git a/src/client/graphics/layers/SegmentTrailRaster.ts b/src/client/graphics/layers/SegmentTrailRaster.ts new file mode 100644 index 0000000000..8ca1d1a723 --- /dev/null +++ b/src/client/graphics/layers/SegmentTrailRaster.ts @@ -0,0 +1,169 @@ +import { TileRef } from "../../../core/game/GameMap"; + +type TrailGameView = { + x(ref: TileRef): number; + y(ref: TileRef): number; +}; + +export type SegmentTrailPlanView = { + startTick: number; + ticksPerStep: number; + points: Uint32Array; + segmentSteps: Uint32Array; + segCumSteps: Uint32Array; +}; + +export function totalTrailSteps(plan: { + segCumSteps: Uint32Array; +}): number { + return plan.segCumSteps.length === 0 + ? 0 + : plan.segCumSteps[plan.segCumSteps.length - 1] >>> 0; +} + +export function stepAtTick( + plan: SegmentTrailPlanView, + tick: number, +): number { + const total = totalTrailSteps(plan); + if (total <= 0) { + return 0; + } + const dt = tick - plan.startTick; + if (dt <= 0) { + return 0; + } + const ticksPerStep = Math.max(1, plan.ticksPerStep); + const step = Math.floor(dt / ticksPerStep); + return Math.max(0, Math.min(total, step)); +} + +export function locateSegment( + segCumSteps: Uint32Array, + segmentCount: number, + step: number, +): number { + if (segmentCount <= 0) { + return 0; + } + const total = + segCumSteps.length === 0 + ? 0 + : segCumSteps[segCumSteps.length - 1] >>> 0; + if (total <= 0) { + return 0; + } + if (step >= total) { + return Math.max(0, segmentCount - 1); + } + + let lo = 0; + let hi = segmentCount - 1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + const start = segCumSteps[mid] >>> 0; + const end = segCumSteps[mid + 1] >>> 0; + if (step < start) { + hi = mid - 1; + } else if (step >= end) { + lo = mid + 1; + } else { + return mid; + } + } + return Math.max(0, Math.min(segmentCount - 1, lo)); +} + +export function positionAtStep( + game: TrailGameView, + plan: SegmentTrailPlanView, + step: number, +): { x: number; y: number } | null { + const points = plan.points; + if (points.length === 0) { + return null; + } + if (points.length === 1 || plan.segmentSteps.length === 0) { + const t = points[points.length - 1] as TileRef; + return { x: game.x(t), y: game.y(t) }; + } + + const total = totalTrailSteps(plan); + const idx = Math.max(0, Math.min(total, step)); + if (idx >= total) { + const t = points[points.length - 1] as TileRef; + return { x: game.x(t), y: game.y(t) }; + } + + const segmentCount = plan.segmentSteps.length; + const seg = locateSegment(plan.segCumSteps, segmentCount, idx); + const segStart = plan.segCumSteps[seg] >>> 0; + const steps = Math.max(1, plan.segmentSteps[seg] >>> 0); + + const p0 = points[seg] as TileRef; + const p1 = points[Math.min(points.length - 1, seg + 1)] as TileRef; + const x0 = game.x(p0); + const y0 = game.y(p0); + const x1 = game.x(p1); + const y1 = game.y(p1); + const local = idx - segStart; + + return { + x: x0 + ((x1 - x0) * local) / steps, + y: y0 + ((y1 - y0) * local) / steps, + }; +} + +export function strokeStepInterval( + ctx: CanvasRenderingContext2D, + game: TrailGameView, + plan: SegmentTrailPlanView, + fromStep: number, + toStep: number, +): boolean { + const total = totalTrailSteps(plan); + if (total <= 0) { + return false; + } + + const from = Math.max(0, Math.min(total, fromStep)); + const to = Math.max(0, Math.min(total, toStep)); + if (to <= from) { + return false; + } + + const start = positionAtStep(game, plan, from); + const end = positionAtStep(game, plan, to); + if (!start || !end) { + return false; + } + + const segmentCount = plan.segmentSteps.length; + if (segmentCount === 0) { + return false; + } + + const fromSeg = locateSegment(plan.segCumSteps, segmentCount, from); + const toSeg = locateSegment(plan.segCumSteps, segmentCount, to); + + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + + if (fromSeg === toSeg) { + ctx.lineTo(end.x, end.y); + ctx.stroke(); + return true; + } + + const fromBoundaryRef = plan.points[Math.min(plan.points.length - 1, fromSeg + 1)] as TileRef; + ctx.lineTo(game.x(fromBoundaryRef), game.y(fromBoundaryRef)); + + for (let seg = fromSeg + 1; seg < toSeg; seg++) { + const boundaryRef = plan.points[Math.min(plan.points.length - 1, seg + 1)] as TileRef; + ctx.lineTo(game.x(boundaryRef), game.y(boundaryRef)); + } + + ctx.lineTo(end.x, end.y); + ctx.stroke(); + return true; +} diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 5f747c6baf..6583682792 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -174,6 +174,11 @@ export class SettingsModal extends LitElement implements Layer { this.requestUpdate(); } + private onToggleWebgpuDebugOverlayButtonClick() { + this.userSettings.toggleWebgpuDebug(); + this.requestUpdate(); + } + private onExitButtonClick() { // redirect to the home page window.location.href = "/"; @@ -526,6 +531,29 @@ export class SettingsModal extends LitElement implements Layer { + +
+
+ +
+
+
tick ms compute
+
${this.tickComputeMs.toFixed(2)}
+
+
+
render fps
+
${this.renderFps}
+
+
+ +
Terrain
+ +
+
Terrain Shader
+ +
+ + ${terrainShader.options.map((opt) => this.renderOptionControl(opt))} + +
Territory
+ +
+
Territory Shader
+ +
+ + ${shader.options.map((opt) => this.renderOptionControl(opt))} + +
Temporal
+ +
+
Post Compute
+ +
+ + ${pre.options.map((opt) => this.renderOptionControl(opt))} + +
+
Post Render
+ +
+ + ${post.options.map((opt) => this.renderOptionControl(opt))} + + `; + } +} diff --git a/src/client/graphics/layers/WebGPUTerritoryBackend.ts b/src/client/graphics/layers/WebGPUTerritoryBackend.ts new file mode 100644 index 0000000000..9ca781e7f8 --- /dev/null +++ b/src/client/graphics/layers/WebGPUTerritoryBackend.ts @@ -0,0 +1,452 @@ +import { Theme } from "../../../core/configuration/Config"; +import { EventBus } from "../../../core/EventBus"; +import { UnitType } from "../../../core/game/Game"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView } from "../../../core/game/GameView"; +import { UserSettings } from "../../../core/game/UserSettings"; +import { + AlternateViewEvent, + MouseOverEvent, + WebGPUComputeMetricsEvent, +} from "../../InputHandler"; +import { FrameProfiler } from "../FrameProfiler"; +import { TransformHandler } from "../TransformHandler"; +import { + buildTerrainShaderParams, + readTerrainShaderId, +} from "../webgpu/render/TerrainShaderRegistry"; +import { + buildTerritoryPostSmoothingParams, + readTerritoryPostSmoothingId, +} from "../webgpu/render/TerritoryPostSmoothingRegistry"; +import { + buildTerritoryPreSmoothingParams, + readTerritoryPreSmoothingId, +} from "../webgpu/render/TerritoryPreSmoothingRegistry"; +import { + buildTerritoryShaderParams, + readTerritoryShaderId, +} from "../webgpu/render/TerritoryShaderRegistry"; +import { TerritoryRenderer } from "../webgpu/TerritoryRenderer"; +import { TerritoryBackend } from "./TerritoryBackend"; + +export class WebGPUTerritoryBackend implements TerritoryBackend { + readonly id = "webgpu"; + + profileName(): string { + return "WebGPUTerritoryBackend:renderLayer"; + } + + private attachedTerritoryCanvas: HTMLCanvasElement | null = null; + + private overlayWrapper: HTMLElement | null = null; + private overlayResizeObserver: ResizeObserver | null = null; + + private theme: Theme; + + private territoryRenderer: TerritoryRenderer | null = null; + private alternativeView = false; + + private lastPaletteSignature: string | null = null; + private lastDefensePostsSignature: string | null = null; + private lastTerrainShaderSignature: string | null = null; + private lastTerritoryShaderSignature: string | null = null; + private lastPreSmoothingSignature: string | null = null; + private lastPostSmoothingSignature: string | null = null; + + private lastMousePosition: { x: number; y: number } | null = null; + private hoveredOwnerSmallId: number | null = null; + private lastHoverUpdateMs = 0; + + constructor( + private game: GameView, + private eventBus: EventBus, + private transformHandler: TransformHandler, + private userSettings: UserSettings, + ) { + this.theme = game.config().theme(); + } + + shouldTransform(): boolean { + return true; + } + + init() { + this.eventBus.on(AlternateViewEvent, (e) => { + this.alternativeView = e.alternateView; + this.territoryRenderer?.setAlternativeView(this.alternativeView); + }); + this.eventBus.on(MouseOverEvent, (e) => { + this.lastMousePosition = { x: e.x, y: e.y }; + }); + this.redraw(); + } + + whenReady(): Promise { + return this.territoryRenderer?.whenReady() ?? Promise.resolve(false); + } + + getFailureReason(): string | null { + return this.territoryRenderer?.getFailureReason() ?? null; + } + + dispose() { + this.overlayResizeObserver?.disconnect(); + this.overlayResizeObserver = null; + this.attachedTerritoryCanvas?.remove(); + this.attachedTerritoryCanvas = null; + this.overlayWrapper = null; + this.territoryRenderer?.dispose(); + this.territoryRenderer = null; + } + + tick() { + const tickProfile = FrameProfiler.start(); + + const currentTheme = this.game.config().theme(); + if (currentTheme !== this.theme) { + this.theme = currentTheme; + this.territoryRenderer?.refreshTerrain(); + this.redraw(); + } + + this.refreshPaletteIfNeeded(); + this.refreshDefensePostsIfNeeded(); + this.applyTerrainShaderSettings(); + this.applyTerritoryShaderSettings(); + this.applyTerritorySmoothingSettings(); + + const updatedTiles = this.game.recentlyUpdatedTiles(); + for (let i = 0; i < updatedTiles.length; i++) { + this.markTile(updatedTiles[i]); + } + + const updatedTerrainTiles = this.game.recentlyUpdatedTerrainTiles(); + if (updatedTerrainTiles.length > 0) { + this.territoryRenderer?.updateTerrainDataTiles(updatedTerrainTiles); + } + + // After collecting pending updates and handling palette/theme changes, + // invoke the renderer's tick() to process compute passes. This ensures + // compute shaders run at the simulation rate rather than every frame. + if (this.territoryRenderer) { + const start = performance.now(); + this.territoryRenderer.tick(); + const computeMs = performance.now() - start; + this.eventBus.emit(new WebGPUComputeMetricsEvent(computeMs)); + } + + FrameProfiler.end("TerritoryLayer:tick", tickProfile); + } + + redraw() { + this.configureRenderer(); + } + + private configureRenderer() { + this.territoryRenderer?.dispose(); + this.territoryRenderer = null; + + const { renderer, reason } = TerritoryRenderer.create( + this.game, + this.theme, + ); + if (!renderer) { + throw new Error(reason ?? "WebGPU is required for territory rendering."); + } + + this.territoryRenderer = renderer; + this.territoryRenderer.setAlternativeView(this.alternativeView); + this.territoryRenderer.setHighlightedOwnerId(this.hoveredOwnerSmallId); + this.applyTerrainShaderSettings(true); + this.applyTerritoryShaderSettings(true); + this.applyTerritorySmoothingSettings(true); + this.territoryRenderer.markAllDirty(); + this.territoryRenderer.refreshPalette(); + this.lastPaletteSignature = this.computePaletteSignature(); + + this.lastDefensePostsSignature = this.computeDefensePostsSignature(); + // Ensure defense posts buffer is uploaded on first tick. + this.territoryRenderer.markDefensePostsDirty(); + + // Run an initial tick to upload state and build the colour texture. Without + // this, the first render call may occur before the initial compute pass + // has been executed, resulting in undefined colours. + this.territoryRenderer.tick(); + } + + renderLayer(context: CanvasRenderingContext2D) { + if (!this.territoryRenderer) { + return; + } + + // Check for theme changes in renderLayer too (for when game is paused) + const currentTheme = this.game.config().theme(); + if (currentTheme !== this.theme) { + this.theme = currentTheme; + this.territoryRenderer.refreshTerrain(); + this.redraw(); + } + + // Apply user settings even while the game is paused (settings modal). + this.applyTerritoryShaderSettings(); + this.applyTerritorySmoothingSettings(); + + this.ensureTerritoryCanvasAttached(context.canvas); + this.updateHoverHighlight(); + + const renderTerritoryStart = FrameProfiler.start(); + this.territoryRenderer.setViewSize( + context.canvas.width, + context.canvas.height, + ); + const viewOffset = this.transformHandler.viewOffset(); + this.territoryRenderer.setViewTransform( + this.transformHandler.scale, + viewOffset.x, + viewOffset.y, + ); + this.territoryRenderer.render(); + FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); + } + + private ensureTerritoryCanvasAttached(mainCanvas: HTMLCanvasElement) { + if (!this.territoryRenderer) { + return; + } + + const canvas = this.territoryRenderer.canvas; + + // If the renderer recreated its canvas, detach the old one. + if (this.attachedTerritoryCanvas !== canvas) { + this.attachedTerritoryCanvas?.remove(); + this.attachedTerritoryCanvas = canvas; + + // Configure overlay canvas styles once. Avoid per-frame style reads/writes. + canvas.style.pointerEvents = "none"; + canvas.style.position = "absolute"; + canvas.style.inset = "0"; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.style.display = "block"; + } + + const parent = mainCanvas.parentElement; + if (!parent) { + // Fallback: if the canvas isn't in the DOM yet, append to body. + if (!canvas.isConnected) { + document.body.appendChild(canvas); + } + return; + } + + // Ensure the main canvas is wrapped in a positioned container so the + // territory canvas can overlay it without mirroring computed styles. + let wrapper: HTMLElement; + const currentParent = mainCanvas.parentElement; + if (currentParent && currentParent.dataset.territoryOverlay === "1") { + wrapper = currentParent; + } else { + wrapper = document.createElement("div"); + wrapper.dataset.territoryOverlay = "1"; + wrapper.style.position = "relative"; + wrapper.style.display = "inline-block"; + wrapper.style.lineHeight = "0"; + + // Replace mainCanvas with wrapper, then re-insert mainCanvas inside wrapper. + parent.replaceChild(wrapper, mainCanvas); + wrapper.appendChild(mainCanvas); + } + + if (this.overlayWrapper !== wrapper) { + this.overlayWrapper = wrapper; + this.overlayResizeObserver?.disconnect(); + this.overlayResizeObserver = new ResizeObserver(() => { + this.syncOverlayWrapperSize(mainCanvas, wrapper); + }); + this.overlayResizeObserver.observe(mainCanvas); + // Kick an initial size update; further updates are handled by ResizeObserver. + this.syncOverlayWrapperSize(mainCanvas, wrapper); + } + + // Ensure territory canvas is the first child so it's the lowest layer. + if (canvas.parentElement !== wrapper) { + canvas.remove(); + wrapper.insertBefore(canvas, mainCanvas); + } else if (canvas !== wrapper.firstElementChild) { + wrapper.insertBefore(canvas, mainCanvas); + } + } + + private syncOverlayWrapperSize( + mainCanvas: HTMLCanvasElement, + wrapper: HTMLElement, + ) { + // Ensure the wrapper has real layout size so the absolutely-positioned + // territory canvas (100% width/height) is non-zero even if the main canvas + // is positioned absolutely. + const rect = mainCanvas.getBoundingClientRect(); + const w = rect.width > 0 ? rect.width : mainCanvas.clientWidth; + const h = rect.height > 0 ? rect.height : mainCanvas.clientHeight; + if (w > 0) wrapper.style.width = `${w}px`; + if (h > 0) wrapper.style.height = `${h}px`; + } + + private markTile(tile: TileRef) { + this.territoryRenderer?.markTile(tile); + } + + private updateHoverHighlight() { + if (!this.territoryRenderer) { + return; + } + + const now = performance.now(); + if (now - this.lastHoverUpdateMs < 100) { + return; + } + this.lastHoverUpdateMs = now; + + let nextOwnerSmallId: number | null = null; + if (this.lastMousePosition) { + const cell = this.transformHandler.screenToWorldCoordinates( + this.lastMousePosition.x, + this.lastMousePosition.y, + ); + if (this.game.isValidCoord(cell.x, cell.y)) { + const tile = this.game.ref(cell.x, cell.y); + const owner = this.game.owner(tile); + if (owner && owner.isPlayer()) { + nextOwnerSmallId = owner.smallID(); + } + } + } + + if (nextOwnerSmallId === this.hoveredOwnerSmallId) { + return; + } + this.hoveredOwnerSmallId = nextOwnerSmallId; + this.territoryRenderer.setHighlightedOwnerId(nextOwnerSmallId); + } + + private computePaletteSignature(): string { + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + const patternsEnabled = this.userSettings.territoryPatterns(); + return `${this.game.playerViews().length}:${maxSmallId}:${patternsEnabled ? 1 : 0}`; + } + + private refreshPaletteIfNeeded() { + if (!this.territoryRenderer) { + return; + } + const signature = this.computePaletteSignature(); + if (signature !== this.lastPaletteSignature) { + this.lastPaletteSignature = signature; + this.territoryRenderer.refreshPalette(); + } + } + + private applyTerritoryShaderSettings(force: boolean = false) { + if (!this.territoryRenderer) { + return; + } + + const shaderId = readTerritoryShaderId(this.userSettings); + const { shaderPath, params0, params1 } = buildTerritoryShaderParams( + this.userSettings, + shaderId, + ); + + const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`; + if (!force && signature === this.lastTerritoryShaderSignature) { + return; + } + this.lastTerritoryShaderSignature = signature; + + this.territoryRenderer.setTerritoryShader(shaderPath); + this.territoryRenderer.setTerritoryShaderParams(params0, params1); + } + + private applyTerrainShaderSettings(force: boolean = false) { + if (!this.territoryRenderer) { + return; + } + + const terrainId = readTerrainShaderId(this.userSettings); + const { shaderPath, params0, params1 } = buildTerrainShaderParams( + this.userSettings, + terrainId, + ); + const signature = `${shaderPath}:${Array.from(params0).join(",")}:${Array.from(params1).join(",")}`; + if (!force && signature === this.lastTerrainShaderSignature) { + return; + } + this.lastTerrainShaderSignature = signature; + this.territoryRenderer.setTerrainShader(shaderPath); + this.territoryRenderer.setTerrainShaderParams(params0, params1); + } + + private applyTerritorySmoothingSettings(force: boolean = false) { + if (!this.territoryRenderer) { + return; + } + + const preId = readTerritoryPreSmoothingId(this.userSettings); + const preParams = buildTerritoryPreSmoothingParams( + this.userSettings, + preId, + ); + const preSignature = `${preId}:${Array.from(preParams.params0).join(",")}`; + if (force || preSignature !== this.lastPreSmoothingSignature) { + this.lastPreSmoothingSignature = preSignature; + this.territoryRenderer.setPreSmoothing( + preParams.enabled, + preParams.shaderPath, + preParams.params0, + ); + } + + const postId = readTerritoryPostSmoothingId(this.userSettings); + const postParams = buildTerritoryPostSmoothingParams( + this.userSettings, + postId, + ); + const postSignature = `${postId}:${Array.from(postParams.params0).join(",")}`; + if (force || postSignature !== this.lastPostSmoothingSignature) { + this.lastPostSmoothingSignature = postSignature; + this.territoryRenderer.setPostSmoothing( + postParams.enabled, + postParams.shaderPath, + postParams.params0, + ); + } + } + + private computeDefensePostsSignature(): string { + // Active + completed posts only. + const parts: string[] = []; + for (const u of this.game.units(UnitType.DefensePost)) { + if (!u.isActive() || u.isUnderConstruction()) continue; + const tile = u.tile(); + parts.push( + `${u.owner().smallID()},${this.game.x(tile)},${this.game.y(tile)}`, + ); + } + parts.sort(); + return parts.join("|"); + } + + private refreshDefensePostsIfNeeded() { + if (!this.territoryRenderer) { + return; + } + const signature = this.computeDefensePostsSignature(); + if (signature !== this.lastDefensePostsSignature) { + this.lastDefensePostsSignature = signature; + this.territoryRenderer.markDefensePostsDirty(); + } + } +} diff --git a/src/client/graphics/webgpu/TerritoryRenderer.ts b/src/client/graphics/webgpu/TerritoryRenderer.ts new file mode 100644 index 0000000000..d37f84299d --- /dev/null +++ b/src/client/graphics/webgpu/TerritoryRenderer.ts @@ -0,0 +1,667 @@ +import { Theme } from "../../../core/configuration/Config"; +import { TileRef } from "../../../core/game/GameMap"; +import { GameView } from "../../../core/game/GameView"; +import { createCanvas } from "../../Utils"; +import { ComputePass } from "./compute/ComputePass"; +import { DefendedStrengthFullPass } from "./compute/DefendedStrengthFullPass"; +import { DefendedStrengthPass } from "./compute/DefendedStrengthPass"; +import { StateUpdatePass } from "./compute/StateUpdatePass"; +import { TerrainComputePass } from "./compute/TerrainComputePass"; +import { VisualStateSmoothingPass } from "./compute/VisualStateSmoothingPass"; +import { GroundTruthData } from "./core/GroundTruthData"; +import { WebGPUDevice } from "./core/WebGPUDevice"; +import { RenderPass } from "./render/RenderPass"; +import { TemporalResolvePass } from "./render/TemporalResolvePass"; +import { TerritoryRenderPass } from "./render/TerritoryRenderPass"; + +export interface TerritoryWebGLCreateResult { + renderer: TerritoryRenderer | null; + reason?: string; +} + +/** + * Main orchestrator for WebGPU territory rendering. + * Manages compute passes (tick-based) and render passes (frame-based). + */ +export class TerritoryRenderer { + public readonly canvas: HTMLCanvasElement; + + private device: WebGPUDevice | null = null; + private resources: GroundTruthData | null = null; + private ready = false; + private initPromise: Promise | null = null; + private failureReason: string | null = null; + private territoryShaderPath = "render/territory.wgsl"; + private territoryShaderParams0 = new Float32Array(4); + private territoryShaderParams1 = new Float32Array(4); + private terrainShaderPath = "compute/terrain-compute.wgsl"; + private terrainShaderParams0 = new Float32Array(4); + private terrainShaderParams1 = new Float32Array(4); + private preSmoothingShaderPath = "compute/visual-state-smoothing.wgsl"; + private preSmoothingParams0 = new Float32Array(4); + private postSmoothingShaderPath = "render/temporal-resolve.wgsl"; + private postSmoothingParams0 = new Float32Array(4); + + // Compute passes + private computePasses: ComputePass[] = []; + private computePassOrder: ComputePass[] = []; + private frameComputePasses: ComputePass[] = []; + + // Render passes + private renderPasses: RenderPass[] = []; + private renderPassOrder: RenderPass[] = []; + + // Pass instances + private terrainComputePass: TerrainComputePass | null = null; + private stateUpdatePass: StateUpdatePass | null = null; + private defendedStrengthFullPass: DefendedStrengthFullPass | null = null; + private defendedStrengthPass: DefendedStrengthPass | null = null; + private visualStateSmoothingPass: VisualStateSmoothingPass | null = null; + private territoryRenderPass: TerritoryRenderPass | null = null; + private temporalResolvePass: TemporalResolvePass | null = null; + private readonly defensePostRange: number; + + private preSmoothingEnabled = false; + private postSmoothingEnabled = false; + + private constructor( + private readonly game: GameView, + private readonly theme: Theme, + ) { + this.canvas = createCanvas(); + this.canvas.style.pointerEvents = "none"; + this.canvas.width = 1; + this.canvas.height = 1; + this.defensePostRange = game.config().defensePostRange(); + } + + static create(game: GameView, theme: Theme): TerritoryWebGLCreateResult { + const state = game.tileStateView(); + const expected = game.width() * game.height(); + if (state.length !== expected) { + return { + renderer: null, + reason: "Tile state buffer size mismatch; GPU renderer disabled.", + }; + } + + const nav = globalThis.navigator as any; + if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") { + return { + renderer: null, + reason: "WebGPU not available; GPU renderer disabled.", + }; + } + + const renderer = new TerritoryRenderer(game, theme); + renderer.startInit(); + return { renderer }; + } + + private startInit(): void { + if (this.initPromise) return; + this.initPromise = this.init().catch((error) => { + this.ready = false; + this.failureReason = + error instanceof Error ? error.message : String(error); + console.warn("[TerritoryRenderer] WebGPU init failed", error); + }); + } + + private async init(): Promise { + const webgpuDevice = await WebGPUDevice.create(this.canvas); + if (!webgpuDevice) { + this.failureReason = "WebGPU device initialization failed."; + return; + } + this.device = webgpuDevice; + void webgpuDevice.device.lost.then((info) => { + this.ready = false; + this.failureReason = `WebGPU device lost: ${info.reason}`; + }); + + const state = this.game.tileStateView(); + this.resources = GroundTruthData.create( + webgpuDevice.device, + this.game, + this.theme, + state, + ); + this.resources.setTerritoryShaderParams( + this.territoryShaderParams0, + this.territoryShaderParams1, + ); + this.resources.setTerrainShaderParams( + this.terrainShaderParams0, + this.terrainShaderParams1, + ); + + // Upload terrain data and params (terrain colors will be computed on GPU) + this.resources.uploadTerrainData(); + this.resources.uploadTerrainParams(); + + // Create compute passes (terrain compute should run first) + this.terrainComputePass = new TerrainComputePass(); + void this.terrainComputePass.setShader(this.terrainShaderPath); + this.stateUpdatePass = new StateUpdatePass(); + this.defendedStrengthFullPass = new DefendedStrengthFullPass(); + this.defendedStrengthPass = new DefendedStrengthPass(); + this.visualStateSmoothingPass = new VisualStateSmoothingPass(); + + this.computePasses = [ + this.terrainComputePass, + this.stateUpdatePass, + this.defendedStrengthFullPass, + this.defendedStrengthPass, + ]; + + this.frameComputePasses = [this.visualStateSmoothingPass]; + + // Create render passes + this.territoryRenderPass = new TerritoryRenderPass(); + this.temporalResolvePass = new TemporalResolvePass(); + this.renderPasses = [this.territoryRenderPass, this.temporalResolvePass]; + + // Initialize all passes + for (const pass of this.computePasses) { + await pass.init(webgpuDevice.device, this.resources); + } + + for (const pass of this.frameComputePasses) { + await pass.init(webgpuDevice.device, this.resources); + } + + for (const pass of this.renderPasses) { + await pass.init( + webgpuDevice.device, + this.resources, + webgpuDevice.canvasFormat, + ); + } + + if (this.territoryRenderPass) { + await this.territoryRenderPass.setShader(this.territoryShaderPath); + } + + this.applyPreSmoothingConfig(); + this.applyPostSmoothingConfig(); + + // Compute dependency order (topological sort) + this.computePassOrder = this.topologicalSort(this.computePasses); + this.renderPassOrder = this.topologicalSort(this.renderPasses); + + this.ready = true; + } + + async whenReady(): Promise { + await this.initPromise; + return this.ready && this.failureReason === null; + } + + getFailureReason(): string | null { + return this.failureReason; + } + + dispose(): void { + this.ready = false; + try { + this.device?.device.destroy(); + } catch { + // Ignore device cleanup failures during renderer fallback. + } + this.canvas.remove(); + } + + /** + * Topological sort of passes based on dependencies. + * Ensures passes run in the correct order. + */ + private topologicalSort( + passes: T[], + ): T[] { + const passMap = new Map(); + for (const pass of passes) { + passMap.set(pass.name, pass); + } + + const visited = new Set(); + const visiting = new Set(); + const result: T[] = []; + + const visit = (pass: T): void => { + if (visiting.has(pass.name)) { + console.warn( + `Circular dependency detected involving pass: ${pass.name}`, + ); + return; + } + if (visited.has(pass.name)) { + return; + } + + visiting.add(pass.name); + for (const depName of pass.dependencies) { + const dep = passMap.get(depName); + if (dep) { + visit(dep); + } + } + visiting.delete(pass.name); + visited.add(pass.name); + result.push(pass); + }; + + for (const pass of passes) { + if (!visited.has(pass.name)) { + visit(pass); + } + } + + return result; + } + + setViewSize(width: number, height: number): void { + if (!this.resources || !this.device) { + return; + } + + const nextWidth = Math.max(1, Math.floor(width)); + const nextHeight = Math.max(1, Math.floor(height)); + + if (nextWidth === this.canvas.width && nextHeight === this.canvas.height) { + return; + } + + this.canvas.width = nextWidth; + this.canvas.height = nextHeight; + this.resources.setViewSize(nextWidth, nextHeight); + this.device.reconfigure(); + + if (this.postSmoothingEnabled && this.resources) { + this.resources.ensurePostSmoothingTextures( + nextWidth, + nextHeight, + this.device.canvasFormat, + ); + this.resources.invalidateHistory(); + } + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + if (!this.resources) { + return; + } + this.resources.setViewTransform(scale, offsetX, offsetY); + } + + setAlternativeView(enabled: boolean): void { + if (!this.resources) { + return; + } + this.resources.setAlternativeView(enabled); + } + + setHighlightedOwnerId(ownerSmallId: number | null): void { + if (!this.resources) { + return; + } + this.resources.setHighlightedOwnerId(ownerSmallId); + } + + setTerritoryShader(shaderPath: string): void { + this.territoryShaderPath = shaderPath; + if (this.territoryRenderPass) { + void this.territoryRenderPass.setShader(shaderPath); + } + this.resources?.invalidateHistory(); + } + + setTerrainShader(shaderPath: string): void { + this.terrainShaderPath = shaderPath; + if (!this.terrainComputePass) { + return; + } + void this.terrainComputePass.setShader(shaderPath).then(() => { + this.refreshTerrain(); + }); + } + + setTerritoryShaderParams( + params0: Float32Array | number[], + params1: Float32Array | number[], + ): void { + for (let i = 0; i < 4; i++) { + this.territoryShaderParams0[i] = Number(params0[i] ?? 0); + this.territoryShaderParams1[i] = Number(params1[i] ?? 0); + } + + if (!this.resources) { + return; + } + this.resources.setTerritoryShaderParams( + this.territoryShaderParams0, + this.territoryShaderParams1, + ); + this.resources.invalidateHistory(); + } + + setTerrainShaderParams( + params0: Float32Array | number[], + params1: Float32Array | number[], + ): void { + for (let i = 0; i < 4; i++) { + this.terrainShaderParams0[i] = Number(params0[i] ?? 0); + this.terrainShaderParams1[i] = Number(params1[i] ?? 0); + } + + if (!this.resources) { + return; + } + this.resources.setTerrainShaderParams( + this.terrainShaderParams0, + this.terrainShaderParams1, + ); + this.refreshTerrain(); + } + + setPreSmoothing( + enabled: boolean, + shaderPath: string, + params0: Float32Array | number[], + ): void { + this.preSmoothingEnabled = enabled; + if (shaderPath) { + this.preSmoothingShaderPath = shaderPath; + } + for (let i = 0; i < 4; i++) { + this.preSmoothingParams0[i] = Number(params0[i] ?? 0); + } + this.applyPreSmoothingConfig(); + } + + setPostSmoothing( + enabled: boolean, + shaderPath: string, + params0: Float32Array | number[], + ): void { + this.postSmoothingEnabled = enabled; + if (shaderPath) { + this.postSmoothingShaderPath = shaderPath; + } + for (let i = 0; i < 4; i++) { + this.postSmoothingParams0[i] = Number(params0[i] ?? 0); + } + this.applyPostSmoothingConfig(); + } + + private applyPreSmoothingConfig(): void { + if (!this.resources || !this.visualStateSmoothingPass) { + return; + } + + this.resources.setUseVisualStateTexture(this.preSmoothingEnabled); + if (this.preSmoothingEnabled) { + this.resources.ensureVisualStateTexture(); + void this.visualStateSmoothingPass.setShader(this.preSmoothingShaderPath); + this.visualStateSmoothingPass.setParams(this.preSmoothingParams0); + } else { + this.visualStateSmoothingPass.setParams(new Float32Array(4)); + this.resources.releaseVisualStateTexture(); + } + + this.resources.invalidateHistory(); + } + + private applyPostSmoothingConfig(): void { + if (!this.resources || !this.temporalResolvePass || !this.device) { + return; + } + + if (this.postSmoothingEnabled) { + void this.temporalResolvePass.setShader(this.postSmoothingShaderPath); + this.temporalResolvePass.setParams(this.postSmoothingParams0); + this.temporalResolvePass.setEnabled(true); + this.resources.ensurePostSmoothingTextures( + this.canvas.width, + this.canvas.height, + this.device.canvasFormat, + ); + } else { + this.temporalResolvePass.setEnabled(false); + this.resources.releasePostSmoothingTextures(); + } + + this.resources.invalidateHistory(); + } + + markTile(tile: TileRef): void { + if (this.stateUpdatePass) { + this.stateUpdatePass.markTile(tile); + } + } + + markAllDirty(): void { + this.resources?.markDefensePostsDirty(); + } + + refreshPalette(): void { + if (!this.resources) { + return; + } + this.resources.markPaletteDirty(); + } + + markDefensePostsDirty(): void { + if (!this.resources) { + return; + } + this.resources.markDefensePostsDirty(); + } + + refreshTerrain(): void { + if (!this.resources || !this.device) { + return; + } + this.resources.markTerrainParamsDirty(); + if (this.terrainComputePass) { + this.terrainComputePass.markDirty(); + // Immediately compute terrain to avoid blank rendering + this.computeTerrainImmediate(); + } + } + + updateTerrainDataTiles(tiles: readonly number[]): void { + if (!this.resources || !this.device || tiles.length === 0) { + return; + } + this.resources.uploadTerrainDataTiles(tiles); + if (this.terrainComputePass) { + this.terrainComputePass.markDirty(); + } + } + + /** + * Immediately execute terrain compute pass (for theme changes). + * This ensures terrain is recomputed before the next render. + */ + private computeTerrainImmediate(): void { + if ( + !this.ready || + !this.device || + !this.resources || + !this.terrainComputePass + ) { + return; + } + + // Upload terrain params if needed + this.resources.uploadTerrainParams(); + + if (!this.terrainComputePass.needsUpdate()) { + return; + } + + const encoder = this.device.device.createCommandEncoder(); + this.terrainComputePass.execute(encoder, this.resources); + this.device.device.queue.submit([encoder.finish()]); + + // Rebuild render pass bind group to ensure it uses the updated terrain texture + // This will be called again in render(), but doing it here ensures it's ready + if (this.territoryRenderPass) { + (this.territoryRenderPass as any).rebuildBindGroup?.(); + } + } + + /** + * Perform one simulation tick. + * Runs compute passes to update ground truth data. + */ + tick(): void { + if (!this.ready || !this.device || !this.resources) { + return; + } + + this.resources.updateTickTiming(performance.now() / 1000); + + if (this.game.config().defensePostRange() !== this.defensePostRange) { + throw new Error("defensePostRange changed at runtime; unsupported."); + } + + // Upload palette if needed + this.resources.uploadPalette(); + + // Upload diplomacy relations (used by retro shader / debug modes) + this.resources.uploadRelations(); + + // Upload defense posts if needed (also produces defended dirty tiles on changes) + this.resources.uploadDefensePosts(); + + // Initial state upload + this.resources.uploadState(); + + const stateUpdatesPending = this.stateUpdatePass?.needsUpdate() ?? false; + if (!stateUpdatesPending) { + this.resources.setLastStateUpdateCount(0); + } + + const needsCompute = + (this.terrainComputePass?.needsUpdate() ?? false) || + stateUpdatesPending || + (this.defendedStrengthFullPass?.needsUpdate() ?? false) || + (this.defendedStrengthPass?.needsUpdate() ?? false); + + if (!needsCompute) { + return; + } + + const encoder = this.device.device.createCommandEncoder(); + + if (this.preSmoothingEnabled && stateUpdatesPending) { + this.resources.ensureVisualStateTexture(); + const visualStateTexture = this.resources.getVisualStateTexture(); + if (visualStateTexture) { + encoder.copyTextureToTexture( + { texture: this.resources.stateTexture }, + { texture: visualStateTexture }, + { + width: this.resources.getMapWidth(), + height: this.resources.getMapHeight(), + depthOrArrayLayers: 1, + }, + ); + this.resources.consumeVisualStateSyncNeeded(); + } + } + + // Execute compute passes in dependency order (clear will run before update if needed) + for (const pass of this.computePassOrder) { + if (!pass.needsUpdate()) { + continue; + } + pass.execute(encoder, this.resources); + } + + this.device.device.queue.submit([encoder.finish()]); + } + + /** + * Render one frame. + * Runs render passes to draw to the canvas. + */ + render(): void { + if ( + !this.ready || + !this.device || + !this.resources || + !this.territoryRenderPass + ) { + return; + } + + const nowSec = performance.now() / 1000; + this.resources.writeTemporalUniformBuffer(nowSec); + + // If terrain needs recomputation, trigger it asynchronously (no blocking) + // It will be ready for the next frame, acceptable trade-off for performance + if (this.terrainComputePass?.needsUpdate()) { + this.resources.uploadTerrainParams(); + const computeEncoder = this.device.device.createCommandEncoder(); + this.terrainComputePass.execute(computeEncoder, this.resources); + this.device.device.queue.submit([computeEncoder.finish()]); + // Continue with render - may show stale terrain for one frame, but better performance + } + + const encoder = this.device.device.createCommandEncoder(); + const swapchainView = this.device.context.getCurrentTexture().createView(); + + if ( + this.preSmoothingEnabled && + this.resources.consumeVisualStateSyncNeeded() + ) { + const visualStateTexture = this.resources.getVisualStateTexture(); + if (visualStateTexture) { + encoder.copyTextureToTexture( + { texture: this.resources.stateTexture }, + { texture: visualStateTexture }, + { + width: this.resources.getMapWidth(), + height: this.resources.getMapHeight(), + depthOrArrayLayers: 1, + }, + ); + } + } + + for (const pass of this.frameComputePasses) { + if (!pass.needsUpdate()) { + continue; + } + pass.execute(encoder, this.resources); + } + + // Execute render passes in dependency order + for (const pass of this.renderPassOrder) { + if (!pass.needsUpdate()) { + continue; + } + if (pass === this.territoryRenderPass && this.postSmoothingEnabled) { + if (!this.resources.getCurrentColorTexture()) { + this.resources.ensurePostSmoothingTextures( + this.canvas.width, + this.canvas.height, + this.device.canvasFormat, + ); + } + const currentTexture = this.resources.getCurrentColorTexture(); + if (currentTexture) { + pass.execute(encoder, this.resources, currentTexture.createView()); + } + continue; + } + + pass.execute(encoder, this.resources, swapchainView); + } + + this.device.device.queue.submit([encoder.finish()]); + } +} diff --git a/src/client/graphics/webgpu/compute/ComputePass.ts b/src/client/graphics/webgpu/compute/ComputePass.ts new file mode 100644 index 0000000000..0be77e64e7 --- /dev/null +++ b/src/client/graphics/webgpu/compute/ComputePass.ts @@ -0,0 +1,37 @@ +import { GroundTruthData } from "../core/GroundTruthData"; + +/** + * Base interface for compute passes. + * Compute passes run during tick() (simulation rate) to update ground truth data. + */ +export interface ComputePass { + /** Unique name of this pass (used for dependency resolution) */ + name: string; + + /** Names of passes that must run before this one */ + dependencies: string[]; + + /** + * Initialize the pass with device and resources. + * Called once during renderer initialization. + */ + init(device: GPUDevice, resources: GroundTruthData): Promise; + + /** + * Check if this pass needs to run this tick. + * Performance optimization: return false to skip execution. + */ + needsUpdate(): boolean; + + /** + * Execute the compute pass. + * @param encoder Command encoder for recording GPU commands + * @param resources Ground truth data (read/write access) + */ + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void; + + /** + * Clean up resources when the pass is no longer needed. + */ + dispose(): void; +} diff --git a/src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts b/src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts new file mode 100644 index 0000000000..3d747803bf --- /dev/null +++ b/src/client/graphics/webgpu/compute/DefendedStrengthFullPass.ts @@ -0,0 +1,159 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Full defended strength recompute across the entire map. + * Used on initial upload or when post diffs are too large for a tile list. + */ +export class DefendedStrengthFullPass implements ComputePass { + name = "defended-strength-full"; + dependencies: string[] = ["state-update"]; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/defended-strength-full.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + texture: { sampleType: "uint" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + } + + needsUpdate(): boolean { + return this.resources?.needsDefendedFullRecompute() ?? false; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline) { + return; + } + + if (!resources.needsDefendedFullRecompute()) { + return; + } + + resources.writeDefendedStrengthParamsBuffer(0); + + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + if ( + !this.bindGroup || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer + ) { + this.rebuildBindGroup(); + } + if (!this.bindGroup) { + return; + } + + const mapWidth = resources.getMapWidth(); + const mapHeight = resources.getMapHeight(); + const workgroupCountX = Math.ceil(mapWidth / 8); + const workgroupCountY = Math.ceil(mapHeight / 8); + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(workgroupCountX, workgroupCountY); + pass.end(); + + resources.clearDefendedFullRecompute(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.defendedStrengthParamsBuffer || + !this.resources.stateTexture || + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer + ) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.defendedStrengthParamsBuffer }, + }, + { + binding: 1, + resource: this.resources.stateTexture.createView(), + }, + { + binding: 2, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 3, + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 4, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, + }, + ], + }); + + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts b/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts new file mode 100644 index 0000000000..ae0034ce61 --- /dev/null +++ b/src/client/graphics/webgpu/compute/DefendedStrengthPass.ts @@ -0,0 +1,172 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Recomputes defended strength for a list of dirty tiles. + * Dirty tiles are produced when defense posts are added/removed/moved. + */ +export class DefendedStrengthPass implements ComputePass { + name = "defended-strength"; + dependencies: string[] = ["state-update"]; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private boundDirtyTilesBuffer: GPUBuffer | null = null; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/defended-strength.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + texture: { sampleType: "uint" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 5, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + } + + needsUpdate(): boolean { + return (this.resources?.getDefendedDirtyTilesCount() ?? 0) > 0; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline) { + return; + } + + const dirtyCount = resources.getDefendedDirtyTilesCount(); + if (dirtyCount === 0) { + return; + } + + resources.writeDefendedStrengthParamsBuffer(dirtyCount); + + const dirtyTilesBuffer = resources.defendedDirtyTilesBuffer; + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + const shouldRebuildBindGroup = + !this.bindGroup || + this.boundDirtyTilesBuffer !== dirtyTilesBuffer || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer; + + if (shouldRebuildBindGroup) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(dirtyCount / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); + + resources.clearDefendedDirtyTiles(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.defendedStrengthParamsBuffer || + !this.resources.defendedDirtyTilesBuffer || + !this.resources.stateTexture || + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer + ) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.defendedStrengthParamsBuffer }, + }, + { + binding: 1, + resource: { buffer: this.resources.defendedDirtyTilesBuffer }, + }, + { + binding: 2, + resource: this.resources.stateTexture.createView(), + }, + { + binding: 3, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 4, + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 5, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, + }, + ], + }); + + this.boundDirtyTilesBuffer = this.resources.defendedDirtyTilesBuffer; + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/compute/StateUpdatePass.ts b/src/client/graphics/webgpu/compute/StateUpdatePass.ts new file mode 100644 index 0000000000..f874305e20 --- /dev/null +++ b/src/client/graphics/webgpu/compute/StateUpdatePass.ts @@ -0,0 +1,197 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Compute pass that scatters tile state updates into the state texture. + */ +export class StateUpdatePass implements ComputePass { + name = "state-update"; + dependencies: string[] = []; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private readonly pendingTiles: Set = new Set(); + private boundUpdatesBuffer: GPUBuffer | null = null; + private boundPostsByOwnerBuffer: GPUBuffer | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const shaderCode = await loadShader("compute/state-update.wgsl"); + const shaderModule = device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "r32uint" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + { + binding: 4, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 5, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + ], + }); + + this.pipeline = device.createComputePipeline({ + layout: device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + + this.rebuildBindGroup(); + } + + needsUpdate(): boolean { + return this.pendingTiles.size > 0; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline) { + return; + } + + const numUpdates = this.pendingTiles.size; + if (numUpdates === 0) { + return; + } + + resources.setLastStateUpdateCount(numUpdates); + + const updatesBuffer = resources.ensureUpdatesBuffer(numUpdates); + resources.writeStateUpdateParamsBuffer(numUpdates); + + const staging = resources.getUpdatesStaging(); + const state = resources.getState(); + + // Prepare staging data + let idx = 0; + for (const tile of this.pendingTiles) { + const stateValue = state[tile]; + staging[idx * 2] = tile; + staging[idx * 2 + 1] = stateValue; + idx++; + } + + // Upload to GPU + this.device.queue.writeBuffer( + updatesBuffer, + 0, + staging.subarray(0, numUpdates * 2), + ); + + const postsByOwnerBuffer = resources.defensePostsByOwnerBuffer; + const shouldRebuildBindGroup = + !this.bindGroup || + this.boundUpdatesBuffer !== updatesBuffer || + this.boundPostsByOwnerBuffer !== postsByOwnerBuffer; + + if (shouldRebuildBindGroup) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(numUpdates / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); + + this.pendingTiles.clear(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.stateUpdateParamsBuffer || + !this.resources.updatesBuffer || + !this.resources.stateTexture || + !this.resources.defendedStrengthTexture || + !this.resources.defenseOwnerOffsetsBuffer || + !this.resources.defensePostsByOwnerBuffer + ) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.stateUpdateParamsBuffer }, + }, + { binding: 1, resource: { buffer: this.resources.updatesBuffer } }, + { + binding: 2, + resource: this.resources.stateTexture.createView(), + }, + { + binding: 3, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 4, + resource: { buffer: this.resources.defenseOwnerOffsetsBuffer }, + }, + { + binding: 5, + resource: { buffer: this.resources.defensePostsByOwnerBuffer }, + }, + ], + }); + + this.boundUpdatesBuffer = this.resources.updatesBuffer; + this.boundPostsByOwnerBuffer = this.resources.defensePostsByOwnerBuffer; + } + + markTile(tile: number): void { + this.pendingTiles.add(tile); + } + + dispose(): void { + // Resources are managed by GroundTruthData + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/compute/TerrainComputePass.ts b/src/client/graphics/webgpu/compute/TerrainComputePass.ts new file mode 100644 index 0000000000..45f2a6e8f4 --- /dev/null +++ b/src/client/graphics/webgpu/compute/TerrainComputePass.ts @@ -0,0 +1,145 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Compute pass that generates terrain colors from terrain data. + * Runs once at initialization or when theme changes. + */ +export class TerrainComputePass implements ComputePass { + name = "terrain-compute"; + dependencies: string[] = []; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private needsCompute = true; + private shaderPath = "compute/terrain-compute.wgsl"; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + this.ensureBindGroupLayout(); + await this.setShader(this.shaderPath); + this.rebuildBindGroup(); + } + + async setShader(shaderPath: string): Promise { + this.shaderPath = shaderPath; + if (!this.device || !this.bindGroupLayout) { + return; + } + + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.pipeline = this.device.createComputePipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + + this.needsCompute = true; + } + + needsUpdate(): boolean { + return this.needsCompute; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline || !this.bindGroup) { + return; + } + + const mapWidth = resources.getMapWidth(); + const mapHeight = resources.getMapHeight(); + const workgroupCountX = Math.ceil(mapWidth / 8); + const workgroupCountY = Math.ceil(mapHeight / 8); + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.dispatchWorkgroups(workgroupCountX, workgroupCountY); + pass.end(); + + this.needsCompute = false; + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.terrainParamsBuffer || + !this.resources.terrainDataTexture || + !this.resources.terrainTexture + ) { + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.terrainParamsBuffer }, + }, + { + binding: 1, + resource: this.resources.terrainDataTexture.createView(), + }, + { + binding: 2, + resource: this.resources.terrainTexture.createView(), + }, + ], + }); + } + + private ensureBindGroupLayout(): void { + if (!this.device || this.bindGroupLayout) { + return; + } + + this.bindGroupLayout = this.device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + texture: { sampleType: "uint" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "rgba8unorm" }, + }, + ], + }); + } + + markDirty(): void { + this.needsCompute = true; + // Rebuild bind group in case terrain params buffer was recreated + this.rebuildBindGroup(); + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts b/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts new file mode 100644 index 0000000000..488c3c0787 --- /dev/null +++ b/src/client/graphics/webgpu/compute/VisualStateSmoothingPass.ts @@ -0,0 +1,203 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { ComputePass } from "./ComputePass"; + +/** + * Per-frame compute pass that updates the visual state texture. + * Supports dissolve and budgeted reveal modes. + */ +export class VisualStateSmoothingPass implements ComputePass { + name = "visual-state-smoothing"; + dependencies: string[] = []; + + private pipeline: GPUComputePipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private paramsBuffer: GPUBuffer | null = null; + private paramsData = new Float32Array(8); + private enabled = false; + private shaderPath = "compute/visual-state-smoothing.wgsl"; + private mode = 0; + private curveExp = 1; + private boundUpdatesBuffer: GPUBuffer | null = null; + private boundVisualStateTexture: GPUTexture | null = null; + + async init(device: GPUDevice, resources: GroundTruthData): Promise { + this.device = device; + this.resources = resources; + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40; + const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.paramsBuffer = device.createBuffer({ + size: 32, + usage: UNIFORM | COPY_DST, + }); + + await this.setShader(this.shaderPath); + this.rebuildBindGroup(); + } + + async setShader(shaderPath: string): Promise { + this.shaderPath = shaderPath; + if (!this.device) { + return; + } + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.bindGroupLayout = this.device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 4 /* COMPUTE */, + buffer: { type: "uniform" }, + }, + { + binding: 2, + visibility: 4 /* COMPUTE */, + buffer: { type: "read-only-storage" }, + }, + { + binding: 3, + visibility: 4 /* COMPUTE */, + storageTexture: { format: "r32uint" }, + }, + ], + }); + + this.pipeline = this.device.createComputePipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + compute: { + module: shaderModule, + entryPoint: "main", + }, + }); + + this.rebuildBindGroup(); + } + + setParams(params0: Float32Array | number[]): void { + this.mode = Number(params0[0] ?? 0); + this.curveExp = Number(params0[1] ?? 1); + this.enabled = this.mode > 0; + } + + needsUpdate(): boolean { + if (!this.enabled || !this.resources) { + return false; + } + return this.resources.getLastStateUpdateCount() > 0; + } + + execute(encoder: GPUCommandEncoder, resources: GroundTruthData): void { + if (!this.device || !this.pipeline || !this.paramsBuffer) { + return; + } + + const updateCount = resources.getLastStateUpdateCount(); + if (updateCount <= 0) { + return; + } + + const updatesBuffer = resources.updatesBuffer; + const visualStateTexture = resources.getVisualStateTexture(); + if (!updatesBuffer || !visualStateTexture) { + return; + } + + this.paramsData[0] = this.mode; + this.paramsData[1] = this.curveExp; + this.paramsData[2] = 0; + this.paramsData[3] = 0; + this.paramsData[4] = updateCount; + this.paramsData[5] = 0; + this.paramsData[6] = 0; + this.paramsData[7] = 0; + this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData); + + const shouldRebuild = + !this.bindGroup || + this.boundUpdatesBuffer !== updatesBuffer || + this.boundVisualStateTexture !== visualStateTexture; + if (shouldRebuild) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginComputePass(); + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + const workgroupCount = Math.ceil(updateCount / 64); + pass.dispatchWorkgroups(workgroupCount); + pass.end(); + } + + private rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.temporalUniformBuffer || + !this.paramsBuffer || + !this.resources.updatesBuffer || + !this.resources.getVisualStateTexture() + ) { + this.bindGroup = null; + return; + } + + const visualStateTexture = this.resources.getVisualStateTexture(); + if (!visualStateTexture) { + this.bindGroup = null; + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.temporalUniformBuffer }, + }, + { + binding: 1, + resource: { buffer: this.paramsBuffer }, + }, + { + binding: 2, + resource: { buffer: this.resources.updatesBuffer }, + }, + { + binding: 3, + resource: visualStateTexture.createView(), + }, + ], + }); + + this.boundUpdatesBuffer = this.resources.updatesBuffer; + this.boundVisualStateTexture = visualStateTexture; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + this.paramsBuffer = null; + } +} diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts new file mode 100644 index 0000000000..d7aad9a71e --- /dev/null +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -0,0 +1,1324 @@ +import { Theme } from "../../../../core/configuration/Config"; +import { UnitType } from "../../../../core/game/Game"; +import type { TileRef } from "../../../../core/game/GameMap"; +import { GameView } from "../../../../core/game/GameView"; + +/** + * Alignment helper for texture uploads. + */ +function align(value: number, alignment: number): number { + return Math.ceil(value / alignment) * alignment; +} + +/** + * Manages authoritative GPU textures and buffers (ground truth data). + * All compute and render passes read from this data. + */ +export class GroundTruthData { + public static readonly PALETTE_RESERVED_SLOTS = 10; + public static readonly PALETTE_FALLOUT_INDEX = 0; + private static readonly MAX_OWNER_SLOTS = 0x1000; // ownerId is 12 bits + + // Textures + public readonly stateTexture: GPUTexture; + public readonly terrainTexture: GPUTexture; + public readonly terrainDataTexture: GPUTexture; + public readonly paletteTexture: GPUTexture; + public readonly ownerIndexTexture: GPUTexture; + public readonly relationsTexture: GPUTexture; + public readonly defendedStrengthTexture: GPUTexture; + public visualStateTexture: GPUTexture | null = null; + public currentColorTexture: GPUTexture | null = null; + public historyColorTextures: [GPUTexture, GPUTexture] | null = null; + + // Buffers + public readonly uniformBuffer: GPUBuffer; + public readonly temporalUniformBuffer: GPUBuffer; + public readonly terrainParamsBuffer: GPUBuffer; + public readonly stateUpdateParamsBuffer: GPUBuffer; + public readonly defendedStrengthParamsBuffer: GPUBuffer; + public updatesBuffer: GPUBuffer | null = null; + public readonly defenseOwnerOffsetsBuffer: GPUBuffer; + public defensePostsByOwnerBuffer: GPUBuffer; + public defendedDirtyTilesBuffer: GPUBuffer; + + // Staging arrays for buffer uploads + private updatesStaging: Uint32Array | null = null; + private defenseOwnerOffsetsStaging: Uint32Array; + private defensePostsByOwnerStaging: Uint32Array | null = null; + private defendedDirtyTilesStaging: Uint32Array | null = null; + + // Buffer capacities + private updatesCapacity = 0; + private defensePostsByOwnerCapacity = 0; + private defendedDirtyTilesCapacity = 0; + + // State tracking + private readonly mapWidth: number; + private readonly mapHeight: number; + private readonly state: Uint16Array; + private readonly terrainData: Uint8Array; + private needsStateUpload = true; + private needsPaletteUpload = true; + private needsTerrainDataUpload = true; + private needsTerrainParamsUpload = true; + private useVisualStateTexture = false; + private visualStateNeedsSync = false; + private lastStateUpdateCount = 0; + private historyIndex = 0; + private historyValid = false; + private postSmoothingWidth = 0; + private postSmoothingHeight = 0; + private postSmoothingFormat: GPUTextureFormat | null = null; + private lastTickSec = 0; + private tickDtSec = 0.1; + private tickDtEmaSec = 0.1; + private tickCount = 0; + private readonly tickEmaAlpha = 0.2; + private paletteWidth = 1; + private needsDefensePostsUpload = true; + private defensePostsTotalCount = 0; + private defendedDirtyTilesCount = 0; + private needsFullDefendedStrengthRecompute = false; + private lastDefensePostKeys = new Set(); + private defenseCircleRange = -1; + private defenseCircleOffsets: Int16Array = new Int16Array(0); // [dx0, dy0, dx1, dy1, ...] + + // Uniform data arrays + private readonly uniformData = new Float32Array(20); + private readonly temporalData = new Float32Array(8); + private readonly terrainParamsData = new Float32Array(32); // 8 vec4f: base colors + tuning + private readonly stateUpdateParamsData = new Uint32Array(4); // updateCount, range, pad, pad + private readonly defendedStrengthParamsData = new Uint32Array(4); // dirtyCount, range, pad, pad + + // View state (updated by renderer) + private viewWidth = 1; + private viewHeight = 1; + private viewScale = 1; + private viewOffsetX = 0; + private viewOffsetY = 0; + private alternativeView = false; + private highlightedOwnerId = -1; + + private territoryShaderParams0 = new Float32Array(4); + private territoryShaderParams1 = new Float32Array(4); + private terrainShaderParams0 = new Float32Array([0.0, 2.5, 1.0, 0.0]); + private terrainShaderParams1 = new Float32Array([0.6, 0.0, 0.0, 0.0]); + + private paletteMaxSmallId = 0; + private ownerIndexWidth = 1; + private relationsSize = 1; + + private constructor( + private readonly device: GPUDevice, + private readonly game: GameView, + private readonly theme: Theme, + state: Uint16Array, + terrainData: Uint8Array, + mapWidth: number, + mapHeight: number, + ) { + this.state = state; + this.terrainData = terrainData; + this.mapWidth = mapWidth; + this.mapHeight = mapHeight; + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const COPY_SRC_TEX = GPUTextureUsage?.COPY_SRC ?? 0x1; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8; + + // Render uniforms: 5x vec4f = 80 bytes + this.uniformBuffer = device.createBuffer({ + size: 80, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Temporal uniforms: 2x vec4f = 32 bytes + this.temporalUniformBuffer = device.createBuffer({ + size: 32, + usage: UNIFORM | COPY_DST_BUF, + }); + + // State update params: 4x u32 = 16 bytes + this.stateUpdateParamsBuffer = device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Defended strength params: 4x u32 = 16 bytes + this.defendedStrengthParamsBuffer = device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST_BUF, + }); + + // Terrain params: 8x vec4f = 128 bytes (base colors + tuning) + this.terrainParamsBuffer = device.createBuffer({ + size: 128, + usage: UNIFORM | COPY_DST_BUF, + }); + + // State texture (r32uint) + this.stateTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "r32uint", + usage: COPY_DST_TEX | COPY_SRC_TEX | TEXTURE_BINDING | STORAGE_BINDING, + }); + + // Defended strength texture (rgba8unorm, r channel used) + this.defendedStrengthTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "rgba8unorm", + usage: TEXTURE_BINDING | STORAGE_BINDING, + }); + + // Palette texture (rgba8unorm): row 0 territory colors, row 1 border colors + this.paletteTexture = device.createTexture({ + size: { width: 1, height: 2 }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + // SmallID -> dense index lookup texture (r32uint) + this.ownerIndexTexture = device.createTexture({ + size: { width: 1, height: 1 }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + // Dense relation matrix texture (r8uint) + this.relationsTexture = device.createTexture({ + size: { width: 1, height: 1 }, + format: "r8uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + // Terrain texture (rgba8unorm) - output of terrain compute shader + this.terrainTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, + }); + + // Terrain data texture (r8uint) - input terrain data (read-only in compute shader) + this.terrainDataTexture = device.createTexture({ + size: { width: mapWidth, height: mapHeight }, + format: "r8uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + + // Defense posts data: ownerOffsets[ownerId] = {start, count}, postsByOwner[start..] = {x,y} + this.defenseOwnerOffsetsBuffer = device.createBuffer({ + size: GroundTruthData.MAX_OWNER_SLOTS * 8, + usage: STORAGE | COPY_DST_BUF, + }); + this.defenseOwnerOffsetsStaging = new Uint32Array( + GroundTruthData.MAX_OWNER_SLOTS * 2, + ); + + this.defensePostsByOwnerBuffer = device.createBuffer({ + size: 8, + usage: STORAGE | COPY_DST_BUF, + }); + + // Dirty tile indices to recompute defended strength when posts change + this.defendedDirtyTilesBuffer = device.createBuffer({ + size: 4 * 8, + usage: STORAGE | COPY_DST_BUF, + }); + } + + static create( + device: GPUDevice, + game: GameView, + theme: Theme, + state: Uint16Array, + ): GroundTruthData { + return new GroundTruthData( + device, + game, + theme, + state, + game.terrainDataView(), + game.width(), + game.height(), + ); + } + + // ===================== + // View state setters + // ===================== + + setViewSize(width: number, height: number): void { + this.viewWidth = Math.max(1, Math.floor(width)); + this.viewHeight = Math.max(1, Math.floor(height)); + } + + setViewTransform(scale: number, offsetX: number, offsetY: number): void { + const eps = 1e-4; + const changed = + Math.abs(scale - this.viewScale) > eps || + Math.abs(offsetX - this.viewOffsetX) > eps || + Math.abs(offsetY - this.viewOffsetY) > eps; + this.viewScale = scale; + this.viewOffsetX = offsetX; + this.viewOffsetY = offsetY; + if (changed) { + this.invalidateHistory(); + } + } + + setAlternativeView(enabled: boolean): void { + if (this.alternativeView !== enabled) { + this.alternativeView = enabled; + this.invalidateHistory(); + } + } + + setHighlightedOwnerId(ownerSmallId: number | null): void { + this.highlightedOwnerId = ownerSmallId ?? -1; + } + + setTerritoryShaderParams( + params0: Float32Array | number[], + params1: Float32Array | number[], + ): void { + for (let i = 0; i < 4; i++) { + this.territoryShaderParams0[i] = Number(params0[i] ?? 0); + this.territoryShaderParams1[i] = Number(params1[i] ?? 0); + } + } + + setTerrainShaderParams( + params0: Float32Array | number[], + params1: Float32Array | number[], + ): void { + for (let i = 0; i < 4; i++) { + this.terrainShaderParams0[i] = Number(params0[i] ?? 0); + this.terrainShaderParams1[i] = Number(params1[i] ?? 0); + } + this.needsTerrainParamsUpload = true; + } + + setUseVisualStateTexture(enabled: boolean): void { + this.useVisualStateTexture = enabled; + if (enabled) { + this.visualStateNeedsSync = true; + } + } + + consumeVisualStateSyncNeeded(): boolean { + if (!this.visualStateNeedsSync) { + return false; + } + this.visualStateNeedsSync = false; + return true; + } + + ensureVisualStateTexture(): void { + if (this.visualStateTexture) { + return; + } + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + const STORAGE_BINDING = GPUTextureUsage?.STORAGE_BINDING ?? 0x8; + this.visualStateTexture = this.device.createTexture({ + size: { width: this.mapWidth, height: this.mapHeight }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING | STORAGE_BINDING, + }); + } + + releaseVisualStateTexture(): void { + if (this.visualStateTexture) { + (this.visualStateTexture as any).destroy?.(); + this.visualStateTexture = null; + } + } + + getVisualStateTexture(): GPUTexture | null { + return this.visualStateTexture; + } + + getRenderStateTexture(): GPUTexture { + if (this.useVisualStateTexture && this.visualStateTexture) { + return this.visualStateTexture; + } + return this.stateTexture; + } + + setLastStateUpdateCount(count: number): void { + this.lastStateUpdateCount = Math.max(0, Math.floor(count)); + } + + getLastStateUpdateCount(): number { + return this.lastStateUpdateCount; + } + + updateTickTiming(nowSec: number): void { + if (this.lastTickSec > 0) { + const dt = Math.max(1e-3, nowSec - this.lastTickSec); + this.tickDtSec = dt; + this.tickDtEmaSec = + this.tickDtEmaSec * (1 - this.tickEmaAlpha) + dt * this.tickEmaAlpha; + } + this.lastTickSec = nowSec; + this.tickCount += 1; + } + + writeTemporalUniformBuffer(nowSec: number): void { + const denom = Math.max(1e-3, this.tickDtEmaSec); + const alpha = Math.max(0, Math.min(1, (nowSec - this.lastTickSec) / denom)); + + this.temporalData[0] = nowSec; + this.temporalData[1] = this.lastTickSec; + this.temporalData[2] = this.tickDtSec; + this.temporalData[3] = this.tickDtEmaSec; + this.temporalData[4] = alpha; + this.temporalData[5] = this.tickCount; + this.temporalData[6] = this.historyValid ? 1 : 0; + this.temporalData[7] = 0; + + this.device.queue.writeBuffer( + this.temporalUniformBuffer, + 0, + this.temporalData, + ); + } + + invalidateHistory(): void { + this.historyValid = false; + } + + markHistoryValid(): void { + this.historyValid = true; + } + + swapHistoryTextures(): void { + if (!this.historyColorTextures) { + return; + } + this.historyIndex = this.historyIndex === 0 ? 1 : 0; + } + + ensurePostSmoothingTextures( + width: number, + height: number, + format: GPUTextureFormat, + ): void { + const w = Math.max(1, Math.floor(width)); + const h = Math.max(1, Math.floor(height)); + const needsRebuild = + !this.currentColorTexture || + !this.historyColorTextures || + this.postSmoothingWidth !== w || + this.postSmoothingHeight !== h || + this.postSmoothingFormat !== format; + + if (!needsRebuild) { + return; + } + + this.releasePostSmoothingTextures(); + + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const RENDER_ATTACHMENT = GPUTextureUsage?.RENDER_ATTACHMENT ?? 0x10; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + + this.currentColorTexture = this.device.createTexture({ + size: { width: w, height: h }, + format, + usage: RENDER_ATTACHMENT | TEXTURE_BINDING, + }); + const historyA = this.device.createTexture({ + size: { width: w, height: h }, + format, + usage: RENDER_ATTACHMENT | TEXTURE_BINDING, + }); + const historyB = this.device.createTexture({ + size: { width: w, height: h }, + format, + usage: RENDER_ATTACHMENT | TEXTURE_BINDING, + }); + + this.historyColorTextures = [historyA, historyB]; + this.historyIndex = 0; + this.historyValid = false; + this.postSmoothingWidth = w; + this.postSmoothingHeight = h; + this.postSmoothingFormat = format; + } + + releasePostSmoothingTextures(): void { + if (this.currentColorTexture) { + (this.currentColorTexture as any).destroy?.(); + this.currentColorTexture = null; + } + if (this.historyColorTextures) { + (this.historyColorTextures[0] as any).destroy?.(); + (this.historyColorTextures[1] as any).destroy?.(); + this.historyColorTextures = null; + } + this.historyValid = false; + this.postSmoothingWidth = 0; + this.postSmoothingHeight = 0; + this.postSmoothingFormat = null; + } + + getCurrentColorTexture(): GPUTexture | null { + return this.currentColorTexture; + } + + getHistoryReadTexture(): GPUTexture | null { + if (!this.historyColorTextures) { + return null; + } + return this.historyColorTextures[this.historyIndex]; + } + + getHistoryWriteTexture(): GPUTexture | null { + if (!this.historyColorTextures) { + return null; + } + return this.historyColorTextures[this.historyIndex === 0 ? 1 : 0]; + } + + // ===================== + // Upload methods + // ===================== + + uploadState(): void { + if (!this.needsStateUpload) { + return; + } + this.needsStateUpload = false; + + // Convert 16-bit CPU state to 32-bit array + const u32State = new Uint32Array(this.state.length); + for (let i = 0; i < this.state.length; i++) { + u32State[i] = this.state[i]; + } + + const bytesPerTexel = Uint32Array.BYTES_PER_ELEMENT; + const fullBytesPerRow = this.mapWidth * bytesPerTexel; + + if (fullBytesPerRow % 256 === 0) { + this.device.queue.writeTexture( + { texture: this.stateTexture }, + u32State, + { bytesPerRow: fullBytesPerRow, rowsPerImage: this.mapHeight }, + { + width: this.mapWidth, + height: this.mapHeight, + depthOrArrayLayers: 1, + }, + ); + } else { + // Fallback: upload row-by-row with padding + const paddedBytesPerRow = align(fullBytesPerRow, 256); + const scratch = new Uint32Array(paddedBytesPerRow / 4); + for (let y = 0; y < this.mapHeight; y++) { + const start = y * this.mapWidth; + scratch.set(u32State.subarray(start, start + this.mapWidth), 0); + this.device.queue.writeTexture( + { texture: this.stateTexture, origin: { x: 0, y } }, + scratch, + { bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 }, + { width: this.mapWidth, height: 1, depthOrArrayLayers: 1 }, + ); + } + } + } + + /** + * @deprecated Use terrain compute shader instead. This method is kept for fallback. + */ + uploadTerrain(): void { + const bytesPerRow = this.mapWidth * 4; + const paddedBytesPerRow = align(bytesPerRow, 256); + const row = new Uint8Array(paddedBytesPerRow); + + const toByte = (value: number): number => + Math.max(0, Math.min(255, Math.round(value))); + + for (let y = 0; y < this.mapHeight; y++) { + row.fill(0); + for (let x = 0; x < this.mapWidth; x++) { + const tile = y * this.mapWidth + x; + const rgba = this.theme.terrainColor(this.game, tile).rgba; + const idx = x * 4; + row[idx] = toByte(rgba.r); + row[idx + 1] = toByte(rgba.g); + row[idx + 2] = toByte(rgba.b); + row[idx + 3] = 255; + } + + this.device.queue.writeTexture( + { texture: this.terrainTexture, origin: { x: 0, y } }, + row, + { bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 }, + { width: this.mapWidth, height: 1, depthOrArrayLayers: 1 }, + ); + } + } + + uploadTerrainData(): void { + if (!this.needsTerrainDataUpload) { + return; + } + this.needsTerrainDataUpload = false; + + const bytesPerRow = this.mapWidth; + const paddedBytesPerRow = align(bytesPerRow, 256); + + if (paddedBytesPerRow === bytesPerRow) { + // Direct upload if already aligned + this.device.queue.writeTexture( + { texture: this.terrainDataTexture }, + this.terrainData, + { bytesPerRow, rowsPerImage: this.mapHeight }, + { + width: this.mapWidth, + height: this.mapHeight, + depthOrArrayLayers: 1, + }, + ); + } else { + // Row-by-row upload with padding + const row = new Uint8Array(paddedBytesPerRow); + for (let y = 0; y < this.mapHeight; y++) { + row.fill(0); + const start = y * this.mapWidth; + row.set(this.terrainData.subarray(start, start + this.mapWidth), 0); + this.device.queue.writeTexture( + { texture: this.terrainDataTexture, origin: { x: 0, y } }, + row, + { bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 }, + { width: this.mapWidth, height: 1, depthOrArrayLayers: 1 }, + ); + } + } + } + + uploadTerrainDataTiles(tiles: readonly TileRef[]): void { + if (tiles.length === 0) { + return; + } + if (this.needsTerrainDataUpload) { + this.uploadTerrainData(); + return; + } + + const rowBounds = new Map(); + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] >>> 0; + if (tile >= this.terrainData.length) { + continue; + } + const y = Math.floor(tile / this.mapWidth); + if (y < 0 || y >= this.mapHeight) { + continue; + } + const x = tile - y * this.mapWidth; + const existing = rowBounds.get(y); + if (existing) { + existing.minX = Math.min(existing.minX, x); + existing.maxX = Math.max(existing.maxX, x); + } else { + rowBounds.set(y, { minX: x, maxX: x }); + } + } + + for (const [y, bounds] of rowBounds) { + const width = bounds.maxX - bounds.minX + 1; + const paddedBytesPerRow = align(width, 256); + const row = new Uint8Array(paddedBytesPerRow); + const start = y * this.mapWidth + bounds.minX; + row.set(this.terrainData.subarray(start, start + width), 0); + this.device.queue.writeTexture( + { + texture: this.terrainDataTexture, + origin: { x: bounds.minX, y }, + }, + row, + { bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 }, + { width, height: 1, depthOrArrayLayers: 1 }, + ); + } + } + + uploadTerrainParams(): void { + if (!this.needsTerrainParamsUpload) { + return; + } + this.needsTerrainParamsUpload = false; + + // Extract theme colors directly from theme object (much faster than sampling tiles) + const themeAny = this.theme as any; + const isDark = themeAny.darkShore !== undefined; + + // Get shore color + const shore = isDark ? themeAny.darkShore : themeAny.shore; + const shoreColor = shore?.rgba ?? { r: 204, g: 203, b: 158, a: 255 }; + + // Get water colors + const water = isDark ? themeAny.darkWater : themeAny.water; + const waterColor = water?.rgba ?? { r: 70, g: 132, b: 180, a: 255 }; + + const shorelineWater = isDark + ? themeAny.darkShorelineWater + : themeAny.shorelineWater; + const shorelineWaterColor = shorelineWater?.rgba ?? { + r: 100, + g: 143, + b: 255, + a: 255, + }; + + // Compute terrain base colors from formulas (no tile sampling needed) + // Plains at mag 0: rgb(190, 220, 138) for pastel, rgb(140, 170, 88) for dark + const plainsColor = isDark + ? { r: 140, g: 170, b: 88, a: 255 } + : { r: 190, g: 220, b: 138, a: 255 }; + + // Highland at mag 10: rgb(220, 203, 158) for pastel, rgb(170, 153, 108) for dark + const highlandColor = isDark + ? { r: 170, g: 153, b: 108, a: 255 } + : { r: 220, g: 203, b: 158, a: 255 }; + + // Mountain at mag 20: rgb(240, 240, 240) for pastel, rgb(190, 190, 190) for dark + const mountainColor = isDark + ? { r: 190, g: 190, b: 190, a: 255 } + : { r: 240, g: 240, b: 240, a: 255 }; + + // Store colors as vec4f (RGBA normalized to 0-1) + // Index 0-3: shore color + this.terrainParamsData[0] = shoreColor.r / 255; + this.terrainParamsData[1] = shoreColor.g / 255; + this.terrainParamsData[2] = shoreColor.b / 255; + this.terrainParamsData[3] = 1.0; + + // Index 4-7: water base color + this.terrainParamsData[4] = waterColor.r / 255; + this.terrainParamsData[5] = waterColor.g / 255; + this.terrainParamsData[6] = waterColor.b / 255; + this.terrainParamsData[7] = 1.0; + + // Index 8-11: shoreline water color + this.terrainParamsData[8] = shorelineWaterColor.r / 255; + this.terrainParamsData[9] = shorelineWaterColor.g / 255; + this.terrainParamsData[10] = shorelineWaterColor.b / 255; + this.terrainParamsData[11] = 1.0; + + // Index 12-15: plains base color (magnitude 0) + this.terrainParamsData[12] = plainsColor.r / 255; + this.terrainParamsData[13] = plainsColor.g / 255; + this.terrainParamsData[14] = plainsColor.b / 255; + this.terrainParamsData[15] = 1.0; + + // Index 16-19: highland base color (magnitude 10) + this.terrainParamsData[16] = highlandColor.r / 255; + this.terrainParamsData[17] = highlandColor.g / 255; + this.terrainParamsData[18] = highlandColor.b / 255; + this.terrainParamsData[19] = 1.0; + + // Index 20-23: mountain base color (magnitude 20) + this.terrainParamsData[20] = mountainColor.r / 255; + this.terrainParamsData[21] = mountainColor.g / 255; + this.terrainParamsData[22] = mountainColor.b / 255; + this.terrainParamsData[23] = 1.0; + + // Index 24-31: tuning params (shader-dependent) + this.terrainParamsData[24] = this.terrainShaderParams0[0]; + this.terrainParamsData[25] = this.terrainShaderParams0[1]; + this.terrainParamsData[26] = this.terrainShaderParams0[2]; + this.terrainParamsData[27] = this.terrainShaderParams0[3]; + this.terrainParamsData[28] = this.terrainShaderParams1[0]; + this.terrainParamsData[29] = this.terrainShaderParams1[1]; + this.terrainParamsData[30] = this.terrainShaderParams1[2]; + this.terrainParamsData[31] = this.terrainShaderParams1[3]; + + this.device.queue.writeBuffer( + this.terrainParamsBuffer, + 0, + this.terrainParamsData, + ); + } + + markTerrainParamsDirty(): void { + this.needsTerrainParamsUpload = true; + } + + uploadPalette(): boolean { + if (!this.needsPaletteUpload) { + return false; + } + this.needsPaletteUpload = false; + + let maxSmallId = 0; + for (const player of this.game.playerViews()) { + maxSmallId = Math.max(maxSmallId, player.smallID()); + } + this.paletteMaxSmallId = maxSmallId; + const nextPaletteWidth = + GroundTruthData.PALETTE_RESERVED_SLOTS + Math.max(1, maxSmallId + 1); + + let textureRecreated = false; + if (nextPaletteWidth !== this.paletteWidth) { + this.paletteWidth = nextPaletteWidth; + (this.paletteTexture as any).destroy?.(); + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + (this as any).paletteTexture = this.device.createTexture({ + size: { width: this.paletteWidth, height: 2 }, + format: "rgba8unorm", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + textureRecreated = true; + } + + const rowStride = this.paletteWidth * 4; + const row0 = new Uint8Array(rowStride); + const row1 = new Uint8Array(rowStride); + + // Store special colors in reserved slots (0-9) + const falloutIdx = GroundTruthData.PALETTE_FALLOUT_INDEX * 4; + row0[falloutIdx] = 120; + row0[falloutIdx + 1] = 255; + row0[falloutIdx + 2] = 71; + row0[falloutIdx + 3] = 255; + + // Store player colors starting at index 10 + for (const player of this.game.playerViews()) { + const id = player.smallID(); + if (id <= 0) continue; + const rgba = player.territoryColor().rgba; + const idx = (GroundTruthData.PALETTE_RESERVED_SLOTS + id) * 4; + row0[idx] = rgba.r; + row0[idx + 1] = rgba.g; + row0[idx + 2] = rgba.b; + row0[idx + 3] = 255; + + const borderRgba = player.borderColor().rgba; + row1[idx] = borderRgba.r; + row1[idx + 1] = borderRgba.g; + row1[idx + 2] = borderRgba.b; + row1[idx + 3] = 255; + } + + const bytesPerRow = align(rowStride, 256); + const padded = new Uint8Array(bytesPerRow * 2); + padded.set(row0, 0); + padded.set(row1, bytesPerRow); + + this.device.queue.writeTexture( + { texture: this.paletteTexture }, + padded, + { bytesPerRow, rowsPerImage: 2 }, + { width: this.paletteWidth, height: 2, depthOrArrayLayers: 1 }, + ); + + return textureRecreated; + } + + uploadRelations(): boolean { + const players = this.game + .playerViews() + .filter((p) => p.smallID() > 0) + .slice() + .sort((a, b) => a.smallID() - b.smallID()); + + const maxSmallId = this.paletteMaxSmallId; + const nextOwnerIndexWidth = Math.max(1, maxSmallId + 1); + + let textureRecreated = false; + + if (nextOwnerIndexWidth !== this.ownerIndexWidth) { + this.ownerIndexWidth = nextOwnerIndexWidth; + (this.ownerIndexTexture as any).destroy?.(); + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + (this as any).ownerIndexTexture = this.device.createTexture({ + size: { width: this.ownerIndexWidth, height: 1 }, + format: "r32uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + textureRecreated = true; + } + + const denseBySmallId = new Uint32Array(this.ownerIndexWidth); + let dense = 0; + for (const p of players) { + const id = p.smallID(); + if (id <= 0 || id >= this.ownerIndexWidth) continue; + dense++; + denseBySmallId[id] = dense; + } + + const ownerIndexBytesPerRow = align(this.ownerIndexWidth * 4, 256); + const ownerIndexPaddedU32 = new Uint32Array(ownerIndexBytesPerRow / 4); + ownerIndexPaddedU32.set(denseBySmallId); + this.device.queue.writeTexture( + { texture: this.ownerIndexTexture }, + ownerIndexPaddedU32, + { bytesPerRow: ownerIndexBytesPerRow, rowsPerImage: 1 }, + { width: this.ownerIndexWidth, height: 1, depthOrArrayLayers: 1 }, + ); + + const nextRelationsSize = Math.max(1, dense + 1); + if (nextRelationsSize !== this.relationsSize) { + this.relationsSize = nextRelationsSize; + (this.relationsTexture as any).destroy?.(); + const GPUTextureUsage = (globalThis as any).GPUTextureUsage; + const COPY_DST_TEX = GPUTextureUsage?.COPY_DST ?? 0x2; + const TEXTURE_BINDING = GPUTextureUsage?.TEXTURE_BINDING ?? 0x4; + (this as any).relationsTexture = this.device.createTexture({ + size: { width: this.relationsSize, height: this.relationsSize }, + format: "r8uint", + usage: COPY_DST_TEX | TEXTURE_BINDING, + }); + textureRecreated = true; + } + + const relBytesPerRow = align(this.relationsSize, 256); + const relPadded = new Uint8Array(relBytesPerRow * this.relationsSize); + + // 0 = neutral, 1 = friendly, 2 = embargo + for (let i = 0; i < players.length; i++) { + for (let j = i + 1; j < players.length; j++) { + const a = players[i]; + const b = players[j]; + const aDense = denseBySmallId[a.smallID()]; + const bDense = denseBySmallId[b.smallID()]; + if (aDense === 0 || bDense === 0) continue; + + let code = 0; + if (a.hasEmbargo(b)) { + code = 2; + } else if (a.isFriendly(b) || b.isFriendly(a)) { + code = 1; + } + + relPadded[aDense + bDense * relBytesPerRow] = code; + relPadded[bDense + aDense * relBytesPerRow] = code; + } + } + + this.device.queue.writeTexture( + { texture: this.relationsTexture }, + relPadded, + { bytesPerRow: relBytesPerRow, rowsPerImage: this.relationsSize }, + { + width: this.relationsSize, + height: this.relationsSize, + depthOrArrayLayers: 1, + }, + ); + + return textureRecreated; + } + + uploadDefensePosts(): void { + if (!this.needsDefensePostsUpload) { + return; + } + this.needsDefensePostsUpload = false; + + const range = this.game.config().defensePostRange(); + const posts = this.collectDefensePosts(); + this.defensePostsTotalCount = posts.length; + + // Diff posts to produce dirty tiles for recompute (include removed + added). + const nextKeys = new Set(); + for (const p of posts) { + nextKeys.add(`${p.ownerId},${p.x},${p.y}`); + } + + const changedPosts: Array<{ x: number; y: number }> = []; + for (const key of this.lastDefensePostKeys) { + if (!nextKeys.has(key)) { + const [ownerStr, xStr, yStr] = key.split(","); + void ownerStr; + changedPosts.push({ x: Number(xStr), y: Number(yStr) }); + } + } + for (const key of nextKeys) { + if (!this.lastDefensePostKeys.has(key)) { + const [ownerStr, xStr, yStr] = key.split(","); + void ownerStr; + changedPosts.push({ x: Number(xStr), y: Number(yStr) }); + } + } + this.lastDefensePostKeys = nextKeys; + + // Pack posts by owner into GPU buffers. + this.packDefensePostsByOwner(posts); + + // Build dirty tiles around changed posts (so removals clear too). + this.buildDefendedDirtyTiles(changedPosts, range); + } + + getDefensePostsTotalCount(): number { + return this.defensePostsTotalCount; + } + + getDefendedDirtyTilesCount(): number { + return this.defendedDirtyTilesCount; + } + + needsDefendedFullRecompute(): boolean { + return this.needsFullDefendedStrengthRecompute; + } + + clearDefendedFullRecompute(): void { + this.needsFullDefendedStrengthRecompute = false; + } + + clearDefendedDirtyTiles(): void { + this.defendedDirtyTilesCount = 0; + } + + writeStateUpdateParamsBuffer(updateCount: number): void { + this.stateUpdateParamsData[0] = updateCount >>> 0; + this.stateUpdateParamsData[1] = this.game.config().defensePostRange() >>> 0; + this.stateUpdateParamsData[2] = 0; + this.stateUpdateParamsData[3] = 0; + this.device.queue.writeBuffer( + this.stateUpdateParamsBuffer, + 0, + this.stateUpdateParamsData, + ); + } + + writeDefendedStrengthParamsBuffer(dirtyCount: number): void { + this.defendedStrengthParamsData[0] = dirtyCount >>> 0; + this.defendedStrengthParamsData[1] = + this.game.config().defensePostRange() >>> 0; + this.defendedStrengthParamsData[2] = 0; + this.defendedStrengthParamsData[3] = 0; + this.device.queue.writeBuffer( + this.defendedStrengthParamsBuffer, + 0, + this.defendedStrengthParamsData, + ); + } + + private collectDefensePosts(): Array<{ + x: number; + y: number; + ownerId: number; + }> { + const posts: Array<{ x: number; y: number; ownerId: number }> = []; + const units = this.game.units(UnitType.DefensePost) as any[]; + for (const u of units) { + if (!u.isActive() || u.isUnderConstruction()) { + continue; + } + const tile = u.tile(); + posts.push({ + x: this.game.x(tile), + y: this.game.y(tile), + ownerId: u.owner().smallID(), + }); + } + return posts; + } + + private ensureDefensePostsByOwnerBuffer(capacityPosts: number): void { + const requested = Math.max(1, capacityPosts); + if ( + this.defensePostsByOwnerBuffer && + requested <= this.defensePostsByOwnerCapacity && + this.defensePostsByOwnerStaging + ) { + return; + } + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.defensePostsByOwnerCapacity = Math.max( + 8, + Math.pow(2, Math.ceil(Math.log2(requested))), + ); + + const bytesPerPost = 8; // 2 * u32 (x,y) + const bufferSize = this.defensePostsByOwnerCapacity * bytesPerPost; + + (this.defensePostsByOwnerBuffer as any).destroy?.(); + this.defensePostsByOwnerBuffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + + this.defensePostsByOwnerStaging = new Uint32Array( + this.defensePostsByOwnerCapacity * 2, + ); + } + + private ensureDefendedDirtyTilesBuffer(capacityTiles: number): void { + if ( + this.defendedDirtyTilesBuffer && + capacityTiles <= this.defendedDirtyTilesCapacity + ) { + return; + } + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.defendedDirtyTilesCapacity = Math.max( + 256, + Math.pow(2, Math.ceil(Math.log2(Math.max(1, capacityTiles)))), + ); + + const bufferSize = this.defendedDirtyTilesCapacity * 4; // u32 per tile + + (this.defendedDirtyTilesBuffer as any).destroy?.(); + this.defendedDirtyTilesBuffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + + this.defendedDirtyTilesStaging = new Uint32Array( + this.defendedDirtyTilesCapacity, + ); + } + + private packDefensePostsByOwner( + posts: Array<{ x: number; y: number; ownerId: number }>, + ): void { + // Reset counts + this.defenseOwnerOffsetsStaging.fill(0); + const counts = new Uint32Array(GroundTruthData.MAX_OWNER_SLOTS); + for (const p of posts) { + const owner = p.ownerId >>> 0; + if (owner === 0 || owner >= GroundTruthData.MAX_OWNER_SLOTS) continue; + counts[owner]++; + } + + // Prefix sums into offsets (start,count) pairs. + let running = 0; + for (let owner = 0; owner < GroundTruthData.MAX_OWNER_SLOTS; owner++) { + const count = counts[owner]; + this.defenseOwnerOffsetsStaging[owner * 2] = running; + this.defenseOwnerOffsetsStaging[owner * 2 + 1] = count; + running += count; + } + + this.ensureDefensePostsByOwnerBuffer(running); + if (!this.defensePostsByOwnerStaging) { + throw new Error("defensePostsByOwnerStaging not allocated"); + } + + const writeCursor = new Uint32Array(GroundTruthData.MAX_OWNER_SLOTS); + for (let owner = 0; owner < GroundTruthData.MAX_OWNER_SLOTS; owner++) { + writeCursor[owner] = this.defenseOwnerOffsetsStaging[owner * 2]; + } + + for (const p of posts) { + const owner = p.ownerId >>> 0; + if (owner === 0 || owner >= GroundTruthData.MAX_OWNER_SLOTS) continue; + const idx = writeCursor[owner]++; + this.defensePostsByOwnerStaging[idx * 2] = p.x >>> 0; + this.defensePostsByOwnerStaging[idx * 2 + 1] = p.y >>> 0; + } + + this.device.queue.writeBuffer( + this.defenseOwnerOffsetsBuffer, + 0, + this.defenseOwnerOffsetsStaging, + ); + if (running > 0) { + this.device.queue.writeBuffer( + this.defensePostsByOwnerBuffer, + 0, + this.defensePostsByOwnerStaging.subarray(0, running * 2), + ); + } + } + + private ensureDefenseCircleOffsets(range: number): void { + if (range === this.defenseCircleRange) { + return; + } + this.defenseCircleRange = range; + if (range <= 0) { + this.defenseCircleOffsets = new Int16Array(0); + return; + } + + const offsets: number[] = []; + const r2 = range * range; + for (let dy = -range; dy <= range; dy++) { + for (let dx = -range; dx <= range; dx++) { + if (dx * dx + dy * dy <= r2) { + offsets.push(dx, dy); + } + } + } + this.defenseCircleOffsets = new Int16Array(offsets); + } + + private buildDefendedDirtyTiles( + changedPosts: Array<{ x: number; y: number }>, + range: number, + ): void { + if (changedPosts.length === 0) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = false; + return; + } + + this.ensureDefenseCircleOffsets(range); + const offsets = this.defenseCircleOffsets; + const offsetsCount = offsets.length / 2; + if (offsetsCount === 0) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = false; + return; + } + + const worstCase = changedPosts.length * offsetsCount; + const mapTiles = this.mapWidth * this.mapHeight; + if (worstCase > mapTiles) { + this.defendedDirtyTilesCount = 0; + this.needsFullDefendedStrengthRecompute = true; + return; + } + + this.needsFullDefendedStrengthRecompute = false; + this.ensureDefendedDirtyTilesBuffer(worstCase); + if (!this.defendedDirtyTilesStaging) { + throw new Error("defendedDirtyTilesStaging not allocated"); + } + + let cursor = 0; + for (const post of changedPosts) { + for (let i = 0; i < offsets.length; i += 2) { + const x = post.x + offsets[i]; + const y = post.y + offsets[i + 1]; + if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) { + continue; + } + this.defendedDirtyTilesStaging[cursor++] = + (y * this.mapWidth + x) >>> 0; + } + } + + this.defendedDirtyTilesCount = cursor; + this.device.queue.writeBuffer( + this.defendedDirtyTilesBuffer, + 0, + this.defendedDirtyTilesStaging.subarray(0, cursor), + ); + } + + ensureUpdatesBuffer(capacity: number): GPUBuffer { + if (this.updatesBuffer && capacity <= this.updatesCapacity) { + return this.updatesBuffer; + } + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const STORAGE = GPUBufferUsage?.STORAGE ?? 0x10; + const COPY_DST_BUF = GPUBufferUsage?.COPY_DST ?? 0x8; + + this.updatesCapacity = Math.max( + 256, + Math.pow(2, Math.ceil(Math.log2(capacity))), + ); + const bufferSize = this.updatesCapacity * 8; // Each update is 8 bytes + + if (this.updatesBuffer) { + (this.updatesBuffer as any).destroy?.(); + } + + const buffer = this.device.createBuffer({ + size: bufferSize, + usage: STORAGE | COPY_DST_BUF, + }); + (this as any).updatesBuffer = buffer; + + this.updatesStaging = new Uint32Array(this.updatesCapacity * 2); + return buffer; + } + + getUpdatesStaging(): Uint32Array { + this.updatesStaging ??= new Uint32Array(this.updatesCapacity * 2); + return this.updatesStaging; + } + + // ===================== + // Uniform buffer updates + // ===================== + + writeUniformBuffer(timeSec: number): void { + this.uniformData[0] = this.mapWidth; + this.uniformData[1] = this.mapHeight; + this.uniformData[2] = this.viewScale; + this.uniformData[3] = timeSec; + this.uniformData[4] = this.viewOffsetX; + this.uniformData[5] = this.viewOffsetY; + this.uniformData[6] = this.alternativeView ? 1 : 0; + this.uniformData[7] = this.highlightedOwnerId; + this.uniformData[8] = this.viewWidth; + this.uniformData[9] = this.viewHeight; + this.uniformData[10] = this.game.myPlayer()?.smallID() ?? 0; + this.uniformData[11] = 0; + + this.uniformData[12] = this.territoryShaderParams0[0]; + this.uniformData[13] = this.territoryShaderParams0[1]; + this.uniformData[14] = this.territoryShaderParams0[2]; + this.uniformData[15] = this.territoryShaderParams0[3]; + + this.uniformData[16] = this.territoryShaderParams1[0]; + this.uniformData[17] = this.territoryShaderParams1[1]; + this.uniformData[18] = this.territoryShaderParams1[2]; + this.uniformData[19] = this.territoryShaderParams1[3]; + + this.device.queue.writeBuffer(this.uniformBuffer, 0, this.uniformData); + } + + // ===================== + // State getters/setters + // ===================== + + markPaletteDirty(): void { + this.needsPaletteUpload = true; + } + + markDefensePostsDirty(): void { + this.needsDefensePostsUpload = true; + } + + getState(): Uint16Array { + return this.state; + } + + getMapWidth(): number { + return this.mapWidth; + } + + getMapHeight(): number { + return this.mapHeight; + } + + getGame(): GameView { + return this.game; + } + + getTheme(): Theme { + return this.theme; + } +} diff --git a/src/client/graphics/webgpu/core/ShaderLoader.ts b/src/client/graphics/webgpu/core/ShaderLoader.ts new file mode 100644 index 0000000000..e5e81d4008 --- /dev/null +++ b/src/client/graphics/webgpu/core/ShaderLoader.ts @@ -0,0 +1,19 @@ +/** + * Utility for loading WGSL shader sources bundled by Vite. + * Uses a static glob so production builds reliably include all shaders. + */ + +const shaderSources = import.meta.glob("../shaders/**/*.wgsl", { + query: "?raw", + import: "default", + eager: true, +}) as Record; + +export async function loadShader(path: string): Promise { + const key = `../shaders/${path}`; + const src = shaderSources[key]; + if (!src) { + throw new Error(`Missing WGSL shader source: ${key}`); + } + return src; +} diff --git a/src/client/graphics/webgpu/core/WebGPUDevice.ts b/src/client/graphics/webgpu/core/WebGPUDevice.ts new file mode 100644 index 0000000000..27b587a7c5 --- /dev/null +++ b/src/client/graphics/webgpu/core/WebGPUDevice.ts @@ -0,0 +1,66 @@ +/** + * Manages WebGPU device initialization and canvas context configuration. + */ + +export class WebGPUDevice { + public readonly device: GPUDevice; + public readonly context: GPUCanvasContext; + public readonly canvasFormat: GPUTextureFormat; + + private constructor( + device: GPUDevice, + context: GPUCanvasContext, + canvasFormat: GPUTextureFormat, + ) { + this.device = device; + this.context = context; + this.canvasFormat = canvasFormat; + } + + /** + * Initialize WebGPU device and canvas context. + * @param canvas Canvas element to configure + * @returns WebGPUDevice instance or null if WebGPU is not available + */ + static async create(canvas: HTMLCanvasElement): Promise { + const nav = globalThis.navigator as any; + if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") { + return null; + } + + const adapter = await nav.gpu.requestAdapter(); + if (!adapter) { + return null; + } + + const device = await adapter.requestDevice(); + const context = canvas.getContext("webgpu"); + if (!context) { + return null; + } + + const canvasFormat = + typeof nav.gpu.getPreferredCanvasFormat === "function" + ? nav.gpu.getPreferredCanvasFormat() + : "bgra8unorm"; + + context.configure({ + device, + format: canvasFormat, + alphaMode: "opaque", + }); + + return new WebGPUDevice(device, context, canvasFormat); + } + + /** + * Reconfigure the canvas context (e.g., when canvas size changes). + */ + reconfigure(): void { + this.context.configure({ + device: this.device, + format: this.canvasFormat, + alphaMode: "opaque", + }); + } +} diff --git a/src/client/graphics/webgpu/render/RenderPass.ts b/src/client/graphics/webgpu/render/RenderPass.ts new file mode 100644 index 0000000000..3140d0026b --- /dev/null +++ b/src/client/graphics/webgpu/render/RenderPass.ts @@ -0,0 +1,46 @@ +import { GroundTruthData } from "../core/GroundTruthData"; + +/** + * Base interface for render passes. + * Render passes run during render() (frame rate) to draw to the canvas. + */ +export interface RenderPass { + /** Unique name of this pass (used for dependency resolution) */ + name: string; + + /** Names of render passes that must run before this one */ + dependencies: string[]; + + /** + * Initialize the pass with device, resources, and canvas format. + * Called once during renderer initialization. + */ + init( + device: GPUDevice, + resources: GroundTruthData, + canvasFormat: GPUTextureFormat, + ): Promise; + + /** + * Check if this pass needs to run this frame. + * Performance optimization: return false to skip execution. + */ + needsUpdate(): boolean; + + /** + * Execute the render pass. + * @param encoder Command encoder for recording GPU commands + * @param resources Ground truth data (read-only access) + * @param target Target texture view to render to + */ + execute( + encoder: GPUCommandEncoder, + resources: GroundTruthData, + target: GPUTextureView, + ): void; + + /** + * Clean up resources when the pass is no longer needed. + */ + dispose(): void; +} diff --git a/src/client/graphics/webgpu/render/TemporalResolvePass.ts b/src/client/graphics/webgpu/render/TemporalResolvePass.ts new file mode 100644 index 0000000000..1d9a4b1628 --- /dev/null +++ b/src/client/graphics/webgpu/render/TemporalResolvePass.ts @@ -0,0 +1,218 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { RenderPass } from "./RenderPass"; + +/** + * Post-render temporal resolve pass. Blends current and history frames. + */ +export class TemporalResolvePass implements RenderPass { + name = "temporal-resolve"; + dependencies: string[] = ["territory"]; + + private pipeline: GPURenderPipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private canvasFormat: GPUTextureFormat | null = null; + private paramsBuffer: GPUBuffer | null = null; + private paramsData = new Float32Array(4); + private enabled = false; + private boundCurrentTexture: GPUTexture | null = null; + private boundHistoryTexture: GPUTexture | null = null; + + async init( + device: GPUDevice, + resources: GroundTruthData, + canvasFormat: GPUTextureFormat, + ): Promise { + this.device = device; + this.resources = resources; + this.canvasFormat = canvasFormat; + + const GPUBufferUsage = (globalThis as any).GPUBufferUsage; + const UNIFORM = GPUBufferUsage?.UNIFORM ?? 0x40; + const COPY_DST = GPUBufferUsage?.COPY_DST ?? 0x8; + this.paramsBuffer = device.createBuffer({ + size: 16, + usage: UNIFORM | COPY_DST, + }); + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 2 /* FRAGMENT */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 2 /* FRAGMENT */, + buffer: { type: "uniform" }, + }, + { + binding: 2, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 3, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + ], + }); + + await this.setShader("render/temporal-resolve.wgsl"); + this.rebuildBindGroup(); + } + + async setShader(shaderPath: string): Promise { + if (!this.device || !this.bindGroupLayout || !this.canvasFormat) { + return; + } + + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.pipeline = this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + vertex: { module: shaderModule, entryPoint: "vsMain" }, + fragment: { + module: shaderModule, + entryPoint: "fsMain", + targets: [{ format: this.canvasFormat }, { format: this.canvasFormat }], + }, + primitive: { topology: "triangle-list" }, + }); + } + + setParams(params0: Float32Array | number[]): void { + this.paramsData[0] = Number(params0[0] ?? 0); + this.paramsData[1] = Number(params0[1] ?? 1); + this.paramsData[2] = Number(params0[2] ?? 0.08); + this.paramsData[3] = 0; + this.enabled = this.paramsData[0] > 0; + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } + + needsUpdate(): boolean { + return this.enabled; + } + + execute( + encoder: GPUCommandEncoder, + resources: GroundTruthData, + target: GPUTextureView, + ): void { + if (!this.device || !this.pipeline || !this.paramsBuffer) { + return; + } + if (!this.enabled) { + return; + } + + const currentTexture = resources.getCurrentColorTexture(); + const historyRead = resources.getHistoryReadTexture(); + const historyWrite = resources.getHistoryWriteTexture(); + if (!currentTexture || !historyRead || !historyWrite) { + return; + } + + this.device.queue.writeBuffer(this.paramsBuffer, 0, this.paramsData); + + const shouldRebuild = + !this.bindGroup || + this.boundCurrentTexture !== currentTexture || + this.boundHistoryTexture !== historyRead; + if (shouldRebuild) { + this.rebuildBindGroup(); + } + + if (!this.bindGroup) { + return; + } + + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: target, + loadOp: "clear", + storeOp: "store", + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }, + { + view: historyWrite.createView(), + loadOp: "clear", + storeOp: "store", + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }, + ], + }); + + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.draw(3); + pass.end(); + + resources.swapHistoryTextures(); + resources.markHistoryValid(); + } + + rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.temporalUniformBuffer || + !this.paramsBuffer + ) { + return; + } + + const currentTexture = this.resources.getCurrentColorTexture(); + const historyRead = this.resources.getHistoryReadTexture(); + if (!currentTexture || !historyRead) { + return; + } + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.resources.temporalUniformBuffer }, + }, + { + binding: 1, + resource: { buffer: this.paramsBuffer }, + }, + { + binding: 2, + resource: currentTexture.createView(), + }, + { + binding: 3, + resource: historyRead.createView(), + }, + ], + }); + + this.boundCurrentTexture = currentTexture; + this.boundHistoryTexture = historyRead; + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + this.paramsBuffer = null; + } +} diff --git a/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts new file mode 100644 index 0000000000..1258b4787d --- /dev/null +++ b/src/client/graphics/webgpu/render/TerrainShaderRegistry.ts @@ -0,0 +1,284 @@ +export type TerrainShaderId = "classic" | "improved-lite" | "improved-heavy"; + +export type TerrainShaderOption = + | { + kind: "boolean"; + key: string; + label: string; + defaultValue: boolean; + } + | { + kind: "range"; + key: string; + label: string; + defaultValue: number; + min: number; + max: number; + step: number; + } + | { + kind: "enum"; + key: string; + label: string; + defaultValue: number; + options: Array<{ value: number; label: string }>; + }; + +export interface TerrainShaderDefinition { + id: TerrainShaderId; + label: string; + wgslPath: string; + options: TerrainShaderOption[]; +} + +export const TERRAIN_SHADER_KEY = "settings.webgpu.terrain.shader"; + +export const TERRAIN_SHADERS: TerrainShaderDefinition[] = [ + { + id: "classic", + label: "Classic", + wgslPath: "compute/terrain-compute.wgsl", + options: [], + }, + { + id: "improved-lite", + label: "Improved (Lite)", + wgslPath: "compute/terrain-compute-improved-lite.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.terrain.improvedLite.noiseStrength", + label: "Noise Strength", + defaultValue: 0.005, + min: 0, + max: 0.08, + step: 0.005, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedLite.blendWidth", + label: "Biome Blend Width", + defaultValue: 5, + min: 0.5, + max: 5, + step: 0.25, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedLite.waterBlurStrength", + label: "Water Blur Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.05, + }, + ], + }, + { + id: "improved-heavy", + label: "Improved (Heavy)", + wgslPath: "compute/terrain-compute-improved-heavy.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.noiseStrength", + label: "Noise Strength", + defaultValue: 0.01, + min: 0, + max: 0.1, + step: 0.005, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.detailNoiseStrength", + label: "Detail Noise Strength", + defaultValue: 0.01, + min: 0, + max: 0.08, + step: 0.005, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.blendWidth", + label: "Biome Blend Width", + defaultValue: 4.5, + min: 0.5, + max: 6, + step: 0.25, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.waterDepthStrength", + label: "Water Depth Strength", + defaultValue: 0.35, + min: 0, + max: 1, + step: 0.05, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.waterDepthCurve", + label: "Water Depth Curve", + defaultValue: 2, + min: 0.5, + max: 4, + step: 0.25, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.waterDepthBlur", + label: "Water Depth Blur", + defaultValue: 0.6, + min: 0, + max: 1, + step: 0.05, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.lightingStrength", + label: "Lighting Strength", + defaultValue: 0.3, + min: 0, + max: 1, + step: 0.05, + }, + { + kind: "range", + key: "settings.webgpu.terrain.improvedHeavy.cavityStrength", + label: "Cavity Strength", + defaultValue: 0.15, + min: 0, + max: 1, + step: 0.05, + }, + ], + }, +]; + +export function getTerrainShaderById( + id: TerrainShaderId, +): TerrainShaderDefinition { + const found = TERRAIN_SHADERS.find((s) => s.id === id); + if (!found) { + throw new Error(`Unknown terrain shader: ${id}`); + } + return found; +} + +export function terrainShaderIdFromInt(value: number): TerrainShaderId { + if (value === 1) return "improved-lite"; + if (value === 2) return "improved-heavy"; + return "classic"; +} + +export function terrainShaderIntFromId(id: TerrainShaderId): number { + if (id === "improved-lite") return 1; + if (id === "improved-heavy") return 2; + return 0; +} + +export function readTerrainShaderId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerrainShaderId { + return terrainShaderIdFromInt(userSettings.getInt(TERRAIN_SHADER_KEY, 2)); +} + +export function buildTerrainShaderParams( + userSettings: { + getFloat: (key: string, defaultValue: number) => number; + }, + shaderId: TerrainShaderId, +): { shaderPath: string; params0: Float32Array; params1: Float32Array } { + const waterDepthStrengthDefault = 0.4; + const waterDepthCurveDefault = 2; + const waterDepthBlurDefault = 0.6; + + if (shaderId === "improved-lite") { + const noiseStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedLite.noiseStrength", + 0.005, + ); + const blendWidth = userSettings.getFloat( + "settings.webgpu.terrain.improvedLite.blendWidth", + 5, + ); + const waterBlurStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedLite.waterBlurStrength", + 1, + ); + const params0 = new Float32Array([ + noiseStrength, + blendWidth, + waterBlurStrength, + 0, + ]); + const params1 = new Float32Array([0, 0, 0, 0]); + return { + shaderPath: "compute/terrain-compute-improved-lite.wgsl", + params0, + params1, + }; + } + + if (shaderId === "improved-heavy") { + const noiseStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.noiseStrength", + 0.01, + ); + const detailNoiseStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.detailNoiseStrength", + 0.01, + ); + const blendWidth = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.blendWidth", + 4.5, + ); + const waterDepthStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.waterDepthStrength", + 0.35, + ); + const waterDepthCurve = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.waterDepthCurve", + 2, + ); + const waterDepthBlur = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.waterDepthBlur", + 0.6, + ); + const lightingStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.lightingStrength", + 0.3, + ); + const cavityStrength = userSettings.getFloat( + "settings.webgpu.terrain.improvedHeavy.cavityStrength", + 0.15, + ); + + const params0 = new Float32Array([ + noiseStrength, + blendWidth, + waterDepthStrength, + waterDepthCurve, + ]); + const params1 = new Float32Array([ + detailNoiseStrength, + lightingStrength, + cavityStrength, + waterDepthBlur, + ]); + return { + shaderPath: "compute/terrain-compute-improved-heavy.wgsl", + params0, + params1, + }; + } + + const params0 = new Float32Array([ + 0, + 2.5, + waterDepthStrengthDefault, + waterDepthCurveDefault, + ]); + const params1 = new Float32Array([waterDepthBlurDefault, 0, 0, 0]); + return { shaderPath: "compute/terrain-compute.wgsl", params0, params1 }; +} diff --git a/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts new file mode 100644 index 0000000000..2c8655761b --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryPostSmoothingRegistry.ts @@ -0,0 +1,128 @@ +import { TerritoryShaderOption } from "./TerritoryShaderRegistry"; + +export type TerritoryPostSmoothingId = "off" | "fade" | "dissolve"; + +export interface TerritoryPostSmoothingDefinition { + id: TerritoryPostSmoothingId; + label: string; + wgslPath: string; + options: TerritoryShaderOption[]; +} + +export const TERRITORY_POST_SMOOTHING_KEY = + "settings.webgpu.territory.smoothing.post"; + +export const TERRITORY_POST_SMOOTHING: TerritoryPostSmoothingDefinition[] = [ + { + id: "off", + label: "Off", + wgslPath: "", + options: [], + }, + { + id: "fade", + label: "Fade", + wgslPath: "render/temporal-resolve.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.postSmoothing.blendStrength", + label: "Blend Strength", + defaultValue: 0.2, + min: 0.01, + max: 1, + step: 0.01, + }, + ], + }, + { + id: "dissolve", + label: "Dissolve", + wgslPath: "render/temporal-resolve.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.postSmoothing.blendStrength", + label: "Blend Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.postSmoothing.dissolveWidth", + label: "Dissolve Width", + defaultValue: 0.08, + min: 0.01, + max: 0.4, + step: 0.01, + }, + ], + }, +]; + +export function territoryPostSmoothingIdFromInt( + value: number, +): TerritoryPostSmoothingId { + if (value === 1) return "fade"; + if (value === 2) return "dissolve"; + return "off"; +} + +export function territoryPostSmoothingIntFromId( + id: TerritoryPostSmoothingId, +): number { + if (id === "fade") return 1; + if (id === "dissolve") return 2; + return 0; +} + +export function readTerritoryPostSmoothingId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerritoryPostSmoothingId { + return territoryPostSmoothingIdFromInt( + userSettings.getInt(TERRITORY_POST_SMOOTHING_KEY, 1), + ); +} + +export function buildTerritoryPostSmoothingParams( + userSettings: { + getFloat: (key: string, defaultValue: number) => number; + }, + smoothingId: TerritoryPostSmoothingId, +): { + enabled: boolean; + shaderPath: string; + params0: Float32Array; + params1: Float32Array; +} { + if (smoothingId === "off") { + return { + enabled: false, + shaderPath: "", + params0: new Float32Array(4), + params1: new Float32Array(4), + }; + } + + const blendStrength = userSettings.getFloat( + "settings.webgpu.territory.postSmoothing.blendStrength", + 0.2, + ); + const dissolveWidth = userSettings.getFloat( + "settings.webgpu.territory.postSmoothing.dissolveWidth", + 0.08, + ); + + const mode = smoothingId === "fade" ? 1 : 2; + const params0 = new Float32Array([mode, blendStrength, dissolveWidth, 0]); + const params1 = new Float32Array([0, 0, 0, 0]); + + return { + enabled: true, + shaderPath: "render/temporal-resolve.wgsl", + params0, + params1, + }; +} diff --git a/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts b/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts new file mode 100644 index 0000000000..e04ee0a6da --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryPreSmoothingRegistry.ts @@ -0,0 +1,114 @@ +import { TerritoryShaderOption } from "./TerritoryShaderRegistry"; + +export type TerritoryPreSmoothingId = "off" | "dissolve" | "budget"; + +export interface TerritoryPreSmoothingDefinition { + id: TerritoryPreSmoothingId; + label: string; + wgslPath: string; + options: TerritoryShaderOption[]; +} + +export const TERRITORY_PRE_SMOOTHING_KEY = + "settings.webgpu.territory.smoothing.pre"; + +export const TERRITORY_PRE_SMOOTHING: TerritoryPreSmoothingDefinition[] = [ + { + id: "off", + label: "Off", + wgslPath: "", + options: [], + }, + { + id: "dissolve", + label: "Dissolve", + wgslPath: "compute/visual-state-smoothing.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.preSmoothing.curveExp", + label: "Reveal Curve", + defaultValue: 1, + min: 0.25, + max: 3, + step: 0.05, + }, + ], + }, + { + id: "budget", + label: "Budgeted Reveal", + wgslPath: "compute/visual-state-smoothing.wgsl", + options: [ + { + kind: "range", + key: "settings.webgpu.territory.preSmoothing.curveExp", + label: "Reveal Curve", + defaultValue: 1, + min: 0.25, + max: 3, + step: 0.05, + }, + ], + }, +]; + +export function territoryPreSmoothingIdFromInt( + value: number, +): TerritoryPreSmoothingId { + if (value === 1) return "dissolve"; + if (value === 2) return "budget"; + return "off"; +} + +export function territoryPreSmoothingIntFromId( + id: TerritoryPreSmoothingId, +): number { + if (id === "dissolve") return 1; + if (id === "budget") return 2; + return 0; +} + +export function readTerritoryPreSmoothingId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerritoryPreSmoothingId { + return territoryPreSmoothingIdFromInt( + userSettings.getInt(TERRITORY_PRE_SMOOTHING_KEY, 0), + ); +} + +export function buildTerritoryPreSmoothingParams( + userSettings: { + getFloat: (key: string, defaultValue: number) => number; + }, + smoothingId: TerritoryPreSmoothingId, +): { + enabled: boolean; + shaderPath: string; + params0: Float32Array; + params1: Float32Array; +} { + if (smoothingId === "off") { + return { + enabled: false, + shaderPath: "", + params0: new Float32Array(4), + params1: new Float32Array(4), + }; + } + + const curveExp = userSettings.getFloat( + "settings.webgpu.territory.preSmoothing.curveExp", + 1, + ); + const mode = smoothingId === "dissolve" ? 1 : 2; + + const params0 = new Float32Array([mode, curveExp, 0, 0]); + const params1 = new Float32Array([0, 0, 0, 0]); + return { + enabled: true, + shaderPath: "compute/visual-state-smoothing.wgsl", + params0, + params1, + }; +} diff --git a/src/client/graphics/webgpu/render/TerritoryRenderPass.ts b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts new file mode 100644 index 0000000000..b9f875a5b9 --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryRenderPass.ts @@ -0,0 +1,210 @@ +import { GroundTruthData } from "../core/GroundTruthData"; +import { loadShader } from "../core/ShaderLoader"; +import { RenderPass } from "./RenderPass"; + +/** + * Main territory rendering pass. + * Renders territory colors, defended tiles, fallout, and hover highlights. + */ +export class TerritoryRenderPass implements RenderPass { + name = "territory"; + dependencies: string[] = []; + + private pipeline: GPURenderPipeline | null = null; + private bindGroupLayout: GPUBindGroupLayout | null = null; + private bindGroup: GPUBindGroup | null = null; + private device: GPUDevice | null = null; + private resources: GroundTruthData | null = null; + private canvasFormat: GPUTextureFormat | null = null; + private shaderPath = "render/territory.wgsl"; + private clearR = 0; + private clearG = 0; + private clearB = 0; + + async init( + device: GPUDevice, + resources: GroundTruthData, + canvasFormat: GPUTextureFormat, + ): Promise { + this.device = device; + this.resources = resources; + this.canvasFormat = canvasFormat; + + this.bindGroupLayout = device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: 2 /* FRAGMENT */, + buffer: { type: "uniform" }, + }, + { + binding: 1, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, + { + binding: 2, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 3, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 4, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "float" }, + }, + { + binding: 5, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, + { + binding: 6, + visibility: 2 /* FRAGMENT */, + texture: { sampleType: "uint" }, + }, + ], + }); + + await this.setShader(this.shaderPath); + + this.rebuildBindGroup(); + + // Extract clear color from theme + const bg = resources.getTheme().backgroundColor().rgba; + this.clearR = bg.r / 255; + this.clearG = bg.g / 255; + this.clearB = bg.b / 255; + } + + async setShader(shaderPath: string): Promise { + this.shaderPath = shaderPath; + + if (!this.device || !this.bindGroupLayout || !this.canvasFormat) { + return; + } + + const shaderCode = await loadShader(shaderPath); + const shaderModule = this.device.createShaderModule({ code: shaderCode }); + + this.pipeline = this.device.createRenderPipeline({ + layout: this.device.createPipelineLayout({ + bindGroupLayouts: [this.bindGroupLayout], + }), + vertex: { module: shaderModule, entryPoint: "vsMain" }, + fragment: { + module: shaderModule, + entryPoint: "fsMain", + targets: [{ format: this.canvasFormat }], + }, + primitive: { topology: "triangle-list" }, + }); + } + + needsUpdate(): boolean { + // Always run every frame (can be optimized later if needed) + return true; + } + + execute( + encoder: GPUCommandEncoder, + resources: GroundTruthData, + target: GPUTextureView, + ): void { + if (!this.device || !this.pipeline) { + return; + } + + // Rebuild bind group if needed (e.g., after texture recreation) + this.rebuildBindGroup(); + + if (!this.bindGroup) { + return; + } + + // Update uniforms + resources.writeUniformBuffer(performance.now() / 1000); + + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: target, + loadOp: "clear", + storeOp: "store", + clearValue: { + r: this.clearR, + g: this.clearG, + b: this.clearB, + a: 1, + }, + }, + ], + }); + + pass.setPipeline(this.pipeline); + pass.setBindGroup(0, this.bindGroup); + pass.draw(3); + pass.end(); + } + + rebuildBindGroup(): void { + if ( + !this.device || + !this.bindGroupLayout || + !this.resources || + !this.resources.uniformBuffer || + !this.resources.defendedStrengthTexture || + !this.resources.paletteTexture || + !this.resources.terrainTexture || + !this.resources.ownerIndexTexture || + !this.resources.relationsTexture + ) { + return; + } + + const stateTexture = this.resources.getRenderStateTexture(); + + this.bindGroup = this.device.createBindGroup({ + layout: this.bindGroupLayout, + entries: [ + { binding: 0, resource: { buffer: this.resources.uniformBuffer } }, + { + binding: 1, + resource: stateTexture.createView(), + }, + { + binding: 2, + resource: this.resources.defendedStrengthTexture.createView(), + }, + { + binding: 3, + resource: this.resources.paletteTexture.createView(), + }, + { + binding: 4, + resource: this.resources.terrainTexture.createView(), + }, + { + binding: 5, + resource: this.resources.ownerIndexTexture.createView(), + }, + { + binding: 6, + resource: this.resources.relationsTexture.createView(), + }, + ], + }); + } + + dispose(): void { + this.pipeline = null; + this.bindGroupLayout = null; + this.bindGroup = null; + this.device = null; + this.resources = null; + } +} diff --git a/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts new file mode 100644 index 0000000000..a32140944d --- /dev/null +++ b/src/client/graphics/webgpu/render/TerritoryShaderRegistry.ts @@ -0,0 +1,341 @@ +export type TerritoryShaderId = "classic" | "retro"; + +export type TerritoryShaderOption = + | { + kind: "boolean"; + key: string; + label: string; + defaultValue: boolean; + } + | { + kind: "range"; + key: string; + label: string; + defaultValue: number; + min: number; + max: number; + step: number; + } + | { + kind: "enum"; + key: string; + label: string; + defaultValue: number; + options: Array<{ value: number; label: string }>; + }; + +export interface TerritoryShaderDefinition { + id: TerritoryShaderId; + label: string; + wgslPath: string; + options: TerritoryShaderOption[]; +} + +export const TERRITORY_SHADER_KEY = "settings.webgpu.territory.shader"; + +export const TERRITORY_SHADERS: TerritoryShaderDefinition[] = [ + { + id: "classic", + label: "Simple", + wgslPath: "render/territory.wgsl", + options: [ + { + kind: "enum", + key: "settings.webgpu.territory.classic.borderMode", + label: "Border Mode", + defaultValue: 1, + options: [ + { value: 0, label: "Off" }, + { value: 1, label: "Simple" }, + { value: 2, label: "Glow" }, + ], + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.thicknessPx", + label: "Thickness (px)", + defaultValue: 1, + min: 0.5, + max: 8, + step: 0.5, + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.borderStrength", + label: "Border Strength", + defaultValue: 0.64, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.glowStrength", + label: "Glow Strength", + defaultValue: 0.42, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.classic.glowRadiusMul", + label: "Glow Radius", + defaultValue: 1, + min: 1, + max: 12, + step: 0.25, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.classic.drawDefendedRadius", + label: "Draw Defended Radius", + defaultValue: false, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.classic.disableDefendedTint", + label: "Disable Defended Tint", + defaultValue: false, + }, + ], + }, + { + id: "retro", + label: "Retro", + wgslPath: "render/retro.wgsl", + options: [ + { + kind: "boolean", + key: "settings.webgpu.territory.retro.colorByRelations", + label: "Color By Player Relations", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.patternWhenDefended", + label: "Pattern When In Defended Range", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.splitBorder", + label: "Split Border", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.drawDefendedRadius", + label: "Draw Defended Radius", + defaultValue: true, + }, + { + kind: "boolean", + key: "settings.webgpu.territory.retro.disableDefendedTint", + label: "Disable Defended Tint", + defaultValue: true, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.thicknessPx", + label: "Thickness (px)", + defaultValue: 6, + min: 0.5, + max: 12, + step: 0.5, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.borderStrength", + label: "Border Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.glowStrength", + label: "Glow Strength", + defaultValue: 0, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.glowRadiusMul", + label: "Glow Radius", + defaultValue: 1, + min: 1, + max: 16, + step: 0.25, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.relationTintStrength", + label: "Relation Tint Strength", + defaultValue: 1, + min: 0, + max: 1, + step: 0.01, + }, + { + kind: "range", + key: "settings.webgpu.territory.retro.defendedPatternStrength", + label: "Defended Pattern Strength", + defaultValue: 0.5, + min: 0, + max: 1, + step: 0.01, + }, + ], + }, +]; + +export function getTerritoryShaderById( + id: TerritoryShaderId, +): TerritoryShaderDefinition { + const found = TERRITORY_SHADERS.find((s) => s.id === id); + if (!found) { + throw new Error(`Unknown territory shader: ${id}`); + } + return found; +} + +export function territoryShaderIdFromInt(value: number): TerritoryShaderId { + return value === 1 ? "retro" : "classic"; +} + +export function territoryShaderIntFromId(id: TerritoryShaderId): number { + return id === "retro" ? 1 : 0; +} + +export function readTerritoryShaderId(userSettings: { + getInt: (key: string, defaultValue: number) => number; +}): TerritoryShaderId { + return territoryShaderIdFromInt(userSettings.getInt(TERRITORY_SHADER_KEY, 1)); +} + +export function buildTerritoryShaderParams( + userSettings: { + get: (key: string, defaultValue: boolean) => boolean; + getFloat: (key: string, defaultValue: number) => number; + getInt: (key: string, defaultValue: number) => number; + }, + shaderId: TerritoryShaderId, +): { shaderPath: string; params0: Float32Array; params1: Float32Array } { + if (shaderId === "retro") { + const thicknessPx = userSettings.getFloat( + "settings.webgpu.territory.retro.thicknessPx", + 6, + ); + const borderStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.borderStrength", + 1, + ); + const glowStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.glowStrength", + 0, + ); + const glowRadiusMul = userSettings.getFloat( + "settings.webgpu.territory.retro.glowRadiusMul", + 1, + ); + + const colorByRelations = userSettings.get( + "settings.webgpu.territory.retro.colorByRelations", + true, + ); + const patternWhenDefended = userSettings.get( + "settings.webgpu.territory.retro.patternWhenDefended", + true, + ); + const splitBorder = userSettings.get( + "settings.webgpu.territory.retro.splitBorder", + true, + ); + const drawDefendedRadius = userSettings.get( + "settings.webgpu.territory.retro.drawDefendedRadius", + true, + ); + const disableDefendedTint = userSettings.get( + "settings.webgpu.territory.retro.disableDefendedTint", + true, + ); + const relationTintStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.relationTintStrength", + 1, + ); + const defendedPatternStrength = userSettings.getFloat( + "settings.webgpu.territory.retro.defendedPatternStrength", + 0.5, + ); + const defendedThreshold = 0.01; + + let flags = 0; + if (colorByRelations) flags |= 1 << 0; + if (patternWhenDefended) flags |= 1 << 1; + if (splitBorder) flags |= 1 << 2; + if (drawDefendedRadius) flags |= 1 << 3; + if (disableDefendedTint) flags |= 1 << 4; + + const params0 = new Float32Array([ + thicknessPx, + borderStrength, + glowStrength, + glowRadiusMul, + ]); + const params1 = new Float32Array([ + flags, + relationTintStrength, + defendedPatternStrength, + defendedThreshold, + ]); + + return { shaderPath: "render/retro.wgsl", params0, params1 }; + } + + const borderMode = userSettings.getInt( + "settings.webgpu.territory.classic.borderMode", + 1, + ); + const thicknessPx = userSettings.getFloat( + "settings.webgpu.territory.classic.thicknessPx", + 1, + ); + const borderStrength = userSettings.getFloat( + "settings.webgpu.territory.classic.borderStrength", + 0.64, + ); + const glowStrength = userSettings.getFloat( + "settings.webgpu.territory.classic.glowStrength", + 0.42, + ); + const glowRadiusMul = userSettings.getFloat( + "settings.webgpu.territory.classic.glowRadiusMul", + 1, + ); + const drawDefendedRadius = userSettings.get( + "settings.webgpu.territory.classic.drawDefendedRadius", + false, + ); + const disableDefendedTint = userSettings.get( + "settings.webgpu.territory.classic.disableDefendedTint", + false, + ); + + const params0 = new Float32Array([ + borderMode, + thicknessPx, + borderStrength, + glowStrength, + ]); + const params1 = new Float32Array([ + glowRadiusMul, + drawDefendedRadius ? 1 : 0, + disableDefendedTint ? 1 : 0, + 0, + ]); + return { shaderPath: "render/territory.wgsl", params0, params1 }; +} diff --git a/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl new file mode 100644 index 0000000000..0cea311a24 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/defended-strength-full.wgsl @@ -0,0 +1,65 @@ +struct Params { + _dirtyCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(3) var ownerOffsets: array; +@group(0) @binding(4) var postsByOwner: array; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let dims = textureDimensions(stateTex); + if (globalId.x >= dims.x || globalId.y >= dims.y) { + return; + } + + let x = i32(globalId.x); + let y = i32(globalId.y); + let state = textureLoad(stateTex, vec2i(x, y), 0).x; + let owner = state & 0xFFFu; + + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); +} + diff --git a/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl b/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl new file mode 100644 index 0000000000..828392ce05 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/defended-strength.wgsl @@ -0,0 +1,69 @@ +struct Params { + dirtyCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var dirtyTiles: array; +@group(0) @binding(2) var stateTex: texture_2d; +@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(4) var ownerOffsets: array; +@group(0) @binding(5) var postsByOwner: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let idx = globalId.x; + if (idx >= p.dirtyCount) { + return; + } + + let tileIndex = dirtyTiles[idx]; + let dims = textureDimensions(stateTex); + let mapWidth = dims.x; + let x = i32(tileIndex % mapWidth); + let y = i32(tileIndex / mapWidth); + + let state = textureLoad(stateTex, vec2i(x, y), 0).x; + let owner = state & 0xFFFu; + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); +} + diff --git a/src/client/graphics/webgpu/shaders/compute/state-update.wgsl b/src/client/graphics/webgpu/shaders/compute/state-update.wgsl new file mode 100644 index 0000000000..dec940fc9d --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/state-update.wgsl @@ -0,0 +1,73 @@ +struct Update { + tileIndex: u32, + newState: u32, +}; + +struct Params { + updateCount: u32, + range: u32, + _pad0: u32, + _pad1: u32, +}; + +@group(0) @binding(0) var p: Params; +@group(0) @binding(1) var updates: array; +@group(0) @binding(2) var stateTex: texture_storage_2d; +@group(0) @binding(3) var defendedStrengthTex: texture_storage_2d; +@group(0) @binding(4) var ownerOffsets: array; +@group(0) @binding(5) var postsByOwner: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let idx = globalId.x; + if (idx >= p.updateCount) { + return; + } + let update = updates[idx]; + let dims = textureDimensions(stateTex); + let mapWidth = dims.x; + let x = i32(update.tileIndex % mapWidth); + let y = i32(update.tileIndex / mapWidth); + textureStore(stateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u)); + + // Update defended strength for this tile based on the new owner. + let owner = update.newState & 0xFFFu; + let range = i32(p.range); + if (owner == 0u || range <= 0) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let off = ownerOffsets[owner]; + let start = off.x; + let count = off.y; + if (count == 0u) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let rx = f32(range); + let r2 = range * range; + var bestDist2: i32 = 0x7FFFFFFF; + var i: u32 = 0u; + loop { + if (i >= count) { break; } + let pos = postsByOwner[start + i]; + let dx = i32(pos.x) - x; + let dy = i32(pos.y) - y; + let d2 = dx * dx + dy * dy; + if (d2 < bestDist2) { + bestDist2 = d2; + } + i = i + 1u; + } + + if (bestDist2 > r2) { + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(0.0, 0.0, 0.0, 1.0)); + return; + } + + let dist = sqrt(f32(bestDist2)); + let strength = clamp(1.0 - (dist / rx), 0.0, 1.0); + textureStore(defendedStrengthTex, vec2i(x, y), vec4f(strength, 0.0, 0.0, 1.0)); +} diff --git a/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl new file mode 100644 index 0000000000..611a2c1a0d --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-heavy.wgsl @@ -0,0 +1,201 @@ +struct TerrainParams { + shoreColor: vec4f, // Shore (land adjacent to water) + waterColor: vec4f, // Deep water base color + shorelineWaterColor: vec4f, // Water near shore + plainsBaseColor: vec4f, // Plains base RGB (magnitude 0) + highlandBaseColor: vec4f, // Highland base RGB (magnitude 10) + mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20) + tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=waterDepthStrength, w=waterDepthCurve + tuning1: vec4f, // x=detailNoise, y=lightingStrength, z=cavityStrength, w=waterDepthBlur +}; + +@group(0) @binding(0) var params: TerrainParams; +@group(0) @binding(1) var terrainDataTex: texture_2d; +@group(0) @binding(2) var terrainTex: texture_storage_2d; + +// Terrain bit constants (matching GameMapImpl) +const IS_LAND_BIT: u32 = 7u; +const SHORELINE_BIT: u32 = 6u; +const MAGNITUDE_MASK: u32 = 0x1fu; + +fn hash21(p: vec2u) -> f32 { + var n = p.x * 0x9e3779b9u + p.y * 0x7f4a7c15u; + n ^= n >> 16u; + n *= 0x85ebca6bu; + n ^= n >> 13u; + n *= 0xc2b2ae35u; + n ^= n >> 16u; + return f32(n & 0x00ffffffu) / 16777215.0; +} + +fn clampCoord(coord: vec2i, dims: vec2u) -> vec2i { + let maxX = i32(dims.x) - 1; + let maxY = i32(dims.y) - 1; + return vec2i(clamp(coord.x, 0, maxX), clamp(coord.y, 0, maxY)); +} + +fn sampleTerrainData(coord: vec2i, dims: vec2u) -> u32 { + let c = clampCoord(coord, dims); + return textureLoad(terrainDataTex, c, 0).x; +} + +fn computeLandColor( + mag: f32, + noise: f32, + noiseStrength: f32, + blendWidth: f32, +) -> vec3f { + let plainsG = max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0); + let plains = vec3f(params.plainsBaseColor.r, plainsG, params.plainsBaseColor.b); + + let highlandMag = clamp(mag - 10.0, 0.0, 9.0); + let highland = vec3f( + min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0), + ); + + let mountainMag = max(mag - 20.0, 0.0); + let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0); + let mountain = vec3f(gray, gray, gray); + + let tHigh = smoothstep(10.0 - blendWidth, 10.0 + blendWidth, mag); + let tMount = smoothstep(20.0 - blendWidth, 20.0 + blendWidth, mag); + var land = mix(plains, highland, tHigh); + land = mix(land, mountain, tMount); + + let noiseBias = (noise - 0.5) * noiseStrength; + return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0)); +} + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let x = i32(globalId.x); + let y = i32(globalId.y); + let dims = textureDimensions(terrainDataTex); + + if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { + return; + } + + let texCoord = vec2i(x, y); + let terrainData = textureLoad(terrainDataTex, texCoord, 0).x; + + let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u; + let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u; + let magnitude = terrainData & MAGNITUDE_MASK; + let mag = f32(magnitude); + + let noise = hash21(vec2u(texCoord)); + let noiseFine = hash21(vec2u(texCoord) * 3u + vec2u(17u, 29u)); + let noiseStrength = max(params.tuning0.x, 0.0); + let blendWidth = max(params.tuning0.y, 0.1); + let waterDepthStrength = clamp(params.tuning0.z, 0.0, 1.0); + let waterDepthCurve = max(params.tuning0.w, 0.1); + let detailNoiseStrength = max(params.tuning1.x, 0.0); + let lightingStrength = clamp(params.tuning1.y, 0.0, 1.0); + let cavityStrength = clamp(params.tuning1.z, 0.0, 1.0); + let waterDepthBlur = clamp(params.tuning1.w, 0.0, 1.0); + let shoreMixLand = 0.6; + let shoreMixWater = 0.55; + let specularStrength = 0.05; + + let hC = mag / 31.0; + let dataL = sampleTerrainData(texCoord + vec2i(-1, 0), dims); + let dataR = sampleTerrainData(texCoord + vec2i(1, 0), dims); + let dataD = sampleTerrainData(texCoord + vec2i(0, -1), dims); + let dataU = sampleTerrainData(texCoord + vec2i(0, 1), dims); + + let magL = f32(dataL & MAGNITUDE_MASK); + let magR = f32(dataR & MAGNITUDE_MASK); + let magD = f32(dataD & MAGNITUDE_MASK); + let magU = f32(dataU & MAGNITUDE_MASK); + + let hL = magL / 31.0; + let hR = magR / 31.0; + let hD = magD / 31.0; + let hU = magU / 31.0; + + let dx = hR - hL; + let dy = hU - hD; + let normal = normalize(vec3f(-dx * 2.2, -dy * 2.2, 1.0)); + let lightDir = normalize(vec3f(0.55, 0.45, 1.0)); + let diffuse = clamp(dot(normal, lightDir), 0.0, 1.0); + let baseLighting = 0.55 + 0.45 * diffuse; + let lighting = mix(1.0, baseLighting, lightingStrength); + + let slope = length(vec2f(dx, dy)); + let rockiness = smoothstep(0.08, 0.28, slope); + + let cavity = clamp(((hL + hR + hD + hU) * 0.25 - hC) * 2.0, 0.0, 0.25); + + var color: vec4f; + + if (isLand) { + var land = computeLandColor(mag, noise, noiseStrength, blendWidth); + + if (isShoreline) { + land = mix(land, params.shoreColor.rgb, shoreMixLand); + } + + land = mix(land, params.mountainBaseColor.rgb, rockiness * 0.6); + + land = clamp(land * lighting, vec3f(0.0), vec3f(1.0)); + land = clamp(land * (1.0 - cavity * cavityStrength), vec3f(0.0), vec3f(1.0)); + land = clamp( + land + vec3f((noiseFine - 0.5) * detailNoiseStrength), + vec3f(0.0), + vec3f(1.0), + ); + + color = vec4f(land, 1.0); + } else { + var sum = mag; + var count = 1.0; + if ((dataL & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + magL; + count = count + 1.0; + } + if ((dataR & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + magR; + count = count + 1.0; + } + if ((dataD & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + magD; + count = count + 1.0; + } + if ((dataU & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + magU; + count = count + 1.0; + } + + let avgMag = sum / count; + let smoothMag = mix(mag, avgMag, waterDepthBlur); + let depth01 = clamp(smoothMag / 10.0, 0.0, 1.0); + let depth = clamp(pow(depth01, waterDepthCurve), 0.0, 1.0); + let depthColor = mix( + params.shorelineWaterColor.rgb, + params.waterColor.rgb, + depth, + ); + var water = mix(params.waterColor.rgb, depthColor, waterDepthStrength); + let noiseBias = (noise - 0.5) * (noiseStrength * 0.6); + water = clamp(water + vec3f(noiseBias), vec3f(0.0), vec3f(1.0)); + + if (isShoreline) { + water = mix(water, params.shorelineWaterColor.rgb, shoreMixWater); + } + + let viewDir = vec3f(0.0, 0.0, 1.0); + let spec = pow(max(dot(reflect(-lightDir, normal), viewDir), 0.0), 24.0); + water = clamp( + water + vec3f(spec * specularStrength), + vec3f(0.0), + vec3f(1.0), + ); + + color = vec4f(water, 1.0); + } + + textureStore(terrainTex, texCoord, color); +} diff --git a/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl new file mode 100644 index 0000000000..95e4dfa5aa --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/terrain-compute-improved-lite.wgsl @@ -0,0 +1,134 @@ +struct TerrainParams { + shoreColor: vec4f, // Shore (land adjacent to water) + waterColor: vec4f, // Deep water base color + shorelineWaterColor: vec4f, // Water near shore + plainsBaseColor: vec4f, // Plains base RGB (magnitude 0) + highlandBaseColor: vec4f, // Highland base RGB (magnitude 10) + mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20) + tuning0: vec4f, // x=noiseStrength, y=blendWidth, z=waterBlurStrength, w=unused +}; + +@group(0) @binding(0) var params: TerrainParams; +@group(0) @binding(1) var terrainDataTex: texture_2d; +@group(0) @binding(2) var terrainTex: texture_storage_2d; + +// Terrain bit constants (matching GameMapImpl) +const IS_LAND_BIT: u32 = 7u; +const SHORELINE_BIT: u32 = 6u; +const MAGNITUDE_MASK: u32 = 0x1fu; + +fn hash21(p: vec2u) -> f32 { + var n = p.x * 0x9e3779b9u + p.y * 0x7f4a7c15u; + n ^= n >> 16u; + n *= 0x85ebca6bu; + n ^= n >> 13u; + n *= 0xc2b2ae35u; + n ^= n >> 16u; + return f32(n & 0x00ffffffu) / 16777215.0; +} + +fn clampCoord(coord: vec2i, dims: vec2u) -> vec2i { + let maxX = i32(dims.x) - 1; + let maxY = i32(dims.y) - 1; + return vec2i(clamp(coord.x, 0, maxX), clamp(coord.y, 0, maxY)); +} + +fn computeLandColor(mag: f32, noise: f32, noiseStrength: f32, blendWidth: f32) -> vec3f { + let plainsG = max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0); + let plains = vec3f(params.plainsBaseColor.r, plainsG, params.plainsBaseColor.b); + + let highlandMag = clamp(mag - 10.0, 0.0, 9.0); + let highland = vec3f( + min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0), + ); + + let mountainMag = max(mag - 20.0, 0.0); + let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0); + let mountain = vec3f(gray, gray, gray); + + let tHigh = smoothstep(10.0 - blendWidth, 10.0 + blendWidth, mag); + let tMount = smoothstep(20.0 - blendWidth, 20.0 + blendWidth, mag); + var land = mix(plains, highland, tHigh); + land = mix(land, mountain, tMount); + + let noiseBias = (noise - 0.5) * noiseStrength; + return clamp(land + vec3f(noiseBias), vec3f(0.0), vec3f(1.0)); +} + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let x = i32(globalId.x); + let y = i32(globalId.y); + let dims = textureDimensions(terrainDataTex); + + if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { + return; + } + + let texCoord = vec2i(x, y); + let terrainData = textureLoad(terrainDataTex, texCoord, 0).x; + + let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u; + let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u; + let magnitude = terrainData & MAGNITUDE_MASK; + let mag = f32(magnitude); + + let noise = hash21(vec2u(texCoord)); + let noiseStrength = max(params.tuning0.x, 0.0); + let blendWidth = max(params.tuning0.y, 0.1); + let waterDepthBlur = clamp(params.tuning0.z, 0.0, 1.0); + let shoreMixLand = 0.6; + var color: vec4f; + + if (isLand) { + var land = computeLandColor(mag, noise, noiseStrength, blendWidth); + if (isShoreline) { + land = mix(land, params.shoreColor.rgb, shoreMixLand); + } + color = vec4f(land, 1.0); + } else { + if (isShoreline) { + color = vec4f(params.shorelineWaterColor.rgb, 1.0); + textureStore(terrainTex, texCoord, color); + return; + } + + var sum = mag; + var count = 1.0; + let dataL = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(-1, 0), dims), 0).x; + if ((dataL & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + f32(dataL & MAGNITUDE_MASK); + count = count + 1.0; + } + let dataR = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(1, 0), dims), 0).x; + if ((dataR & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + f32(dataR & MAGNITUDE_MASK); + count = count + 1.0; + } + let dataD = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(0, -1), dims), 0).x; + if ((dataD & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + f32(dataD & MAGNITUDE_MASK); + count = count + 1.0; + } + let dataU = textureLoad(terrainDataTex, clampCoord(texCoord + vec2i(0, 1), dims), 0).x; + if ((dataU & (1u << IS_LAND_BIT)) == 0u) { + sum = sum + f32(dataU & MAGNITUDE_MASK); + count = count + 1.0; + } + + let avgMag = sum / count; + let smoothMag = mix(mag, avgMag, waterDepthBlur); + let magClamped = min(smoothMag, 10.0); + let adjustment = (1.0 - magClamped) / 255.0; + let water = vec3f( + max(params.waterColor.r + adjustment, 0.0), + max(params.waterColor.g + adjustment, 0.0), + max(params.waterColor.b + adjustment, 0.0), + ); + color = vec4f(water, 1.0); + } + + textureStore(terrainTex, texCoord, color); +} diff --git a/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl b/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl new file mode 100644 index 0000000000..b37ac68de4 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/terrain-compute.wgsl @@ -0,0 +1,104 @@ +struct TerrainParams { + shoreColor: vec4f, // Shore (land adjacent to water) + waterColor: vec4f, // Deep water base color + shorelineWaterColor: vec4f, // Water near shore + plainsBaseColor: vec4f, // Plains base RGB (magnitude 0) + highlandBaseColor: vec4f, // Highland base RGB (magnitude 10) + mountainBaseColor: vec4f, // Mountain base RGB (magnitude 20) + tuning0: vec4f, // Shader tuning params (unused in classic) + tuning1: vec4f, // Shader tuning params (unused in classic) +}; + +@group(0) @binding(0) var params: TerrainParams; +@group(0) @binding(1) var terrainDataTex: texture_2d; +@group(0) @binding(2) var terrainTex: texture_storage_2d; + +// Terrain bit constants (matching GameMapImpl) +const IS_LAND_BIT: u32 = 7u; +const SHORELINE_BIT: u32 = 6u; +const OCEAN_BIT: u32 = 5u; +const MAGNITUDE_MASK: u32 = 0x1fu; + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let x = i32(globalId.x); + let y = i32(globalId.y); + let dims = textureDimensions(terrainDataTex); + + if (x < 0 || y < 0 || u32(x) >= dims.x || u32(y) >= dims.y) { + return; + } + + let texCoord = vec2i(x, y); + let terrainData = textureLoad(terrainDataTex, texCoord, 0).x; + + // Extract terrain bits + let isLand = (terrainData & (1u << IS_LAND_BIT)) != 0u; + let isShoreline = (terrainData & (1u << SHORELINE_BIT)) != 0u; + let isOcean = (terrainData & (1u << OCEAN_BIT)) != 0u; + let magnitude = terrainData & MAGNITUDE_MASK; + let mag = f32(magnitude); + + var color: vec4f; + + // Check if shore (land adjacent to water) + if (isLand && isShoreline) { + color = params.shoreColor; + } else if (!isLand) { + // Water tile + if (isShoreline) { + color = params.shorelineWaterColor; + } else { + // Deep water - color varies by magnitude + // CPU formula: waterColor - 10 + (11 - min(mag, 10)) + // In normalized space: waterColor + (-10 + (11 - min(mag, 10))) / 255.0 + // Simplified: waterColor + (1 - min(mag, 10)) / 255.0 + let magClamped = min(mag, 10.0); + let adjustment = (1.0 - magClamped) / 255.0; + color = vec4f( + max(params.waterColor.r + adjustment, 0.0), + max(params.waterColor.g + adjustment, 0.0), + max(params.waterColor.b + adjustment, 0.0), + 1.0 + ); + } + } else { + // Land tile - determine terrain type from magnitude + // CPU formulas: + // Plains: rgb(190, 220 - 2*mag, 138) for mag 0-9 + // Highland: rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag) for mag 10-19 + // Mountain: rgb(230 + mag/2, 230 + mag/2, 230 + mag/2) for mag >= 20 + // + // We sampled plains at mag 0, so plainsBaseColor = rgb(190, 220, 138) / 255 + // We sampled highland at some mag 10-19, need to compute from mag 10 + if (magnitude < 10u) { + // Plains: rgb(190, 220 - 2*mag, 138) + color = vec4f( + params.plainsBaseColor.r, // 190/255 + max(params.plainsBaseColor.g - (2.0 * mag) / 255.0, 0.0), // (220 - 2*mag)/255 + params.plainsBaseColor.b, // 138/255 + 1.0 + ); + } else if (magnitude < 20u) { + // Highland: CPU formula is rgb(200 + 2*mag, 183 + 2*mag, 138 + 2*mag) + // We sampled highlandBaseColor at mag 10, so it's rgb(220, 203, 158) / 255 + // For any mag 10-19: highlandBaseColor + 2*(mag - 10) / 255 + let highlandMag = mag - 10.0; + color = vec4f( + min(params.highlandBaseColor.r + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.g + (2.0 * highlandMag) / 255.0, 1.0), + min(params.highlandBaseColor.b + (2.0 * highlandMag) / 255.0, 1.0), + 1.0 + ); + } else { + // Mountain: CPU formula is rgb(230 + mag/2, 230 + mag/2, 230 + mag/2) + // We sampled mountainBaseColor at mag 20, so it's rgb(240, 240, 240) / 255 for pastel + // For any mag >= 20: mountainBaseColor + (mag - 20) / 2 / 255 + let mountainMag = mag - 20.0; + let gray = min(params.mountainBaseColor.r + (mountainMag / 2.0) / 255.0, 1.0); + color = vec4f(gray, gray, gray, 1.0); + } + } + + textureStore(terrainTex, texCoord, color); +} diff --git a/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl b/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl new file mode 100644 index 0000000000..3be34cec06 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/compute/visual-state-smoothing.wgsl @@ -0,0 +1,76 @@ +struct Temporal { + nowSec: f32, + lastTickSec: f32, + tickDtSec: f32, + tickDtEmaSec: f32, + tickAlpha: f32, + tickCount: f32, + historyValid: f32, + _pad0: f32, +}; + +struct Params { + params0: vec4f, // x=mode, y=curveExp + params1: vec4f, // x=updateCount +}; + +struct Update { + tileIndex: u32, + newState: u32, +}; + +@group(0) @binding(0) var t: Temporal; +@group(0) @binding(1) var p: Params; +@group(0) @binding(2) var updates: array; +@group(0) @binding(3) var visualStateTex: texture_storage_2d; + +fn hashUint(x: u32) -> u32 { + var h = x * 1664525u + 1013904223u; + h ^= h >> 16u; + h *= 2246822519u; + h ^= h >> 13u; + h *= 3266489917u; + h ^= h >> 16u; + return h; +} + +fn hashToUnitFloat(x: u32) -> f32 { + return f32(x & 0x00FFFFFFu) / 16777216.0; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) globalId: vec3) { + let idx = globalId.x; + let updateCount = u32(max(0.0, p.params1.x) + 0.5); + if (idx >= updateCount) { + return; + } + + let mode = u32(max(0.0, p.params0.x) + 0.5); + let curveExp = max(0.001, p.params0.y); + let alpha = clamp(pow(clamp(t.tickAlpha, 0.0, 1.0), curveExp), 0.0, 1.0); + + let update = updates[idx]; + + if (mode == 1u) { + let tickSeed = u32(max(0.0, t.tickCount) + 0.5); + let h = hashUint(update.tileIndex ^ (tickSeed * 2654435761u)); + let r = hashToUnitFloat(h); + if (r > alpha) { + return; + } + } else if (mode == 2u) { + let targetCount = u32(floor(f32(updateCount) * alpha)); + if (idx >= targetCount) { + return; + } + } else { + return; + } + + let dims = textureDimensions(visualStateTex); + let mapWidth = dims.x; + let x = i32(update.tileIndex % mapWidth); + let y = i32(update.tileIndex / mapWidth); + textureStore(visualStateTex, vec2i(x, y), vec4u(update.newState, 0u, 0u, 0u)); +} diff --git a/src/client/graphics/webgpu/shaders/render/retro.wgsl b/src/client/graphics/webgpu/shaders/render/retro.wgsl new file mode 100644 index 0000000000..ad257d504e --- /dev/null +++ b/src/client/graphics/webgpu/shaders/render/retro.wgsl @@ -0,0 +1,303 @@ +struct Uniforms { + mapResolution_viewScale_time: vec4f, // x=mapW, y=mapH, z=viewScale, w=timeSec + viewOffset_alt_highlight: vec4f, // x=offX, y=offY, z=alternativeView, w=highlightOwnerId + viewSize_pad: vec4f, // x=viewW, y=viewH, z=myPlayerSmallId, w unused + shaderParams0: vec4f, // x=thicknessPx, y=borderStrength, z=glowStrength, w=glowRadiusMul + shaderParams1: vec4f, // x=flags, y=relationTintStrength, z=defendedPatternStrength, w=defendedThreshold +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_2d; +@group(0) @binding(3) var paletteTex: texture_2d; +@group(0) @binding(4) var terrainTex: texture_2d; +@group(0) @binding(5) var ownerIndexTex: texture_2d; +@group(0) @binding(6) var relationsTex: texture_2d; + +@vertex +fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { + var pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + let p = pos[vi]; + return vec4f(p, 0.0, 1.0); +} + +fn hasFlag(flags: u32, bit: u32) -> bool { + return (flags & (1u << bit)) != 0u; +} + +fn relationCode(ownerA: u32, ownerB: u32) -> u32 { + if (ownerA == 0u || ownerB == 0u) { + return 0u; + } + let aDense = textureLoad(ownerIndexTex, vec2i(i32(ownerA), 0), 0).x; + let bDense = textureLoad(ownerIndexTex, vec2i(i32(ownerB), 0), 0).x; + if (aDense == 0u || bDense == 0u) { + return 0u; + } + return textureLoad(relationsTex, vec2i(i32(aDense), i32(bDense)), 0).x; +} + +fn applyDefendedPattern( + baseRgb: vec3f, + strength: f32, + texCoord: vec2i, +) -> vec3f { + let parity = (u32(texCoord.x) ^ u32(texCoord.y)) & 1u; + let factor = select(0.75, 1.25, parity == 1u); + let patterned = clamp(baseRgb * factor, vec3f(0.0), vec3f(1.0)); + return mix(baseRgb, patterned, clamp(strength, 0.0, 1.0)); +} + +@fragment +fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { + let mapRes = u.mapResolution_viewScale_time.xy; + let viewScale = u.mapResolution_viewScale_time.z; + let timeSec = u.mapResolution_viewScale_time.w; + let viewOffset = u.viewOffset_alt_highlight.xy; + let altView = u.viewOffset_alt_highlight.z; + let highlightId = u.viewOffset_alt_highlight.w; + let myPlayerSmallId = u.viewSize_pad.z; + + let thicknessPx = u.shaderParams0.x; + let borderStrength = u.shaderParams0.y; + let glowStrength = u.shaderParams0.z; + let glowRadiusMul = u.shaderParams0.w; + + let flags = u32(max(0.0, u.shaderParams1.x) + 0.5); + let relationTintStrength = u.shaderParams1.y; + let defendedPatternStrength = u.shaderParams1.z; + let defendedThreshold = u.shaderParams1.w; + + let enableRelations = hasFlag(flags, 0u); + let enableDefendedPattern = hasFlag(flags, 1u); + let enableSplit = hasFlag(flags, 2u); + let drawDefendedRadius = hasFlag(flags, 3u); + let disableDefendedTint = hasFlag(flags, 4u); + + // WebGPU fragment position is top-left origin and at pixel centers (0.5, 1.5, ...). + let viewCoord = vec2f(pos.x - 0.5, pos.y - 0.5); + let mapHalf = mapRes * 0.5; + let mapCoord = (viewCoord - mapHalf) / viewScale + viewOffset + mapHalf; + + if ( + mapCoord.x < 0.0 || + mapCoord.y < 0.0 || + mapCoord.x >= mapRes.x || + mapCoord.y >= mapRes.y + ) { + discard; + } + + let texCoord = vec2i(mapCoord); + let state = textureLoad(stateTex, texCoord, 0).x; + let owner = state & 0xFFFu; + let hasFallout = (state & 0x2000u) != 0u; + + let terrain = textureLoad(terrainTex, texCoord, 0); + let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x; + + var outColor = terrain; + if (owner != 0u) { + // Player colors start at index 10 + let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0); + var territoryRgb = c.rgb; + if (!disableDefendedTint) { + let defendedTint = select( + 0.0, + clamp(0.8 * defendedStrength, 0.1, 0.35), + defendedStrength > 0.001, + ); + territoryRgb = mix( + territoryRgb, + vec3f(1.0, 0.0, 1.0), + defendedTint, + ); + } + if (hasFallout) { + // Fallout color is at index 0 + let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; + territoryRgb = mix(territoryRgb, falloutColor, 0.5); + } + outColor = vec4f(mix(terrain.rgb, territoryRgb, 0.65), 1.0); + } else if (hasFallout) { + let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; + outColor = vec4f(mix(terrain.rgb, falloutColor, 0.5), 1.0); + } + + // In alt view we show only borders on top of terrain. + if (altView > 0.5) { + outColor = terrain; + } + + if (owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var bestDist = 1e9; + var otherOwner = 0u; + var otherCoord = texCoord; + + // Only border against other non-zero owners. + if (texCoord.x > 0) { + let o = textureLoad(stateTex, texCoord + vec2i(-1, 0), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = fx; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(-1, 0); + } + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let o = textureLoad(stateTex, texCoord + vec2i(1, 0), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = 1.0 - fx; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(1, 0); + } + } + } + if (texCoord.y > 0) { + let o = textureLoad(stateTex, texCoord + vec2i(0, -1), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = fy; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(0, -1); + } + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let o = textureLoad(stateTex, texCoord + vec2i(0, 1), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + let d = 1.0 - fy; + if (d < bestDist) { + bestDist = d; + otherOwner = o; + otherCoord = texCoord + vec2i(0, 1); + } + } + } + + if (otherOwner != 0u) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + let thicknessTiles = max(0.1, thicknessPx) / pxPerTile; + + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, bestDist); + let glowTiles = (max(0.1, thicknessPx) * max(0.1, glowRadiusMul)) / pxPerTile; + let glow = 1.0 - smoothstep(glowTiles, glowTiles + aaTiles * 3.0, bestDist); + + var baseBorderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb; + + if (!enableSplit) { + let otherBorderRgb = textureLoad(paletteTex, vec2i(i32(otherOwner) + 10, 1), 0).rgb; + baseBorderRgb = 0.5 * (baseBorderRgb + otherBorderRgb); + } + + var edgeDefendedStrength = defendedStrength; + if (!enableSplit) { + let otherDef = textureLoad(defendedStrengthTex, otherCoord, 0).x; + edgeDefendedStrength = max(edgeDefendedStrength, otherDef); + } + + // Determine relation color (normal: between owners, altView: relation to viewer). + var rel = 0u; + if (enableRelations) { + if (altView > 0.5) { + rel = relationCode(owner, u32(max(0.0, myPlayerSmallId) + 0.5)); + } else { + rel = relationCode(owner, otherOwner); + } + } + + var borderRgb = baseBorderRgb; + if (rel != 0u) { + let tintTarget = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), rel == 2u); + let tint = clamp(0.35 * relationTintStrength, 0.0, 1.0); + borderRgb = mix(borderRgb, tintTarget, tint); + } + + if (enableDefendedPattern && edgeDefendedStrength >= defendedThreshold) { + borderRgb = applyDefendedPattern(borderRgb, defendedPatternStrength, texCoord); + } + + outColor = vec4f( + mix(outColor.rgb, borderRgb, clamp(line * borderStrength, 0.0, 1.0)), + outColor.a, + ); + outColor = vec4f( + mix(outColor.rgb, borderRgb, clamp(glow * glowStrength, 0.0, 1.0)), + outColor.a, + ); + } + } + + if (drawDefendedRadius && defendedStrength > 0.001 && owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var dist = 1e9; + + if (texCoord.x > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(-1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, fx); + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fx); + } + } + if (texCoord.y > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, -1), 0).x; + if (s <= 0.001) { + dist = min(dist, fy); + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, 1), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fy); + } + } + + if (dist < 1e8) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + let thicknessTiles = 1.5 / pxPerTile; + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist); + + let baseBorderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb; + let ringRgb = mix(baseBorderRgb, vec3f(1.0, 1.0, 1.0), 0.5); + outColor = vec4f( + mix(outColor.rgb, ringRgb, clamp(line * 0.65, 0.0, 1.0)), + outColor.a, + ); + } + } + + // Apply hover highlight if needed + if (highlightId > 0.5) { + let alpha = select(0.65, 0.0, altView > 0.5); + + if (alpha > 0.0 && owner != 0u && abs(f32(owner) - highlightId) < 0.5) { + let pulse = 0.5 + 0.5 * sin(timeSec * 6.2831853); + let strength = 0.15 + 0.15 * pulse; + let highlightedRgb = mix(outColor.rgb, vec3f(1.0, 1.0, 1.0), strength); + outColor = vec4f(highlightedRgb, outColor.a); + } + } + + return outColor; +} diff --git a/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl b/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl new file mode 100644 index 0000000000..e4cd48dbee --- /dev/null +++ b/src/client/graphics/webgpu/shaders/render/temporal-resolve.wgsl @@ -0,0 +1,81 @@ +struct Temporal { + nowSec: f32, + lastTickSec: f32, + tickDtSec: f32, + tickDtEmaSec: f32, + tickAlpha: f32, + tickCount: f32, + historyValid: f32, + _pad0: f32, +}; + +struct Params { + params0: vec4f, // x=mode, y=blendStrength, z=dissolveWidth +}; + +@group(0) @binding(0) var t: Temporal; +@group(0) @binding(1) var p: Params; +@group(0) @binding(2) var currentTex: texture_2d; +@group(0) @binding(3) var historyTex: texture_2d; + +struct FragOutput { + @location(0) color: vec4f, + @location(1) history: vec4f, +}; + +@vertex +fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { + var pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + let p = pos[vi]; + return vec4f(p, 0.0, 1.0); +} + +fn hashUint(x: u32) -> u32 { + var h = x * 1664525u + 1013904223u; + h ^= h >> 16u; + h *= 2246822519u; + h ^= h >> 13u; + h *= 3266489917u; + h ^= h >> 16u; + return h; +} + +fn hashToUnitFloat(x: u32) -> f32 { + return f32(x & 0x00FFFFFFu) / 16777216.0; +} + +@fragment +fn fsMain(@builtin(position) pos: vec4f) -> FragOutput { + let texCoord = vec2i(pos.xy); + let curr = textureLoad(currentTex, texCoord, 0); + let hist = textureLoad(historyTex, texCoord, 0); + + let mode = u32(max(0.0, p.params0.x) + 0.5); + let strength = clamp(p.params0.y, 0.0, 1.0); + let width = max(0.001, p.params0.z); + + var alpha = clamp(t.tickAlpha * strength, 0.0, 1.0); + if (t.historyValid < 0.5) { + alpha = 1.0; + } + + if (mode == 1u) { + let outColor = mix(hist, curr, alpha); + return FragOutput(outColor, outColor); + } + + if (mode == 2u) { + let seed = (u32(texCoord.x) * 73856093u) ^ (u32(texCoord.y) * 19349663u); + let tickSeed = u32(max(0.0, t.tickCount) + 0.5); + let r = hashToUnitFloat(hashUint(seed ^ (tickSeed * 2654435761u))); + let mask = smoothstep(alpha - width, alpha + width, r); + let outColor = mix(hist, curr, mask); + return FragOutput(outColor, outColor); + } + + return FragOutput(curr, curr); +} diff --git a/src/client/graphics/webgpu/shaders/render/territory.wgsl b/src/client/graphics/webgpu/shaders/render/territory.wgsl new file mode 100644 index 0000000000..a234c5a631 --- /dev/null +++ b/src/client/graphics/webgpu/shaders/render/territory.wgsl @@ -0,0 +1,218 @@ +struct Uniforms { + mapResolution_viewScale_time: vec4f, // x=mapW, y=mapH, z=viewScale, w=timeSec + viewOffset_alt_highlight: vec4f, // x=offX, y=offY, z=alternativeView, w=highlightOwnerId + viewSize_pad: vec4f, // x=viewW, y=viewH, z=myPlayerSmallId, w unused + shaderParams0: vec4f, + shaderParams1: vec4f, +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var stateTex: texture_2d; +@group(0) @binding(2) var defendedStrengthTex: texture_2d; +@group(0) @binding(3) var paletteTex: texture_2d; +@group(0) @binding(4) var terrainTex: texture_2d; +@group(0) @binding(5) var ownerIndexTex: texture_2d; +@group(0) @binding(6) var relationsTex: texture_2d; + +@vertex +fn vsMain(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f { + var pos = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + let p = pos[vi]; + return vec4f(p, 0.0, 1.0); +} + +@fragment +fn fsMain(@builtin(position) pos: vec4f) -> @location(0) vec4f { + let mapRes = u.mapResolution_viewScale_time.xy; + let viewScale = u.mapResolution_viewScale_time.z; + let timeSec = u.mapResolution_viewScale_time.w; + let viewOffset = u.viewOffset_alt_highlight.xy; + let altView = u.viewOffset_alt_highlight.z; + let highlightId = u.viewOffset_alt_highlight.w; + let viewSize = u.viewSize_pad.xy; + let borderMode = u.shaderParams0.x; + let thicknessPx = u.shaderParams0.y; + let borderStrength = u.shaderParams0.z; + let glowStrength = u.shaderParams0.w; + let glowRadiusMul = u.shaderParams1.x; + let drawDefendedRadius = u.shaderParams1.y; + let disableDefendedTint = u.shaderParams1.z; + + // WebGPU fragment position is top-left origin and at pixel centers (0.5, 1.5, ...). + let viewCoord = vec2f(pos.x - 0.5, pos.y - 0.5); + let mapHalf = mapRes * 0.5; + // Match TransformHandler.screenToWorldCoordinates formula: + // gameX = (canvasX - game.width() / 2) / scale + offsetX + game.width() / 2 + let mapCoord = (viewCoord - mapHalf) / viewScale + viewOffset + mapHalf; + + if (mapCoord.x < 0.0 || mapCoord.y < 0.0 || mapCoord.x >= mapRes.x || mapCoord.y >= mapRes.y) { + discard; + } + + let texCoord = vec2i(mapCoord); + let state = textureLoad(stateTex, texCoord, 0).x; + let owner = state & 0xFFFu; + let hasFallout = (state & 0x2000u) != 0u; + + let terrain = textureLoad(terrainTex, texCoord, 0); + let defendedStrength = textureLoad(defendedStrengthTex, texCoord, 0).x; + var outColor = terrain; + if (owner != 0u) { + // Player colors start at index 10 + let c = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0); + var territoryRgb = c.rgb; + if (disableDefendedTint <= 0.5) { + let defendedTint = select( + 0.0, + clamp(0.8 * defendedStrength, 0.1, 0.35), + defendedStrength > 0.001, + ); + territoryRgb = mix( + territoryRgb, + vec3f(1.0, 0.0, 1.0), + defendedTint, + ); + } + if (hasFallout) { + // Fallout color is at index 0 + let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; + territoryRgb = mix(territoryRgb, falloutColor, 0.5); + } + outColor = vec4f(mix(terrain.rgb, territoryRgb, 0.65), 1.0); + } else if (hasFallout) { + // Fallout color is at index 0 + let falloutColor = textureLoad(paletteTex, vec2i(0, 0), 0).rgb; + outColor = vec4f(mix(terrain.rgb, falloutColor, 0.5), 1.0); + } + + // Apply alternative view (hide territory by showing terrain only) + if (altView > 0.5 && owner != 0u) { + outColor = terrain; + } + + // Borders (purely visual): render a stable-pixel-width line at ownership edges. + if (borderMode > 0.5 && altView <= 0.5 && owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var dist = 1e9; + + // Only border against other non-zero owners. + if (texCoord.x > 0) { + let o = textureLoad(stateTex, texCoord + vec2i(-1, 0), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + dist = min(dist, fx); + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let o = textureLoad(stateTex, texCoord + vec2i(1, 0), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + dist = min(dist, 1.0 - fx); + } + } + if (texCoord.y > 0) { + let o = textureLoad(stateTex, texCoord + vec2i(0, -1), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + dist = min(dist, fy); + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let o = textureLoad(stateTex, texCoord + vec2i(0, 1), 0).x & 0xFFFu; + if (o != 0u && o != owner) { + dist = min(dist, 1.0 - fy); + } + } + + if (dist < 1e8) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + + // Mode 1: thin black border. + // Mode 2: thicker black border + obvious tinted glow. + let isGlow = borderMode > 1.5; + let thicknessTiles = thicknessPx / pxPerTile; + + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist); + outColor = vec4f( + mix(outColor.rgb, vec3f(0.0, 0.0, 0.0), clamp(line * borderStrength, 0.0, 1.0)), + outColor.a, + ); + + if (isGlow) { + let glowTiles = (thicknessPx * glowRadiusMul) / pxPerTile; + let glow = 1.0 - smoothstep(glowTiles, glowTiles + aaTiles * 3.0, dist); + let ownerRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 0), 0).rgb; + let glowColor = mix(vec3f(1.0, 1.0, 1.0), ownerRgb, 0.85); + outColor = vec4f( + mix(outColor.rgb, glowColor, clamp(glow * glowStrength, 0.0, 1.0)), + outColor.a, + ); + } + } + } + + // Debug: defended radius boundary (based on defendedStrengthTex coverage). + if (drawDefendedRadius > 0.5 && defendedStrength > 0.001 && owner != 0u) { + let fx = fract(mapCoord.x); + let fy = fract(mapCoord.y); + + var dist = 1e9; + + if (texCoord.x > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(-1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, fx); + } + } + if (texCoord.x + 1 < i32(mapRes.x)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(1, 0), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fx); + } + } + if (texCoord.y > 0) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, -1), 0).x; + if (s <= 0.001) { + dist = min(dist, fy); + } + } + if (texCoord.y + 1 < i32(mapRes.y)) { + let s = textureLoad(defendedStrengthTex, texCoord + vec2i(0, 1), 0).x; + if (s <= 0.001) { + dist = min(dist, 1.0 - fy); + } + } + + if (dist < 1e8) { + let pxPerTile = max(viewScale, 0.001); + let aaTiles = 1.0 / pxPerTile; + let thicknessTiles = 1.5 / pxPerTile; + let line = 1.0 - smoothstep(thicknessTiles, thicknessTiles + aaTiles, dist); + + let borderRgb = textureLoad(paletteTex, vec2i(i32(owner) + 10, 1), 0).rgb; + let ringRgb = mix(borderRgb, vec3f(1.0, 1.0, 1.0), 0.5); + outColor = vec4f( + mix(outColor.rgb, ringRgb, clamp(line * 0.65, 0.0, 1.0)), + outColor.a, + ); + } + } + + // Apply hover highlight if needed + if (highlightId > 0.5) { + let alpha = select(0.65, 0.0, altView > 0.5); + + if (alpha > 0.0 && owner != 0u && abs(f32(owner) - highlightId) < 0.5) { + let pulse = 0.5 + 0.5 * sin(timeSec * 6.2831853); + let strength = 0.15 + 0.15 * pulse; + let highlightedRgb = mix(outColor.rgb, vec3f(1.0, 1.0, 1.0), strength); + outColor = vec4f(highlightedRgb, outColor.a); + } + } + + return outColor; +} diff --git a/src/client/vite-env.d.ts b/src/client/vite-env.d.ts index d00e00e09b..5f9a79e8a4 100644 --- a/src/client/vite-env.d.ts +++ b/src/client/vite-env.d.ts @@ -34,3 +34,13 @@ declare module "*.webp" { const webpContent: string; export default webpContent; } + +declare module "*.svg?url" { + const svgUrl: string; + export default svgUrl; +} + +declare module "*.wgsl?raw" { + const content: string; + export default content; +} diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index ae6196294f..031a1d9cac 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -83,6 +83,7 @@ export const PackSchema = CosmeticSchema.extend({ displayName: z.string(), currency: z.enum(["hard", "soft"]), amount: z.number().int().positive(), + bonusAmount: z.number().int().nonnegative(), }); // Schema for resources/cosmetics/cosmetics.json diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index e0cacf57ba..6b7e08c2d6 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -205,6 +205,7 @@ export interface Theme { allyColor(): Colord; neutralColor(): Colord; enemyColor(): Colord; + playerHighlightColor(): Colord; spawnHighlightColor(): Colord; spawnHighlightSelfColor(): Colord; spawnHighlightTeamColor(): Colord; diff --git a/src/core/configuration/PastelTheme.ts b/src/core/configuration/PastelTheme.ts index 23ae4e6532..354ca3dc8f 100644 --- a/src/core/configuration/PastelTheme.ts +++ b/src/core/configuration/PastelTheme.ts @@ -35,6 +35,8 @@ export class PastelTheme implements Theme { /** Alternate View colors for enemies, red */ private _enemyColor = colord("rgb(255,0,0)"); + /** Hover highlight color for player territories */ + private _playerHighlightColor = colord("rgb(221, 221, 221)"); /** Default spawn highlight colors for other players in FFA, yellow */ private _spawnHighlightColor = colord("rgb(255,213,79)"); /** Added non-default spawn highlight colors for self, full white */ @@ -209,6 +211,9 @@ export class PastelTheme implements Theme { enemyColor(): Colord { return this._enemyColor; } + playerHighlightColor(): Colord { + return this._playerHighlightColor; + } spawnHighlightColor(): Colord { return this._spawnHighlightColor; diff --git a/src/core/configuration/PastelThemeDark.ts b/src/core/configuration/PastelThemeDark.ts index 2cff806857..d840f0fb6e 100644 --- a/src/core/configuration/PastelThemeDark.ts +++ b/src/core/configuration/PastelThemeDark.ts @@ -8,6 +8,7 @@ export class PastelThemeDark extends PastelTheme { private darkWater = colord("rgb(14,11,30)"); private darkShorelineWater = colord("rgb(50,50,50)"); + private darkPlayerHighlight = colord("rgb(99, 42, 42)"); // | Terrain Type | Magnitude | Base Color Logic | Visual Description | // | :---------------- | :-------- | :---------------------------------------------- | :-------------------- | @@ -59,4 +60,8 @@ export class PastelThemeDark extends PastelTheme { }); } } + + playerHighlightColor(): Colord { + return this.darkPlayerHighlight; + } } diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 6e3e779f48..69c904de9c 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -10,6 +10,7 @@ import { UnitType, } from "../game/Game"; import { TileRef } from "../game/GameMap"; +import type { MotionPlanRecord } from "../game/MotionPlans"; import { UniversalPathFinding } from "../pathfinding/PathFinder"; import { ParabolaUniversalPathFinder } from "../pathfinding/PathFinder.Parabola"; import { PathStatus } from "../pathfinding/types"; @@ -188,6 +189,18 @@ export class NukeExecution implements Execution { targetTile: this.dst, trajectory: this.getTrajectory(this.dst), }); + const motionPlan: MotionPlanRecord = { + kind: "parabola", + unitId: this.nuke.id(), + planId: 1, + startTick: ticks + 1 + this.waitTicks, + src: spawn, + dst: this.dst, + increment: this.speed, + distanceBasedHeight: this.nukeType !== UnitType.MIRVWarhead, + directionUp: this.rocketDirectionUp, + }; + this.mg.recordMotionPlan(motionPlan); if (this.nuke.type() !== UnitType.MIRVWarhead) { this.maybeBreakAlliances(); } diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 5c9d4b484e..fcd968cf7d 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -130,18 +130,16 @@ export class TradeShipExecution implements Execution { if (dst !== this.motionPlanDst) { this.motionPlanId++; const from = result.node; - const path = this.pathFinder.findPath(from, dst) ?? [from]; - if (path.length === 0 || path[0] !== from) { - path.unshift(from); - } + const segPlan = this.safeSegmentPlan(from, dst); this.mg.recordMotionPlan({ - kind: "grid", + kind: "grid_segments", unitId: this.tradeShip.id(), planId: this.motionPlanId, startTick: ticks + 1, ticksPerStep: 1, - path, + points: segPlan.points, + segmentSteps: segPlan.segmentSteps, }); this.motionPlanDst = dst; } @@ -230,4 +228,23 @@ export class TradeShipExecution implements Execution { dstPort(): TileRef { return this._dstPort.tile(); } + + private safeSegmentPlan(from: TileRef, to: TileRef): { + points: Uint32Array; + segmentSteps: Uint32Array; + } { + const segPlan = this.pathFinder.planSegments?.(from, to); + if (segPlan) { + return segPlan; + } + + const map = this.mg.map(); + console.warn( + `TradeShipExecution: missing segment plan from (${map.x(from)},${map.y(from)}) to (${map.x(to)},${map.y(to)}); using defensive single-point fallback`, + ); + return { + points: Uint32Array.from([from]), + segmentSteps: new Uint32Array(0), + }; + } } diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 77ad21327e..3a2cd6bf02 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -128,18 +128,16 @@ export class TransportShipExecution implements Execution { targetTile: this.dst, }); - const fullPath = this.pathFinder.findPath(this.src, this.dst) ?? [this.src]; - if (fullPath.length === 0 || fullPath[0] !== this.src) { - fullPath.unshift(this.src); - } + const segPlan = this.safeSegmentPlan(this.src, this.dst); const motionPlan: MotionPlanRecord = { - kind: "grid", + kind: "grid_segments", unitId: this.boat.id(), planId: this.motionPlanId, startTick: ticks + this.ticksPerMove, ticksPerStep: this.ticksPerMove, - path: fullPath, + points: segPlan.points, + segmentSteps: segPlan.segmentSteps, }; this.mg.recordMotionPlan(motionPlan); this.motionPlanDst = this.dst; @@ -294,20 +292,17 @@ export class TransportShipExecution implements Execution { if (this.dst !== null && this.dst !== this.motionPlanDst) { this.motionPlanId++; - const fullPath = this.pathFinder.findPath(this.boat.tile(), this.dst) ?? [ - this.boat.tile(), - ]; - if (fullPath.length === 0 || fullPath[0] !== this.boat.tile()) { - fullPath.unshift(this.boat.tile()); - } + const from = this.boat.tile(); + const segPlan = this.safeSegmentPlan(from, this.dst); this.mg.recordMotionPlan({ - kind: "grid", + kind: "grid_segments", unitId: this.boat.id(), planId: this.motionPlanId, startTick: ticks + this.ticksPerMove, ticksPerStep: this.ticksPerMove, - path: fullPath, + points: segPlan.points, + segmentSteps: segPlan.segmentSteps, }); this.motionPlanDst = this.dst; } @@ -329,4 +324,23 @@ export class TransportShipExecution implements Execution { request.reject(); } } + + private safeSegmentPlan(from: TileRef, to: TileRef): { + points: Uint32Array; + segmentSteps: Uint32Array; + } { + const segPlan = this.pathFinder.planSegments?.(from, to); + if (segPlan) { + return segPlan; + } + + const map = this.mg.map(); + console.warn( + `TransportShipExecution: missing segment plan from (${map.x(from)},${map.y(from)}) to (${map.x(to)},${map.y(to)}); using defensive single-point fallback`, + ); + return { + points: Uint32Array.from([from]), + segmentSteps: new Uint32Array(0), + }; + } } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index a9e914e77e..816ba4e3b0 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -483,7 +483,10 @@ export class GameImpl implements Game { recordMotionPlan(record: MotionPlanRecord): void { switch (record.kind) { - case "grid": + case "grid_segments": + this.planDrivenUnitIds.add(record.unitId); + break; + case "parabola": this.planDrivenUnitIds.add(record.unitId); break; case "train": @@ -692,6 +695,7 @@ export class GameImpl implements Game { owner._lastTileChange = this._ticks; this.updateBorders(tile); this._map.setFallout(tile, false); + this.updateDefendedStateForTileChange(tile, owner); this.recordTileUpdate(tile); } @@ -710,6 +714,9 @@ export class GameImpl implements Game { this._map.setOwnerID(tile, 0); this.updateBorders(tile); + if (this._map.isDefended(tile)) { + this._map.setDefended(tile, false); + } this.recordTileUpdate(tile); } @@ -958,7 +965,6 @@ export class GameImpl implements Game { playerID: id, }); } - addUnit(u: Unit) { this.unitGrid.addUnit(u); this._unitMap.set(u.id(), u); @@ -972,9 +978,18 @@ export class GameImpl implements Game { } } updateUnitTile(u: Unit) { + if (u.type() === UnitType.DefensePost) { + this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl); + } this.unitGrid.updateUnitCell(u); } + refreshDefensePostDefendedState(u: Unit) { + if (u.type() === UnitType.DefensePost) { + this.updateDefendedStateForDefensePost(u.tile(), u.owner() as PlayerImpl); + } + } + hasUnitNearby( tile: TileRef, searchRange: number, @@ -1097,6 +1112,12 @@ export class GameImpl implements Game { hasFallout(ref: TileRef): boolean { return this._map.hasFallout(ref); } + isDefended(ref: TileRef): boolean { + return this._map.isDefended(ref); + } + setDefended(ref: TileRef, value: boolean): void { + this._map.setDefended(ref, value); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } @@ -1155,6 +1176,12 @@ export class GameImpl implements Game { updateTile(tile: TileRef, state: number): boolean { return this._map.updateTile(tile, state); } + tileStateView(): Uint16Array { + return this._map.tileStateView(); + } + terrainDataView(): Uint8Array { + return this._map.terrainDataView(); + } numTilesWithFallout(): number { return this._map.numTilesWithFallout(); } @@ -1243,6 +1270,49 @@ export class GameImpl implements Game { gold: goldCaptured, }); } + + private updateDefendedStateForDefensePost( + center: TileRef, + owner: PlayerImpl, + ) { + const range = this.config().defensePostRange(); + const rangeSq = range * range; + + for (const tile of owner._borderTiles) { + if (this._map.euclideanDistSquared(center, tile) <= rangeSq) { + const wasDefended = this._map.isDefended(tile); + const isDefended = this.unitGrid.hasUnitNearby( + tile, + range, + UnitType.DefensePost, + owner.id(), + ); + if (wasDefended !== isDefended) { + this._map.setDefended(tile, isDefended); + this.recordTileUpdate(tile); + } + } + } + } + + private updateDefendedStateForTileChange(tile: TileRef, owner: PlayerImpl) { + const wasDefended = this._map.isDefended(tile); + const isDefended = this.unitGrid.hasUnitNearby( + tile, + this.config().defensePostRange(), + UnitType.DefensePost, + owner.id(), + ); + if (wasDefended !== isDefended) { + this._map.setDefended(tile, isDefended); + } + + if ( + this.unitGrid.hasUnitNearby(tile, 0, UnitType.DefensePost, owner.id()) + ) { + this.updateDefendedStateForDefensePost(tile, owner); + } + } } // Or a more dynamic approach that will catch new enum values: diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 592d02ca40..6185c38921 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -33,6 +33,10 @@ export interface GameMap { setOwnerID(ref: TileRef, playerId: number): void; hasFallout(ref: TileRef): boolean; setFallout(ref: TileRef, value: boolean): void; + isDefended(ref: TileRef): boolean; + setDefended(ref: TileRef, value: boolean): void; + tileStateView(): Uint16Array; + terrainDataView(): Uint8Array; isOnEdgeOfMap(ref: TileRef): boolean; isBorder(ref: TileRef): boolean; neighbors(ref: TileRef): TileRef[]; @@ -96,6 +100,7 @@ export class GameMapImpl implements GameMap { // State bits (Uint16Array) private static readonly PLAYER_ID_MASK = 0xfff; + private static readonly DEFENDED_BIT = 12; private static readonly FALLOUT_BIT = 13; private static readonly DEFENSE_BONUS_BIT = 14; // Bit 15 still reserved @@ -266,6 +271,26 @@ export class GameMapImpl implements GameMap { } } + isDefended(ref: TileRef): boolean { + return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENDED_BIT)); + } + + setDefended(ref: TileRef, value: boolean): void { + if (value) { + this.state[ref] |= 1 << GameMapImpl.DEFENDED_BIT; + } else { + this.state[ref] &= ~(1 << GameMapImpl.DEFENDED_BIT); + } + } + + tileStateView(): Uint16Array { + return this.state; + } + + terrainDataView(): Uint8Array { + return this.terrain; + } + isOnEdgeOfMap(ref: TileRef): boolean { const x = this.x(ref); const y = this.y(ref); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index a924abd973..98658dfd20 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -2,6 +2,8 @@ import { Colord, colord } from "colord"; import { base64url } from "jose"; import { Config } from "../configuration/Config"; import { ColorPalette } from "../CosmeticSchemas"; +import { UniversalPathFinding } from "../pathfinding/PathFinder"; +import { PathStatus } from "../pathfinding/types"; import { PatternDecoder } from "../PatternDecoder"; import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas"; import { createRandomName, formatPlayerDisplayName } from "../Util"; @@ -664,11 +666,18 @@ type TrainPlanState = { export class GameView implements GameMap { private lastUpdate: GameUpdateViewData | null; + private _lastUpdateAtMs = performance.now(); + private _tickDtEmaMs = 100; private smallIDToID = new Map(); private _players = new Map(); private _units = new Map(); private updatedTiles: TileRef[] = []; private updatedTerrainTiles: TileRef[] = []; + private updatedOwnerChanges: Array<{ + tile: TileRef; + previousOwner: number; + newOwner: number; + }> = []; private _myPlayer: PlayerView | null = null; @@ -679,7 +688,10 @@ export class GameView implements GameMap { planId: number; startTick: number; ticksPerStep: number; - path: Uint32Array; + points: Uint32Array; + segmentSteps: Uint32Array; + segCumSteps: Uint32Array; + lastSegIdx: number; } >(); private trainMotionPlans = new Map(); @@ -729,7 +741,10 @@ export class GameView implements GameMap { planId: number; startTick: number; ticksPerStep: number; - path: Uint32Array; + points: Uint32Array; + segmentSteps: Uint32Array; + segCumSteps: Uint32Array; + lastSegIdx: number; } > { return this.unitMotionPlans; @@ -772,7 +787,24 @@ export class GameView implements GameMap { return (this.lastUpdate?.pendingTurns ?? 0) > 1; } + public lastUpdateAtMs(): number { + return this._lastUpdateAtMs; + } + + public tickDtEmaMs(): number { + return this._tickDtEmaMs; + } + public update(gu: GameUpdateViewData) { + const nowMs = performance.now(); + const dtMs = nowMs - this._lastUpdateAtMs; + if (Number.isFinite(dtMs) && dtMs > 0 && dtMs < 10_000) { + // Smooth tick interval estimation to avoid jitter when interpolation. + const alpha = 0.12; + this._tickDtEmaMs = this._tickDtEmaMs * (1 - alpha) + dtMs * alpha; + } + this._lastUpdateAtMs = nowMs; + this.toDelete.forEach((id) => this._units.delete(id)); this.toDelete.clear(); @@ -780,15 +812,25 @@ export class GameView implements GameMap { this.updatedTiles = []; this.updatedTerrainTiles = []; + this.updatedOwnerChanges = []; const packed = this.lastUpdate.packedTileUpdates; for (let i = 0; i + 1 < packed.length; i += 2) { const tile = packed[i]; const state = packed[i + 1]; + const previousOwner = this._map.ownerID(tile); const terrainChanged = this.updateTile(tile, state); this.updatedTiles.push(tile); if (terrainChanged) { this.updatedTerrainTiles.push(tile); } + const newOwner = this._map.ownerID(tile); + if (previousOwner !== newOwner) { + this.updatedOwnerChanges.push({ + tile, + previousOwner, + newOwner, + }); + } } if (gu.packedMotionPlans) { @@ -884,9 +926,71 @@ export class GameView implements GameMap { const dt = currentTick - plan.startTick; const stepIndex = dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep)); - const lastIndex = plan.path.length - 1; - const idx = Math.max(0, Math.min(lastIndex, stepIndex)); - const newTile = plan.path[idx] as TileRef; + + const points = plan.points; + const segmentSteps = plan.segmentSteps; + const segCumSteps = plan.segCumSteps; + const totalSteps = + segCumSteps.length === 0 + ? 0 + : segCumSteps[segCumSteps.length - 1] >>> 0; + const idx = Math.max(0, Math.min(totalSteps, stepIndex)); + + let newTile: TileRef; + if (points.length === 0) { + newTile = oldTile; + } else if (segmentSteps.length === 0 || idx >= totalSteps) { + newTile = points[points.length - 1] as TileRef; + } else { + const segmentCount = segmentSteps.length; + let seg = plan.lastSegIdx >>> 0; + if (seg >= segmentCount) { + seg = segmentCount - 1; + } + + const currentStart = segCumSteps[seg] >>> 0; + if (idx < currentStart) { + let lo = 0; + let hi = segmentCount - 1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + const start = segCumSteps[mid] >>> 0; + const end = segCumSteps[mid + 1] >>> 0; + if (idx < start) { + hi = mid - 1; + } else if (idx >= end) { + lo = mid + 1; + } else { + seg = mid; + break; + } + } + } else { + while (seg + 1 < segmentCount && idx >= segCumSteps[seg + 1] >>> 0) { + seg++; + } + } + plan.lastSegIdx = seg; + + const localStep = idx - (segCumSteps[seg] >>> 0); + const p0 = points[seg] as TileRef; + const p1 = points[seg + 1] as TileRef; + const x0 = this.x(p0); + const y0 = this.y(p0); + const x1 = this.x(p1); + const y1 = this.y(p1); + const steps = segmentSteps[seg] >>> 0; + if (steps === 0) { + newTile = p0; + } else { + const dx = x1 - x0; + const dy = y1 - y0; + newTile = this.ref( + Math.round(x0 + (dx * localStep) / steps), + Math.round(y0 + (dy * localStep) / steps), + ); + } + } if (newTile !== oldTile) { unit.applyDerivedPosition(newTile); @@ -896,7 +1000,7 @@ export class GameView implements GameMap { // Once a plan is past its final step, `newTile` remains clamped to the last path tile. // Drop finished plans to avoid repeatedly marking static units as updated each tick. - if (dt > 0 && stepIndex >= lastIndex) { + if (dt > 0 && stepIndex >= totalSteps) { if (this.unitMotionPlans.delete(unitId)) { this.markMotionPlannedUnitIdsDirty(); } @@ -1022,11 +1126,62 @@ export class GameView implements GameMap { } } + private setGridSegmentMotionPlan(record: { + unitId: number; + planId: number; + startTick: number; + ticksPerStep: number; + points: readonly TileRef[] | Uint32Array; + segmentSteps: readonly number[] | Uint32Array; + }): boolean { + if ( + record.ticksPerStep < 1 || + record.points.length < 1 || + record.segmentSteps.length !== Math.max(0, record.points.length - 1) + ) { + return false; + } + const existing = this.unitMotionPlans.get(record.unitId); + if (existing && record.planId <= existing.planId) { + return false; + } + + const points = + record.points instanceof Uint32Array + ? record.points + : Uint32Array.from(record.points); + const segmentSteps = + record.segmentSteps instanceof Uint32Array + ? record.segmentSteps + : Uint32Array.from(record.segmentSteps); + + const segCumSteps = new Uint32Array(segmentSteps.length + 1); + for (let i = 0; i < segmentSteps.length; i++) { + segCumSteps[i + 1] = (segCumSteps[i] + (segmentSteps[i] >>> 0)) >>> 0; + } + + this.unitMotionPlans.set(record.unitId, { + planId: record.planId, + startTick: record.startTick, + ticksPerStep: record.ticksPerStep, + points, + segmentSteps, + segCumSteps, + lastSegIdx: 0, + }); + this.markMotionPlannedUnitIdsDirty(); + return true; + } + private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void { for (const record of records) { switch (record.kind) { - case "grid": { - if (record.ticksPerStep < 1 || record.path.length < 1) { + case "grid_segments": { + this.setGridSegmentMotionPlan(record); + break; + } + case "parabola": { + if (record.increment < 1) { break; } const existing = this.unitMotionPlans.get(record.unitId); @@ -1034,18 +1189,32 @@ export class GameView implements GameMap { break; } - const path = - record.path instanceof Uint32Array - ? record.path - : Uint32Array.from(record.path); + const pf = UniversalPathFinding.Parabola(this._map, { + increment: record.increment, + distanceBasedHeight: record.distanceBasedHeight, + directionUp: record.directionUp, + }); - this.unitMotionPlans.set(record.unitId, { + const points: TileRef[] = [record.src]; + for (let i = 0; i < 20000; i++) { + const step = pf.next(record.src, record.dst, record.increment); + if (step.status === PathStatus.NEXT) { + points.push(step.node); + continue; + } + break; + } + + const segmentSteps = new Uint32Array(Math.max(0, points.length - 1)); + segmentSteps.fill(1); + this.setGridSegmentMotionPlan({ + unitId: record.unitId, planId: record.planId, startTick: record.startTick, - ticksPerStep: record.ticksPerStep, - path, + ticksPerStep: 1, + points, + segmentSteps, }); - this.markMotionPlannedUnitIdsDirty(); break; } case "train": { @@ -1107,6 +1276,14 @@ export class GameView implements GameMap { return this.updatedTerrainTiles; } + recentlyUpdatedOwnerTiles(): Array<{ + tile: TileRef; + previousOwner: number; + newOwner: number; + }> { + return this.updatedOwnerChanges; + } + nearbyUnits( tile: TileRef, searchRange: number, @@ -1323,6 +1500,18 @@ export class GameView implements GameMap { setFallout(ref: TileRef, value: boolean): void { return this._map.setFallout(ref, value); } + isDefended(ref: TileRef): boolean { + return this._map.isDefended(ref); + } + setDefended(ref: TileRef, value: boolean): void { + return this._map.setDefended(ref, value); + } + tileStateView(): Uint16Array { + return this._map.tileStateView(); + } + terrainDataView(): Uint8Array { + return this._map.terrainDataView(); + } isBorder(ref: TileRef): boolean { return this._map.isBorder(ref); } diff --git a/src/core/game/MotionPlans.ts b/src/core/game/MotionPlans.ts index 4417d02120..de9bc59e9c 100644 --- a/src/core/game/MotionPlans.ts +++ b/src/core/game/MotionPlans.ts @@ -1,20 +1,19 @@ import { TileRef } from "./GameMap"; export enum PackedMotionPlanKind { - GridPathSet = 1, TrainRailPathSet = 2, + GridPathKeypointSegments = 3, + ParabolaSet = 4, } -export interface GridPathPlan { - kind: "grid"; +export interface GridKeypointSegmentPlan { + kind: "grid_segments"; unitId: number; planId: number; startTick: number; ticksPerStep: number; - /** - * TileRef path where `path[0]` is the unit tile at `startTick`. - */ - path: readonly TileRef[] | Uint32Array; + points: readonly TileRef[] | Uint32Array; + segmentSteps: readonly number[] | Uint32Array; } export interface TrainRailPathPlan { @@ -34,7 +33,22 @@ export interface TrainRailPathPlan { path: readonly TileRef[] | Uint32Array; } -export type MotionPlanRecord = GridPathPlan | TrainRailPathPlan; +export interface ParabolaPlan { + kind: "parabola"; + unitId: number; + planId: number; + startTick: number; + src: TileRef; + dst: TileRef; + increment: number; + distanceBasedHeight: boolean; + directionUp: boolean; +} + +export type MotionPlanRecord = + | GridKeypointSegmentPlan + | TrainRailPathPlan + | ParabolaPlan; export function packMotionPlans( records: readonly MotionPlanRecord[], @@ -42,9 +56,9 @@ export function packMotionPlans( let totalWords = 1; for (const record of records) { switch (record.kind) { - case "grid": { - const pathLen = (record.path.length >>> 0) as number; - totalWords += 2 + 5 + pathLen; + case "grid_segments": { + const pointCount = (record.points.length >>> 0) as number; + totalWords += 2 + 5 + pointCount + Math.max(0, pointCount - 1); break; } case "train": { @@ -53,6 +67,10 @@ export function packMotionPlans( totalWords += 2 + 7 + carCount + pathLen; break; } + case "parabola": { + totalWords += 2 + 7; + break; + } } } @@ -62,21 +80,32 @@ export function packMotionPlans( let offset = 1; for (const record of records) { switch (record.kind) { - case "grid": { - const path = record.path as ArrayLike; - const pathLen = path.length >>> 0; - const wordCount = 2 + 5 + pathLen; + case "grid_segments": { + const points = record.points as ArrayLike; + const segmentSteps = record.segmentSteps as ArrayLike; + const pointCount = points.length >>> 0; + const segmentCount = pointCount > 0 ? pointCount - 1 : 0; + if (segmentSteps.length >>> 0 !== segmentCount) { + throw new Error( + `grid_segments segmentSteps length mismatch: points=${pointCount}, segmentSteps=${segmentSteps.length}`, + ); + } + + const wordCount = 2 + 5 + pointCount + segmentCount; - out[offset++] = PackedMotionPlanKind.GridPathSet; + out[offset++] = PackedMotionPlanKind.GridPathKeypointSegments; out[offset++] = wordCount >>> 0; out[offset++] = record.unitId >>> 0; out[offset++] = record.planId >>> 0; out[offset++] = record.startTick >>> 0; out[offset++] = record.ticksPerStep >>> 0; - out[offset++] = pathLen >>> 0; + out[offset++] = pointCount >>> 0; - for (let i = 0; i < pathLen; i++) { - out[offset++] = path[i] >>> 0; + for (let i = 0; i < pointCount; i++) { + out[offset++] = points[i] >>> 0; + } + for (let i = 0; i < segmentCount; i++) { + out[offset++] = segmentSteps[i] >>> 0; } break; } @@ -106,6 +135,22 @@ export function packMotionPlans( } break; } + case "parabola": { + const flags = + (record.distanceBasedHeight ? 1 : 0) | (record.directionUp ? 2 : 0); + const wordCount = 2 + 7; + + out[offset++] = PackedMotionPlanKind.ParabolaSet; + out[offset++] = wordCount >>> 0; + out[offset++] = record.unitId >>> 0; + out[offset++] = record.planId >>> 0; + out[offset++] = record.startTick >>> 0; + out[offset++] = record.src >>> 0; + out[offset++] = record.dst >>> 0; + out[offset++] = record.increment >>> 0; + out[offset++] = flags >>> 0; + break; + } } } @@ -135,7 +180,7 @@ export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] { } switch (kind) { - case PackedMotionPlanKind.GridPathSet: { + case PackedMotionPlanKind.GridPathKeypointSegments: { if (wordCount < 2 + 5) { break; } @@ -143,24 +188,34 @@ export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] { const planId = packed[offset + 3] >>> 0; const startTick = packed[offset + 4] >>> 0; const ticksPerStep = packed[offset + 5] >>> 0; - const pathLen = packed[offset + 6] >>> 0; + const pointCount = packed[offset + 6] >>> 0; + const segmentCount = pointCount > 0 ? pointCount - 1 : 0; - const expectedWordCount = 2 + 5 + pathLen; - if (expectedWordCount !== wordCount) { + const expectedWordCount = 2 + 5 + pointCount + segmentCount; + if ( + expectedWordCount !== wordCount || + pointCount < 1 || + ticksPerStep < 1 + ) { break; } - const pathStart = offset + 7; - const pathEnd = pathStart + pathLen; - const path = packed.slice(pathStart, pathEnd); + const pointsStart = offset + 7; + const pointsEnd = pointsStart + pointCount; + const segmentsStart = pointsEnd; + const segmentsEnd = segmentsStart + segmentCount; + + const points = packed.slice(pointsStart, pointsEnd); + const segmentSteps = packed.slice(segmentsStart, segmentsEnd); records.push({ - kind: "grid", + kind: "grid_segments", unitId, planId, startTick, ticksPerStep, - path, + points, + segmentSteps, }); break; } @@ -200,6 +255,34 @@ export function unpackMotionPlans(packed: Uint32Array): MotionPlanRecord[] { }); break; } + case PackedMotionPlanKind.ParabolaSet: { + if (wordCount !== 2 + 7) { + break; + } + const unitId = packed[offset + 2] >>> 0; + const planId = packed[offset + 3] >>> 0; + const startTick = packed[offset + 4] >>> 0; + const src = packed[offset + 5] as TileRef; + const dst = packed[offset + 6] as TileRef; + const increment = packed[offset + 7] >>> 0; + const flags = packed[offset + 8] >>> 0; + if (increment < 1) { + break; + } + + records.push({ + kind: "parabola", + unitId, + planId, + startTick, + src, + dst, + increment, + distanceBasedHeight: (flags & 1) !== 0, + directionUp: (flags & 2) !== 0, + }); + break; + } default: // Unknown kind: skip. break; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 9444ed70b8..ba9cda3447 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -433,6 +433,9 @@ export class UnitImpl implements Unit { setUnderConstruction(underConstruction: boolean): void { if (this._underConstruction !== underConstruction) { this._underConstruction = underConstruction; + if (this._type === UnitType.DefensePost) { + this.mg.refreshDefensePostDefendedState(this); + } this.mg.addUpdate(this.toUpdate()); } } diff --git a/src/core/game/UserSettings.ts b/src/core/game/UserSettings.ts index 5062c79365..35b7b3abfa 100644 --- a/src/core/game/UserSettings.ts +++ b/src/core/game/UserSettings.ts @@ -47,6 +47,14 @@ export const COLOR_KEY = "settings.territoryColor"; export const DARK_MODE_KEY = "settings.darkMode"; export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay"; export const KEYBINDS_KEY = "settings.keybinds"; +export const TERRITORY_RENDERER_KEY = "settings.territoryRenderer"; +export const WEBGL_DEBUG_KEY = "settings.webglDebug"; +export const WEBGPU_DEBUG_KEY = "settings.webgpuDebug"; +export type TerritoryRendererPreference = + | "auto" + | "classic" + | "webgl" + | "webgpu"; export class UserSettings { private static cache = new Map(); @@ -110,7 +118,15 @@ export class UserSettings { this.setCached(key, value); } - private getFloat(key: string, defaultValue: number): number { + get(key: string, defaultValue: boolean): boolean { + return this.getBool(key, defaultValue); + } + + set(key: string, value: boolean): void { + this.setBool(key, value); + } + + getFloat(key: string, defaultValue: number): number { const value = this.getCached(key); if (!value) return defaultValue; @@ -119,10 +135,24 @@ export class UserSettings { return floatValue; } - private setFloat(key: string, value: number) { + setFloat(key: string, value: number) { this.setCached(key, value.toString()); } + getInt(key: string, defaultValue: number): number { + const value = localStorage.getItem(key); + if (!value) return defaultValue; + + const intValue = parseInt(value, 10); + if (!Number.isFinite(intValue)) return defaultValue; + + return intValue; + } + + setInt(key: string, value: number): void { + localStorage.setItem(key, Math.trunc(value).toString()); + } + emojis() { return this.getBool("settings.emojis", true); } @@ -131,6 +161,22 @@ export class UserSettings { return this.getBool(PERFORMANCE_OVERLAY_KEY, false); } + webgpuDebug(): boolean { + return this.get(WEBGPU_DEBUG_KEY, false); + } + + webglDebug(): boolean { + return this.get(WEBGL_DEBUG_KEY, false); + } + + setWebgpuDebug(value: boolean): void { + this.set(WEBGPU_DEBUG_KEY, value); + } + + setWebglDebug(value: boolean): void { + this.set(WEBGL_DEBUG_KEY, value); + } + alertFrame() { return this.getBool("settings.alertFrame", true); } @@ -167,6 +213,33 @@ export class UserSettings { return this.getBool("settings.attackingTroopsOverlay", true); } + territoryBorderMode(): number { + return this.getInt("settings.territoryBorderMode", 1); + } + + territoryRenderer(): TerritoryRendererPreference { + const value = this.getString(TERRITORY_RENDERER_KEY, "auto"); + if ( + value === "auto" || + value === "classic" || + value === "webgl" || + value === "webgpu" + ) { + return value; + } + return "auto"; + } + + setTerritoryRenderer(value: string): void { + const renderer = + value === "classic" || value === "webgl" || value === "webgpu" + ? value + : "auto"; + this.setWebglDebug(renderer === "webgl"); + this.setWebgpuDebug(renderer === "webgpu"); + this.setString(TERRITORY_RENDERER_KEY, renderer); + } + toggleAttackingTroopsOverlay() { this.setBool( "settings.attackingTroopsOverlay", @@ -196,6 +269,10 @@ export class UserSettings { this.setBool(PERFORMANCE_OVERLAY_KEY, !this.performanceOverlay()); } + toggleWebgpuDebug() { + this.setWebgpuDebug(!this.webgpuDebug()); + } + toggleAlertFrame() { this.setBool("settings.alertFrame", !this.alertFrame()); } diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts index b40c914bb1..972dce9894 100644 --- a/src/core/pathfinding/PathFinder.ts +++ b/src/core/pathfinding/PathFinder.ts @@ -15,7 +15,12 @@ import { ComponentCheckTransformer } from "./transformers/ComponentCheckTransfor import { MiniMapTransformer } from "./transformers/MiniMapTransformer"; import { ShoreCoercingTransformer } from "./transformers/ShoreCoercingTransformer"; import { SmoothingWaterTransformer } from "./transformers/SmoothingWaterTransformer"; -import { PathResult, PathStatus, SteppingPathFinder } from "./types"; +import { + PathResult, + PathStatus, + SegmentPlan, + SteppingPathFinder, +} from "./types"; /** * Pathfinders that work with GameMap - usable in both simulation and UI layers @@ -57,6 +62,7 @@ export class PathFinding { const pf = new AStarWater(miniMap); return PathFinderBuilder.create(pf) + .wrap((pf) => new SmoothingWaterTransformer(pf, miniMap)) .wrap((pf) => new ShoreCoercingTransformer(pf, miniMap)) .wrap((pf) => new MiniMapTransformer(pf, game.map(), miniMap)) .buildWithStepper(tileStepperConfig(game)); @@ -159,6 +165,11 @@ export class WaterPathFinder implements SteppingPathFinder { return this.inner.findPath(from, to); } + planSegments(from: TileRef | TileRef[], to: TileRef): SegmentPlan | null { + this.ensureFresh(); + return this.inner.planSegments?.(from, to) ?? null; + } + invalidate(): void { this.inner.invalidate(); } diff --git a/src/core/pathfinding/PathFinderStepper.ts b/src/core/pathfinding/PathFinderStepper.ts index 4b8081fdcb..86e90340b6 100644 --- a/src/core/pathfinding/PathFinderStepper.ts +++ b/src/core/pathfinding/PathFinderStepper.ts @@ -2,6 +2,7 @@ import { PathFinder, PathResult, PathStatus, + SegmentPlan, SteppingPathFinder, } from "./types"; @@ -110,10 +111,224 @@ export class PathFinderStepper implements SteppingPathFinder { }); if (allFailed) { + if (!Array.isArray(from)) { + this.path = null; + this.pathIndex = 0; + this.lastTo = to; + } return null; } } - return this.finder.findPath(from, to); + const isSingleSource = !Array.isArray(from); + if (isSingleSource) { + if (this.lastTo === null || !this.config.equals(this.lastTo, to)) { + this.path = null; + this.pathIndex = 0; + this.lastTo = to; + } + } + + const path = this.finder.findPath(from, to); + + if (isSingleSource) { + if (path === null) { + this.path = null; + this.pathIndex = 0; + return null; + } + + this.path = path; + this.pathIndex = 0; + if (path.length > 0 && this.config.equals(path[0], from)) { + this.pathIndex = 1; + } + this.lastTo = to; + } + + return path; + } + + planSegments(from: T | T[], to: T): SegmentPlan | null { + if (this.config.preCheck) { + if (Array.isArray(from)) { + const allFailed = from.every((f) => { + const result = this.config.preCheck!(f, to); + return result?.status === PathStatus.NOT_FOUND; + }); + if (allFailed) { + return null; + } + } else { + const result = this.config.preCheck(from, to); + if (result?.status === PathStatus.NOT_FOUND) { + return null; + } + } + } + + if (!Array.isArray(from) && this.config.equals(from, to)) { + if (typeof (from as any) !== "number") { + return null; + } + return { + points: Uint32Array.from([from as any]), + segmentSteps: new Uint32Array(0), + }; + } + + if (Array.isArray(from)) { + const path = this.findPath(from, to); + if (path === null) { + return null; + } + return this.compressDenseTilePath(path); + } + + const cachedDense = this.cachedDenseSuffix(from, to); + if (cachedDense !== null) { + return this.compressDenseTilePath(cachedDense); + } + + const path = this.findPath(from, to); + if (path === null) { + return null; + } + + return this.compressDenseTilePath( + this.normalizeSingleSourceDensePath(from, path), + ); + } + + private cachedDenseSuffix(from: T, to: T): T[] | null { + if ( + this.path === null || + this.lastTo === null || + !this.config.equals(this.lastTo, to) + ) { + return null; + } + + if (this.pathIndex <= 0) { + return null; + } + + const expectedPos = this.path[this.pathIndex - 1]; + if (!this.config.equals(from, expectedPos)) { + return null; + } + + return this.path.slice(this.pathIndex - 1); + } + + private normalizeSingleSourceDensePath(from: T, path: T[]): T[] { + if (path.length === 0) { + return [from]; + } + if (this.config.equals(path[0], from)) { + return path; + } + return [from, ...path]; + } + + private compressDenseTilePath(path: ArrayLike): SegmentPlan | null { + const count = path.length >>> 0; + if (count === 0) { + return null; + } + + const first = path[0]; + if (typeof first !== "number") { + return null; + } + + let segmentCount = 0; + let pointCount = 1; + let prev = first as number; + let hasRun = false; + let runDelta = 0; + + for (let i = 1; i < count; i++) { + const node = path[i]; + if (typeof node !== "number") { + return null; + } + + const cur = node as number; + const delta = cur - prev; + prev = cur; + if (delta === 0) { + continue; + } + + if (!hasRun) { + hasRun = true; + runDelta = delta; + segmentCount = 1; + pointCount = 2; + continue; + } + + if (delta !== runDelta) { + runDelta = delta; + segmentCount++; + pointCount++; + } + } + + if (segmentCount === 0) { + return { + points: Uint32Array.from([(first as number) >>> 0]), + segmentSteps: new Uint32Array(0), + }; + } + + const points = new Uint32Array(pointCount); + const segmentSteps = new Uint32Array(segmentCount); + points[0] = (first as number) >>> 0; + + let seg = 0; + let steps = 0; + runDelta = 0; + prev = first as number; + + for (let i = 1; i < count; i++) { + const cur = path[i] as number; + const delta = cur - prev; + if (delta === 0) { + prev = cur; + continue; + } + + if (steps === 0) { + runDelta = delta; + steps = 1; + prev = cur; + continue; + } + + if (delta === runDelta) { + steps++; + prev = cur; + continue; + } + + const runEnd = path[i - 1]; + if (typeof runEnd !== "number") { + return null; + } + segmentSteps[seg] = steps >>> 0; + points[seg + 1] = runEnd >>> 0; + seg++; + + runDelta = delta; + steps = 1; + prev = cur; + } + + segmentSteps[seg] = steps >>> 0; + points[seg + 1] = prev >>> 0; + + return { points, segmentSteps }; } } diff --git a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts index 549e047b5e..848507ac30 100644 --- a/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts +++ b/src/core/pathfinding/transformers/SmoothingWaterTransformer.ts @@ -38,9 +38,11 @@ export class SmoothingWaterTransformer implements PathFinder { findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null { const path = this.inner.findPath(from, to); - return DebugSpan.wrap("smoothingTransformer", () => - path ? this.smooth(path) : null, - ); + if (!path) { + return null; + } + + return DebugSpan.wrap("smoothingTransformer", () => this.smooth(path)); } private smooth(path: TileRef[]): TileRef[] { @@ -59,11 +61,9 @@ export class SmoothingWaterTransformer implements PathFinder { ); // Pass 3: LOS smoothing again, farther from the shore - smoothed = DebugSpan.wrap("smoother:los2", () => + return DebugSpan.wrap("smoother:los2", () => this.losSmooth(smoothed, LOS_MIN_MAGNITUDE_PASS2), ); - - return smoothed; } private losSmooth(path: TileRef[], minMagnitude: number): TileRef[] { diff --git a/src/core/pathfinding/types.ts b/src/core/pathfinding/types.ts index c844b9df0b..558c3dfeda 100644 --- a/src/core/pathfinding/types.ts +++ b/src/core/pathfinding/types.ts @@ -20,8 +20,20 @@ export type PathResult = */ export interface PathFinder { findPath(from: T | T[], to: T): T[] | null; + /** + * Optional: returns a sparse keypoint polyline with per-segment step counts. + * Only implemented for TileRef-style (number) pathfinders. + * + * `points.length === segmentSteps.length + 1` when present. + */ + planSegments?(from: T | T[], to: T): SegmentPlan | null; } +export type SegmentPlan = { + points: Uint32Array; + segmentSteps: Uint32Array; +}; + /** * SteppingPathFinder - PathFinder with stepping support. * Used by execution classes that need incremental path traversal. diff --git a/src/server/PublicAssetManifest.ts b/src/server/PublicAssetManifest.ts index 0fc31b6a48..cd3ee4bdab 100644 --- a/src/server/PublicAssetManifest.ts +++ b/src/server/PublicAssetManifest.ts @@ -195,11 +195,63 @@ function renderBitmapFontAsset({ ); } +function renderTextureAtlasJsonAsset({ + resourcesDir, + relativePath, + assetManifest, +}: DerivedPublicAssetRenderContext): string { + const atlas = JSON.parse(readPublicAssetText(resourcesDir, relativePath)) as { + meta?: { image?: unknown }; + }; + + const imagePath = atlas.meta?.image; + if (imagePath === undefined) { + return `${JSON.stringify(atlas, null, 2)}\n`; + } + + if (typeof imagePath !== "string") { + throw new Error( + `Derived asset ${relativePath} contains a non-string atlas image reference`, + ); + } + + if (imagePath.trim().length === 0) { + throw new Error( + `Derived asset ${relativePath} contains a blank atlas image reference`, + ); + } + + if (!isExternalAssetReference(imagePath)) { + const referencedAssetPath = resolveDerivedAssetReference( + relativePath, + imagePath, + ); + const referencedHashedUrl = assetManifest[referencedAssetPath]; + if (!referencedHashedUrl) { + throw new Error( + `Derived asset ${relativePath} references ${referencedAssetPath}, but it is missing from the asset manifest`, + ); + } + + atlas.meta!.image = getEmittedAssetRelativePath( + relativePath, + referencedHashedUrl, + ); + } + + return `${JSON.stringify(atlas, null, 2)}\n`; +} + const DERIVED_PUBLIC_ASSET_RENDERERS: DerivedPublicAssetRenderer[] = [ { matches: (relativePath) => relativePath === "manifest.json", render: renderWebManifestAsset, }, + { + matches: (relativePath) => + relativePath.startsWith("images/") && relativePath.endsWith(".json"), + render: renderTextureAtlasJsonAsset, + }, { matches: (relativePath) => relativePath.startsWith("fonts/") && relativePath.endsWith(".xml"), diff --git a/tests/MiniMapTransformerPlanSegments.test.ts b/tests/MiniMapTransformerPlanSegments.test.ts new file mode 100644 index 0000000000..ea56cfed83 --- /dev/null +++ b/tests/MiniMapTransformerPlanSegments.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { GameMapImpl } from "../src/core/game/GameMap"; +import { MiniMapTransformer } from "../src/core/pathfinding/transformers/MiniMapTransformer"; + +function makeMap(width: number, height: number): GameMapImpl { + return new GameMapImpl(width, height, new Uint8Array(width * height), 0); +} + +describe("MiniMapTransformer", () => { + it("preserves dense path endpoints after upscaling/fixing extremes", () => { + const map = makeMap(10, 10); + const miniMap = makeMap(5, 5); + + const miniPath = [ + miniMap.ref(0, 0), + miniMap.ref(1, 0), + miniMap.ref(2, 0), + miniMap.ref(2, 1), + miniMap.ref(2, 2), + ]; + + const inner = { + findPath() { + return miniPath.slice(); + }, + }; + + const transformer = new MiniMapTransformer(inner as any, map, miniMap); + const from = map.ref(0, 0); + const to = map.ref(4, 4); + + const dense = transformer.findPath(from, to); + expect(dense).not.toBeNull(); + if (!dense) return; + expect(dense[0]).toBe(from); + expect(dense[dense.length - 1]).toBe(to); + }); +}); diff --git a/tests/MotionPlansSegments.test.ts b/tests/MotionPlansSegments.test.ts new file mode 100644 index 0000000000..f7fe42af39 --- /dev/null +++ b/tests/MotionPlansSegments.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + packMotionPlans, + unpackMotionPlans, +} from "../src/core/game/MotionPlans"; + +describe("MotionPlans grid_segments", () => { + it("packs/unpacks grid_segments", () => { + const packed = packMotionPlans([ + { + kind: "grid_segments", + unitId: 123, + planId: 7, + startTick: 10, + ticksPerStep: 2, + points: Uint32Array.from([1, 6, 11]), + segmentSteps: Uint32Array.from([5, 5]), + }, + ]); + + const records = unpackMotionPlans(packed); + expect(records).toHaveLength(1); + const r = records[0]; + expect(r.kind).toBe("grid_segments"); + if (r.kind !== "grid_segments") throw new Error("type guard"); + expect(r.unitId).toBe(123); + expect(r.planId).toBe(7); + expect(r.startTick).toBe(10); + expect(r.ticksPerStep).toBe(2); + expect(Array.from(r.points)).toEqual([1, 6, 11]); + expect(Array.from(r.segmentSteps)).toEqual([5, 5]); + }); + + it("packs/unpacks parabola records", () => { + const packed = packMotionPlans([ + { + kind: "parabola", + unitId: 44, + planId: 3, + startTick: 99, + src: 10, + dst: 20, + increment: 7, + distanceBasedHeight: true, + directionUp: false, + }, + ]); + + const records = unpackMotionPlans(packed); + expect(records).toHaveLength(1); + const r = records[0]; + expect(r.kind).toBe("parabola"); + if (r.kind !== "parabola") throw new Error("type guard"); + expect(r.unitId).toBe(44); + expect(r.planId).toBe(3); + expect(r.startTick).toBe(99); + expect(r.src).toBe(10); + expect(r.dst).toBe(20); + expect(r.increment).toBe(7); + expect(r.distanceBasedHeight).toBe(true); + expect(r.directionUp).toBe(false); + }); + + it("skips unknown kinds using wordCount", () => { + const gridPacked = packMotionPlans([ + { + kind: "grid_segments", + unitId: 1, + planId: 1, + startTick: 1, + ticksPerStep: 1, + points: Uint32Array.from([10, 12]), + segmentSteps: Uint32Array.from([2]), + }, + ]); + + const gridRecordWords = gridPacked.slice(1); // strip recordCount + const unknownWordCount = 4; + const out = new Uint32Array(1 + unknownWordCount + gridRecordWords.length); + out[0] = 2; + let o = 1; + out[o++] = 999; + out[o++] = unknownWordCount; + out[o++] = 111; + out[o++] = 222; + out.set(gridRecordWords, o); + + const records = unpackMotionPlans(out); + expect(records).toHaveLength(1); + expect(records[0].kind).toBe("grid_segments"); + }); +}); diff --git a/tests/NameLayer.test.ts b/tests/NameLayer.test.ts index 2337e78a1a..049d64a184 100644 --- a/tests/NameLayer.test.ts +++ b/tests/NameLayer.test.ts @@ -1,4 +1,16 @@ -import { computeAllianceClipPath } from "../src/client/graphics/PlayerIcons"; +import { + computeAllianceClipPath, + computeAllianceTopCutPercent, +} from "../src/client/graphics/PlayerIcons"; +import { + computeNameLayerLayout, + computeNameLayerScreenMetrics, + computeNameLayerWorldScale, + computeTraitorFlashAlpha, + computeTraitorFlashDurationSeconds, + replaceUnsupportedNameGlyphs, + resetNameLayerGlyphWarningsForTests, +} from "../src/client/graphics/layers/NameLayerLayout"; describe("PlayerIcons", () => { describe("computeAllianceClipPath", () => { @@ -37,5 +49,114 @@ describe("PlayerIcons", () => { expect(result).toContain("-2px"); expect(result.match(/-2px/g)).toHaveLength(2); // Should appear twice (left and right) }); + + test("shares numeric top-cut helper with Pixi masks", () => { + expect(computeAllianceTopCutPercent(1.0)).toBeCloseTo(20); + expect(computeAllianceTopCutPercent(0.5)).toBeCloseTo(51.2); + expect(computeAllianceTopCutPercent(0.0)).toBeCloseTo(82.4); + }); + }); +}); + +describe("NameLayerLayout", () => { + test("computes DOM-compatible local row positions with flag and icon gaps", () => { + const layout = computeNameLayerLayout({ + fontSize: 10, + iconSize: 15, + iconCount: 2, + centeredIconCount: 1, + hasFlag: true, + flagAspectRatio: 2, + nameWidth: 40, + troopWidth: 30, + }); + + expect(layout.iconPositions).toEqual([ + { x: -9.5, y: -9.75 }, + { x: 9.5, y: -9.75 }, + ]); + expect(layout.flag).toEqual({ x: -20, y: 2.75, width: 20, height: 10 }); + expect(layout.nameText).toEqual({ x: 10, y: 2.75 }); + expect(layout.troopText).toEqual({ x: 0, y: 12.25 }); + expect(layout.centeredIconPositions).toEqual([{ x: 0, y: 2.75 }]); + }); + + test("keeps no-flag names centered on the text width", () => { + const layout = computeNameLayerLayout({ + fontSize: 12, + iconSize: 18, + iconCount: 0, + centeredIconCount: 0, + hasFlag: false, + flagAspectRatio: 1, + nameWidth: 60, + troopWidth: 24, + }); + + expect(layout.flag).toBeNull(); + expect(layout.nameText.x).toBe(0); + expect(layout.width).toBe(60); + }); + + test("combines local label scale with camera scale for world-stable labels", () => { + expect(computeNameLayerWorldScale(8, 2)).toBeCloseTo(4); + expect(computeNameLayerWorldScale(20, 2)).toBeCloseTo(6); + }); + + test("computes final screen-space text and icon sizes", () => { + expect(computeNameLayerScreenMetrics(8, 2)).toEqual({ + fontSize: 16, + iconSize: 24, + }); + expect(computeNameLayerScreenMetrics(20, 2)).toEqual({ + fontSize: 48, + iconSize: 72, + }); + }); + + test("matches traitor flash duration thresholds and alpha extrema", () => { + expect(computeTraitorFlashDurationSeconds(156)).toBeNull(); + expect(computeTraitorFlashDurationSeconds(150)).toBeCloseTo(1); + expect(computeTraitorFlashDurationSeconds(0)).toBeCloseTo(0.2); + expect(computeTraitorFlashAlpha(150, 0)).toBeCloseTo(1); + expect(computeTraitorFlashAlpha(150, 250)).toBeCloseTo(0.65); + expect(computeTraitorFlashAlpha(150, 500)).toBeCloseTo(0.3); + }); + + test("spreads multiple centered icons instead of stacking them", () => { + const layout = computeNameLayerLayout({ + fontSize: 10, + iconSize: 15, + iconCount: 0, + centeredIconCount: 2, + hasFlag: false, + flagAspectRatio: 1, + nameWidth: 40, + troopWidth: 30, + }); + + expect(layout.centeredIconPositions).toEqual([ + { x: -9.5, y: -4.75 }, + { x: 9.5, y: -4.75 }, + ]); + }); + + test("replaces unsupported glyphs once per glyph", () => { + resetNameLayerGlyphWarningsForTests(); + const warn = vi.fn(); + + expect(replaceUnsupportedNameGlyphs("A🙂🙂B", warn)).toBe("A??B"); + expect(replaceUnsupportedNameGlyphs("🙂", warn)).toBe("?"); + expect(warn).toHaveBeenCalledTimes(1); + }); + + test("replaces unsupported grapheme clusters with one fallback glyph", () => { + resetNameLayerGlyphWarningsForTests(); + const warn = vi.fn(); + + expect( + replaceUnsupportedNameGlyphs("A\u{1F469}\u200D\u{1F4BB}B", warn), + ).toBe("A?B"); + expect(warn).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/PathFinderStepperPriming.test.ts b/tests/PathFinderStepperPriming.test.ts new file mode 100644 index 0000000000..3e2a233858 --- /dev/null +++ b/tests/PathFinderStepperPriming.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { PathFinderStepper } from "../src/core/pathfinding/PathFinderStepper"; +import { PathStatus } from "../src/core/pathfinding/types"; + +describe("PathFinderStepper cache priming", () => { + it("primes next() cache via findPath()", () => { + let calls = 0; + const finder = { + findPath(from: number | number[], to: number) { + calls++; + const start = Array.isArray(from) ? from[0] : from; + return [start, to]; + }, + }; + + const stepper = new PathFinderStepper(finder, { + equals: (a, b) => a === b, + }); + + const from = 10; + const to = 42; + + const path = stepper.findPath(from, to); + expect(path).toEqual([from, to]); + expect(calls).toBe(1); + + const r1 = stepper.next(from, to); + expect(r1.status).toBe(PathStatus.NEXT); + if (r1.status === PathStatus.NEXT) { + expect(r1.node).toBe(to); + } + expect(calls).toBe(1); + }); +}); diff --git a/tests/SegmentTrailRaster.test.ts b/tests/SegmentTrailRaster.test.ts new file mode 100644 index 0000000000..abcd13d8d0 --- /dev/null +++ b/tests/SegmentTrailRaster.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + locateSegment, + positionAtStep, + stepAtTick, + strokeStepInterval, +} from "../src/client/graphics/layers/SegmentTrailRaster"; + +function makeGame() { + return { + x(ref: number): number { + return ref % 10; + }, + y(ref: number): number { + return Math.floor(ref / 10); + }, + }; +} + +function makePlan() { + return { + startTick: 10, + ticksPerStep: 2, + points: Uint32Array.from([0, 3, 33]), // (0,0)->(3,0)->(3,3) + segmentSteps: Uint32Array.from([3, 3]), + segCumSteps: Uint32Array.from([0, 3, 6]), + }; +} + +function makeMockCtx() { + const ops: Array<{ op: string; x?: number; y?: number }> = []; + const ctx = { + beginPath() { + ops.push({ op: "beginPath" }); + }, + moveTo(x: number, y: number) { + ops.push({ op: "moveTo", x, y }); + }, + lineTo(x: number, y: number) { + ops.push({ op: "lineTo", x, y }); + }, + stroke() { + ops.push({ op: "stroke" }); + }, + } as unknown as CanvasRenderingContext2D; + return { ctx, ops }; +} + +describe("SegmentTrailRaster", () => { + it("stepAtTick clamps before start and after end", () => { + const plan = makePlan(); + expect(stepAtTick(plan, 8)).toBe(0); + expect(stepAtTick(plan, 10)).toBe(0); + expect(stepAtTick(plan, 12)).toBe(1); + expect(stepAtTick(plan, 100)).toBe(6); + }); + + it("locateSegment handles boundaries with end-exclusive segments", () => { + const plan = makePlan(); + expect(locateSegment(plan.segCumSteps, 2, 0)).toBe(0); + expect(locateSegment(plan.segCumSteps, 2, 2)).toBe(0); + expect(locateSegment(plan.segCumSteps, 2, 3)).toBe(1); + expect(locateSegment(plan.segCumSteps, 2, 6)).toBe(1); + }); + + it("positionAtStep matches expected piecewise interpolation", () => { + const plan = makePlan(); + const game = makeGame(); + expect(positionAtStep(game, plan, 2)).toEqual({ x: 2, y: 0 }); + expect(positionAtStep(game, plan, 4)).toEqual({ x: 3, y: 1 }); + expect(positionAtStep(game, plan, 6)).toEqual({ x: 3, y: 3 }); + }); + + it("strokeStepInterval draws same-segment interval including first step", () => { + const { ctx, ops } = makeMockCtx(); + const plan = makePlan(); + const game = makeGame(); + const drew = strokeStepInterval(ctx, game, plan, 0, 1); + expect(drew).toBe(true); + expect(ops).toEqual([ + { op: "beginPath" }, + { op: "moveTo", x: 0, y: 0 }, + { op: "lineTo", x: 1, y: 0 }, + { op: "stroke" }, + ]); + }); + + it("strokeStepInterval crosses corners without skipping boundaries", () => { + const { ctx, ops } = makeMockCtx(); + const plan = makePlan(); + const game = makeGame(); + const drew = strokeStepInterval(ctx, game, plan, 2, 5); + expect(drew).toBe(true); + expect(ops).toEqual([ + { op: "beginPath" }, + { op: "moveTo", x: 2, y: 0 }, + { op: "lineTo", x: 3, y: 0 }, + { op: "lineTo", x: 3, y: 2 }, + { op: "stroke" }, + ]); + }); + + it("strokeStepInterval no-ops for empty deltas", () => { + const { ctx, ops } = makeMockCtx(); + const plan = makePlan(); + const game = makeGame(); + expect(strokeStepInterval(ctx, game, plan, 4, 4)).toBe(false); + expect(ops).toEqual([]); + }); + + it("supports replan-style epoch replay by drawing multiple intervals", () => { + const { ctx, ops } = makeMockCtx(); + const game = makeGame(); + const epochA = { + startTick: 0, + ticksPerStep: 1, + points: Uint32Array.from([0, 3]), + segmentSteps: Uint32Array.from([3]), + segCumSteps: Uint32Array.from([0, 3]), + }; + const epochB = { + startTick: 3, + ticksPerStep: 1, + points: Uint32Array.from([3, 33]), + segmentSteps: Uint32Array.from([3]), + segCumSteps: Uint32Array.from([0, 3]), + }; + + expect(strokeStepInterval(ctx, game, epochA, 0, 3)).toBe(true); + expect(strokeStepInterval(ctx, game, epochB, 0, 2)).toBe(true); + + expect(ops).toEqual([ + { op: "beginPath" }, + { op: "moveTo", x: 0, y: 0 }, + { op: "lineTo", x: 3, y: 0 }, + { op: "stroke" }, + { op: "beginPath" }, + { op: "moveTo", x: 3, y: 0 }, + { op: "lineTo", x: 3, y: 2 }, + { op: "stroke" }, + ]); + }); +}); diff --git a/tests/TerritoryBackendSelection.test.ts b/tests/TerritoryBackendSelection.test.ts new file mode 100644 index 0000000000..b722f3b774 --- /dev/null +++ b/tests/TerritoryBackendSelection.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "vitest"; +import { + selectTerritoryBackend, + type TerritoryBackendCandidate, + type TerritoryRendererId, + type TerritoryRendererPreference, +} from "../src/client/graphics/layers/TerritoryBackend"; + +type FakeBackendSpec = { + initError?: string; + ready?: boolean; + failureReason?: string; +}; + +type FakeBackendSpecs = Partial>; + +class FakeBackend implements TerritoryBackendCandidate { + initialized = false; + disposed = false; + + constructor( + readonly id: TerritoryRendererId, + private readonly spec: FakeBackendSpec = {}, + ) {} + + init() { + this.initialized = true; + if (this.spec.initError) { + throw new Error(this.spec.initError); + } + } + + async whenReady(): Promise { + return this.spec.ready ?? true; + } + + getFailureReason(): string | null { + return this.spec.failureReason ?? null; + } + + dispose() { + this.disposed = true; + } +} + +class RendererSelectionHarness { + active: TerritoryRendererId | null = null; + readonly failed = new Set(); + preference: TerritoryRendererPreference; + + constructor(preference: TerritoryRendererPreference) { + this.preference = preference; + } + + setPreference(preference: TerritoryRendererPreference) { + this.preference = preference; + this.failed.clear(); + } + + async select(specs: FakeBackendSpecs = {}) { + const created: FakeBackend[] = []; + const selection = await selectTerritoryBackend( + this.preference, + this.failed, + (id) => { + const backend = new FakeBackend(id, specs[id]); + created.push(backend); + return backend; + }, + ); + + for (const failure of selection.failures) { + if (failure.id !== "classic") { + this.failed.add(failure.id); + } + } + if (selection.backend) { + this.active = selection.backend.id; + } + + return { ...selection, created }; + } + + async failActiveRuntime(specs: FakeBackendSpecs = {}) { + if (this.active && this.active !== "classic") { + this.failed.add(this.active); + } + return this.select(specs); + } +} + +describe("territory renderer backend selection", () => { + test("auto selects WebGPU when ready", async () => { + const harness = new RendererSelectionHarness("auto"); + + const result = await harness.select(); + + expect(result.backend?.id).toBe("webgpu"); + expect(harness.active).toBe("webgpu"); + expect(result.failures).toEqual([]); + expect(result.created.map((backend) => backend.id)).toEqual(["webgpu"]); + }); + + test("auto falls back to WebGL when WebGPU init fails", async () => { + const harness = new RendererSelectionHarness("auto"); + + const result = await harness.select({ + webgpu: { initError: "navigator.gpu unavailable" }, + }); + + expect(result.backend?.id).toBe("webgl"); + expect(harness.active).toBe("webgl"); + expect(result.failures.map((failure) => failure.id)).toEqual(["webgpu"]); + expect(result.created[0].disposed).toBe(true); + }); + + test("auto falls back to classic when both accelerated backends fail", async () => { + const harness = new RendererSelectionHarness("auto"); + + const result = await harness.select({ + webgpu: { initError: "navigator.gpu unavailable" }, + webgl: { failureReason: "WebGL2 unavailable" }, + }); + + expect(result.backend?.id).toBe("classic"); + expect(harness.active).toBe("classic"); + expect(result.failures.map((failure) => failure.id)).toEqual([ + "webgpu", + "webgl", + ]); + }); + + test("forced WebGPU falls back on runtime failure without changing saved setting", async () => { + const harness = new RendererSelectionHarness("webgpu"); + await harness.select(); + + const result = await harness.failActiveRuntime(); + + expect(result.backend?.id).toBe("webgl"); + expect(harness.active).toBe("webgl"); + expect(harness.preference).toBe("webgpu"); + expect(harness.failed.has("webgpu")).toBe(true); + }); + + test("manual setting change retries previously failed backends", async () => { + const harness = new RendererSelectionHarness("auto"); + await harness.select({ + webgpu: { initError: "navigator.gpu unavailable" }, + }); + + expect(harness.active).toBe("webgl"); + expect(harness.failed.has("webgpu")).toBe(true); + + harness.setPreference("auto"); + const retry = await harness.select(); + + expect(retry.backend?.id).toBe("webgpu"); + expect(harness.active).toBe("webgpu"); + expect(harness.failed.size).toBe(0); + }); +}); diff --git a/tests/UnitLayerTrailLifecycle.test.ts b/tests/UnitLayerTrailLifecycle.test.ts new file mode 100644 index 0000000000..efd16041a0 --- /dev/null +++ b/tests/UnitLayerTrailLifecycle.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { pruneInactiveTrails } from "../src/client/graphics/layers/TrailLifecycle"; + +describe("UnitLayer trail lifecycle helpers", () => { + it("removes transport and nuke trails for inactive units", () => { + const nukeTrails = new Map([ + [10, [1, 2, 3]], + [11, [4, 5]], + ]); + const transportTrails = new Map< + number, + { activePlanId: number; epochs: unknown[]; lastOnScreen: boolean } + >([ + [10, { activePlanId: 1, epochs: [{}], lastOnScreen: true }], + [12, { activePlanId: 2, epochs: [{}], lastOnScreen: false }], + ]); + + const result = pruneInactiveTrails( + nukeTrails, + transportTrails, + (unitId) => unitId === 11, + ); + + expect(result).toEqual({ removedNukes: 1, removedTransport: 2 }); + expect(Array.from(nukeTrails.keys())).toEqual([11]); + expect(transportTrails.size).toBe(0); + }); + + it("keeps all trails when units are active", () => { + const nukeTrails = new Map([[1, [1]]]); + const transportTrails = new Map< + number, + { activePlanId: number; epochs: unknown[]; lastOnScreen: boolean } + >([ + [2, { activePlanId: 1, epochs: [{}], lastOnScreen: true }], + ]); + + const result = pruneInactiveTrails( + nukeTrails, + transportTrails, + () => true, + ); + + expect(result).toEqual({ removedNukes: 0, removedTransport: 0 }); + expect(nukeTrails.size).toBe(1); + expect(transportTrails.size).toBe(1); + }); +}); diff --git a/tests/core/pathfinding/PathFinderStepper.test.ts b/tests/core/pathfinding/PathFinderStepper.test.ts index 5cf0fbc538..05b9e9c199 100644 --- a/tests/core/pathfinding/PathFinderStepper.test.ts +++ b/tests/core/pathfinding/PathFinderStepper.test.ts @@ -176,4 +176,119 @@ describe("PathFinderStepper", () => { expect((result2 as { node: Pos }).node).toEqual({ x: 3, y: 0 }); }); }); + + describe("planSegments", () => { + it("compresses dense paths into delta runs", () => { + const path = [10, 11, 12, 13, 23, 33, 43]; + const stepper = new PathFinderStepper({ + findPath: () => path.slice(), + }); + + const plan = stepper.planSegments(10, 43); + + expect(plan).not.toBeNull(); + if (!plan) return; + expect(Array.from(plan.points)).toEqual([10, 13, 43]); + expect(Array.from(plan.segmentSteps)).toEqual([3, 3]); + }); + + it("reuses cached suffix after next() without an extra findPath call", () => { + let calls = 0; + const path = [1, 2, 3, 4, 14, 24]; + const stepper = new PathFinderStepper({ + findPath: () => { + calls++; + return path.slice(); + }, + }); + + const r1 = stepper.next(1, 24); + expect(r1.status).toBe(PathStatus.NEXT); + const r2 = stepper.next(2, 24); + expect(r2.status).toBe(PathStatus.NEXT); + expect(calls).toBe(1); + + const plan = stepper.planSegments(3, 24); + + expect(plan).not.toBeNull(); + if (!plan) return; + expect(calls).toBe(1); + expect(Array.from(plan.points)).toEqual([3, 4, 24]); + expect(Array.from(plan.segmentSteps)).toEqual([1, 2]); + }); + + it("prepends source when the returned dense path omits it", () => { + const stepper = new PathFinderStepper({ + findPath: () => [11, 12, 22], + }); + + const plan = stepper.planSegments(10, 22); + + expect(plan).not.toBeNull(); + if (!plan) return; + expect(Array.from(plan.points)).toEqual([10, 12, 22]); + expect(Array.from(plan.segmentSteps)).toEqual([2, 1]); + }); + + it("skips zero-delta nodes while preserving run counts", () => { + const stepper = new PathFinderStepper({ + findPath: () => [10, 10, 11, 12, 22, 22, 32, 31], + }); + + const plan = stepper.planSegments(10, 31); + + expect(plan).not.toBeNull(); + if (!plan) return; + expect(Array.from(plan.points)).toEqual([10, 12, 32, 31]); + expect(Array.from(plan.segmentSteps)).toEqual([2, 2, 1]); + }); + + it("returns a single-point plan when from equals to", () => { + let calls = 0; + const stepper = new PathFinderStepper({ + findPath: () => { + calls++; + return [5]; + }, + }); + + const plan = stepper.planSegments(5, 5); + + expect(plan).not.toBeNull(); + if (!plan) return; + expect(calls).toBe(0); + expect(Array.from(plan.points)).toEqual([5]); + expect(plan.segmentSteps.length).toBe(0); + }); + + it("returns null when no path exists", () => { + const stepper = new PathFinderStepper({ + findPath: () => null, + }); + + const plan = stepper.planSegments(1, 99); + expect(plan).toBeNull(); + }); + + it("supports multi-source by compressing the returned dense path once", () => { + let calls = 0; + const stepper = new PathFinderStepper({ + findPath: (from) => { + calls++; + if (!Array.isArray(from)) { + return null; + } + return [from[1], from[1] + 1, from[1] + 2]; + }, + }); + + const plan = stepper.planSegments([10, 20], 22); + + expect(plan).not.toBeNull(); + if (!plan) return; + expect(calls).toBe(1); + expect(Array.from(plan.points)).toEqual([20, 22]); + expect(Array.from(plan.segmentSteps)).toEqual([2]); + }); + }); }); diff --git a/tests/server/PublicAssetManifest.test.ts b/tests/server/PublicAssetManifest.test.ts index db7ec06206..14ee417dec 100644 --- a/tests/server/PublicAssetManifest.test.ts +++ b/tests/server/PublicAssetManifest.test.ts @@ -59,6 +59,36 @@ describe("PublicAssetManifest", () => { await fs.writeFile(pagePath, pageContent); } + async function writeTextureAtlasFixture( + resourcesDir: string, + jsonRelativePath: string, + imageFilePath: string, + imageContent: string = "png-v1", + ): Promise { + const jsonPath = path.join(resourcesDir, jsonRelativePath); + const imagePath = path.join(path.dirname(jsonPath), imageFilePath); + const atlasImagePath = imageFilePath.split(path.sep).join(path.posix.sep); + + await fs.mkdir(path.dirname(imagePath), { recursive: true }); + await fs.writeFile( + jsonPath, + JSON.stringify( + { + frames: {}, + meta: { + image: atlasImagePath, + format: "RGBA8888", + size: { w: 1, h: 1 }, + scale: "1", + }, + }, + null, + 2, + ), + ); + await fs.writeFile(imagePath, imageContent); + } + async function emitHashedAsset( outDir: string, assetHref: string, @@ -189,6 +219,79 @@ describe("PublicAssetManifest", () => { expect(emittedManifest).toContain("data:image/png;base64,AAA"); }); + test("rewrites TexturePacker atlas image refs to hashed relative paths", async () => { + const { resourcesDir, outDir } = await createTempResources(); + + await writeTextureAtlasFixture( + resourcesDir, + path.join("images", "namelayer-icons.json"), + "namelayer-icons.png", + ); + + const assetManifest = buildPublicAssetManifest([resourcesDir]); + createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest); + + const jsonHref = assetManifest["images/namelayer-icons.json"]; + const pngHref = assetManifest["images/namelayer-icons.png"]; + const emittedJson = await emitHashedAsset(outDir, jsonHref); + const emittedAtlas = JSON.parse(emittedJson) as { + meta: { image: string }; + }; + + expect(emittedAtlas.meta.image).toBe( + getExpectedRelativeEmittedPath(jsonHref, pngHref), + ); + expect(emittedAtlas.meta.image).not.toBe("namelayer-icons.png"); + }); + + test("TexturePacker atlas JSON hash changes when its image changes", async () => { + const { resourcesDir } = await createTempResources(); + + await writeTextureAtlasFixture( + resourcesDir, + path.join("images", "namelayer-icons.json"), + "namelayer-icons.png", + ); + + const firstManifest = buildPublicAssetManifest([resourcesDir]); + + await fs.writeFile( + path.join(resourcesDir, "images", "namelayer-icons.png"), + "png-v2", + ); + clearPublicAssetManifestCache(); + + const secondManifest = buildPublicAssetManifest([resourcesDir]); + + expect(firstManifest["images/namelayer-icons.png"]).not.toBe( + secondManifest["images/namelayer-icons.png"], + ); + expect(firstManifest["images/namelayer-icons.json"]).not.toBe( + secondManifest["images/namelayer-icons.json"], + ); + }); + + test("fails when TexturePacker atlas JSON references a missing image", async () => { + const { resourcesDir } = await createTempResources(); + + await fs.mkdir(path.join(resourcesDir, "images"), { recursive: true }); + await fs.writeFile( + path.join(resourcesDir, "images", "namelayer-icons.json"), + JSON.stringify( + { + frames: {}, + meta: { image: "missing.png" }, + }, + null, + 2, + ), + ); + + expect(() => buildPublicAssetManifest([resourcesDir])).toThrow( + /images\/namelayer-icons\.json references images\/missing\.png/i, + ); + }); + test("rewrites BMFont XML page filenames to hashed relative paths", async () => { const { resourcesDir, outDir } = await createTempResources();