commit c4347f8420f420d7d64321b564397047ce2c2e45 Author: Tronax Date: Sat Aug 15 16:07:09 2026 +0200 feat: initialize TRXTD browser tower defense diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed39c62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment files +.env +.env.* +!.env.example + +# Temporary test / screenshot outputs +public/shot*.png +test-results/ +coverage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..27aed8b --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# TRXTD – Tower Defense im Browser + +Ein vollständiges Tower-Defense-Spiel, gebaut mit **Vue 3 + TypeScript + Vite**. +Die Spiellogik läuft in reinem TypeScript auf einem HTML-Canvas (60 FPS), Vue kümmert sich um HUD, Menüs und Panels. + +Enthält **Solo-Kampagne**, **2-Spieler-Coop** und **1v1-Duell** (Online/Lokal per WebSocket). + +## Schnellstart + +1. Abhängigkeiten installieren: + ```bash + npm install + ``` + +2. Frontend (Vite) und Multiplayer-Server starten: + ```bash + # Terminal 1: Dev-Server + npm run dev # → http://localhost:5173 + + # Terminal 2: Multiplayer-Relay (nur für Coop / 1v1 nötig) + npm run server # → ws://localhost:3001 + ``` + +3. Browser öffnen unter `http://localhost:5173`. + +--- + +## Spielmodi + +### 1. Solo-Kampagne +Verteidige deine Basis gegen 20 handgetunte Wellen (Leicht: 30 Leben, Normal: 20 Leben, Schwer: 12 Leben). Nach Welle 20 geht es auf Wunsch im **Endlos-Modus** weiter. + +### 2. 🤝 2-Spieler-Coop +- **Gemeinsame Basis & gemeinsames Gold** – Sprecht euch ab! +- **Eigene Türme:** Deine Türme haben einen **blauen Ring**, die deines Partners einen **orangenen Ring** (jeder kann nur seine eigenen Türme upgraden oder verkaufen). +- Gegner haben 70 % mehr Leben, um der doppelten Feuerkraft standzuhalten. +- Wellen starten automatisch nach Countdown oder per Klick auf „Nächste Welle" (beide können klicken). + +### 3. ⚔ 1v1-Duell (Tower-Battles-Prinzip) +- Jeder Spieler verteidigt sein **eigenes Spielfeld**. +- Über ein **Live-Miniaturfenster (PiP)** siehst du in Echtzeit das Spielfeld deines Gegners mit allen Türmen und Gegnern. +- **Rush-Mechanik:** Schicke gegen Gold (`60 🪙 + 8 🪙/Welle`) eine Welle schneller Läufer auf die Basis des Gegners! +- **Siegbedingungen:** + - Basis des Gegners fällt auf 0 Leben → Sofortsieg! + - Nach 20 Wellen gewinnt, wer mehr Leben übrig hat (bei Gleichstand entscheiden die Punkte). + +### Multiplayer-Raum-System +- Klicke auf dem Startbildschirm auf **„Coop-Raum erstellen"** oder **„1v1-Raum erstellen"**. +- Gib deinem Mitspieler den **4-stelligen Raum-Code** (z. B. `5SK7`). +- Der Mitspieler tippt seinen Namen + den Code ein und klickt auf **„Beitreten"**. +- Der Host startet das Spiel, sobald beide in der Lobby sind. +- Funktioniert im lokalen Netzwerk (LAN / WLAN) – für Freunde über das Internet einfach Port 3001 freigeben oder über ngrok/Tailscale leiten. + +--- + +## Türme + +| Turm | Kosten | Upgrade 2/3 | Besonderheit | +| --- | --- | --- | --- | +| 🏹 Bogenturm | 50 🪙 | 60 / 110 🪙 | Günstig, schnelle Einzelschüsse, trifft Luft | +| 🧨 Kanone | 110 🪙 | 100 / 180 🪙 | Flächenschaden, trifft **keine** Flieger | +| ❄️ Eisturm | 80 🪙 | 70 / 130 🪙 | Verlangsamt alle Gegner im Umkreis um bis zu 65 % | +| ⚡ Teslaturm | 130 🪙 | 120 / 200 🪙 | Kettenblitz springt auf bis zu 5 Gegner über | +| 💫 Laserturm | 160 🪙 | 150 / 260 🪙 | Soforttreffer, maximale Reichweite & Durchschlag | + +- **Verkauf:** 70 % der Gesamtinvestition (Kauf + Upgrades) werden erstattet. +- **Zielmodi:** Erster / Letzter / Stärkster / Nächster. + +--- + +## Gegner + +- **🟢 Kriecher:** Standard-Einheit, gut für frühes Gold +- **🟡 Läufer:** Sehr schnell – Bogentürme oder Eisturm nötig +- **⬜ Panzer:** Zäh, kostet **2 Leben** bei Durchbruch +- **🟣 Flieger:** Ignorieren den Weg und fliegen auf halber Höhe geradeaus (nur Bogenturm, Eisturm, Tesla, Laser!) +- **🔴 Boss:** Riesige Lebensleiste, kostet **5 Leben** (Wellen 10, 15, 20 …) + +--- + +## Hindernisse + +Bäume (60 🪙) und Felsen (40 🪙) blockieren strategisch wichtige Bauplätze. +- **Klick auf das Hindernis** → „⛏ Entfernen" öffnet sich oben rechts. +- Nach dem Entfernen wird das Feld sofort bebaubar. + +--- + +## Steuerung + +- **1–5** – Turm zum Bauen auswählen, Klick auf freies Feld platziert ihn +- **Klick auf Turm** – Details, Upgrade, Verkauf, Zielmodus +- **Klick auf Hindernis** – Entfernen gegen Gold +- **Leertaste** – nächste Welle früh starten (Frühstart-Bonus!) / Pause (nur Solo) +- **U** Upgrade · **V** Verkaufen · **T** Zielmodus wechseln +- **P** Pause (nur Solo) · **M** Ton an/aus · **Esc** Abbrechen +- **Rechte Maustaste** – Bauauswahl aufheben + +--- + +## Technische Details + +- **Client-Side Simulation:** Determinister Lockstep-Tick-Loop (30 Hz) – beide Clients berechnen den exakten Spielverlauf parallel; der Server (`server/server.mjs`) vermittelt nur die Räume und leitet Aktionen weiter (minimaler Traffic, kein Lag bei der Bewegung). +- **Renderer:** Offscreen-gepufferter HTML-Canvas mit dynamischer Vollbild-Skalierung, Retina-DPR-Support, Partikelsystem und Mündungsfeuer. +- **Sound:** Web Audio API mit synthetisierten Klängen (keine externen Audio-Dateien). +- **Automatisierte Tests:** + - `npm run sim` – Headless-Bot spielt die Solo-Kampagne zur Balancing-Prüfung durch + - `npx tsx scripts/test-mp.mts` – Lockstep-Determinismus-Test (3600 Ticks, byte-identischer Zustand auf beiden Seiten) + - `npx tsx scripts/test-obstacle.mts` – Feature-Test: Hindernis-Entfernung diff --git a/index.html b/index.html new file mode 100644 index 0000000..85e719e --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + TRXTD – Tower Defense + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..23388ec --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2071 @@ +{ + "name": "trxtd", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trxtd", + "version": "1.0.0", + "dependencies": { + "vue": "^3.5.13", + "ws": "^8.21.3" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "@types/ws": "^8.18.1", + "@vitejs/plugin-vue": "^5.2.1", + "tsx": "^4.19.2", + "typescript": "~5.7.2", + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bcdfd57 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "trxtd", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "server": "node server/server.mjs", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview", + "sim": "tsx scripts/sim.mts" + }, + "dependencies": { + "vue": "^3.5.13", + "ws": "^8.21.3" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "@types/ws": "^8.18.1", + "@vitejs/plugin-vue": "^5.2.1", + "tsx": "^4.19.2", + "typescript": "~5.7.2", + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" + } +} diff --git a/scripts/sim.mts b/scripts/sim.mts new file mode 100644 index 0000000..51e38c7 --- /dev/null +++ b/scripts/sim.mts @@ -0,0 +1,163 @@ +/** + * Headless balance simulation: a greedy bot plays the game on 'normal' + * to verify the campaign is winnable (and not trivially easy). + * + * Run: npm run sim + */ +import { GameEngine } from '../src/game/engine.ts' +import type { TowerKind } from '../src/game/types.ts' + +const eng = new GameEngine() +eng.startGame('normal') + +// predetermined build spots (tile coords) near path hot spots +const spots: [number, number][] = [ + [8, 5], // center between the two long vertical runs + [3, 0], + [7, 0], + [11, 0], + [4, 4], + [12, 6], + [8, 8], + [8, 10], + [12, 10], + [16, 3], + [18, 4], + [15, 0], + [4, 7], + [8, 2], + [12, 3], + [7, 10], + [13, 10], + [1, 4], + [18, 6], + [16, 6], + [5, 0], + [9, 0], + [13, 0], + [8, 6], + [12, 7], + [4, 2], +] + +// what to build in which order (afterwards: upgrades only) +const plan: TowerKind[] = [ + 'arrow', + 'frost', + 'arrow', + 'cannon', + 'arrow', + 'tesla', + 'frost', + 'arrow', + 'cannon', + 'laser', + 'tesla', + 'laser', + 'frost', + 'cannon', + 'tesla', + 'arrow', + 'laser', + 'frost', + 'cannon', + 'tesla', + 'laser', + 'arrow', + 'arrow', + 'arrow', + 'arrow', + 'arrow', +] + +function bot(): void { + // spend greedily: cheapest sensible action first, repeat + for (;;) { + let acted = false + + // next planned build on the next free spot + const built = eng.towers.length + if (built < plan.length) { + const kind = plan[built] + const spot = spots[built % spots.length] + const cost = towerCost(kind) + if (eng.money >= cost && eng.canPlace(spot[0], spot[1])) { + if (!eng.placeTower(kind, spot[0], spot[1])) throw new Error('place failed at ' + spot) + acted = true + continue + } + // spot blocked -> skip this spot for that build + if (!eng.canPlace(spot[0], spot[1])) { + // try any other free spot + const alt = spots.find((s) => eng.canPlace(s[0], s[1])) + if (alt && eng.money >= cost) { + eng.placeTower(kind, alt[0], alt[1]) + acted = true + continue + } + } + } + + // cheapest upgrade among towers (max 3 levels) + let bestCost = Infinity + let bestT: ReturnType = undefined + for (const t of eng.towers) { + const c = upgradeCostOf(t.kind, t.level) + if (c !== null && c < bestCost) { + bestCost = c + bestT = t + } + } + if (bestT && bestCost <= eng.money) { + eng.upgradeTower(bestT) + acted = true + continue + } + + if (!acted) break + } + + // start next wave early for the bonus, like an aggressive player + if (eng.phase === 'intermission') eng.startWave(true) +} + +function towerCost(kind: TowerKind): number { + const costs: Record = { arrow: 50, cannon: 110, frost: 80, tesla: 130, laser: 160 } + return costs[kind] +} + +function upgradeCostOf(kind: TowerKind, level: number): number | null { + const up: Record = { + arrow: [60, 110], + cannon: [100, 180], + frost: [70, 130], + tesla: [120, 200], + laser: [150, 260], + } + if (level >= 3) return null + return up[kind][level - 1] +} + +let lastWave = 0 +let guard = 0 +while (eng.phase !== 'gameover' && eng.phase !== 'victory' && guard < 60 * 60 * 60) { + eng.update(1 / 60) + bot() + if (eng.waveNo !== lastWave) { + lastWave = eng.waveNo + const kindCounts: Record = {} + for (const t of eng.towers) kindCounts[t.kind + t.level] = (kindCounts[t.kind + t.level] ?? 0) + 1 + console.log( + `Welle ${String(eng.waveNo).padStart(2)} | ❤ ${String(eng.lives).padStart(2)} | 🪙 ${String(Math.floor(eng.money)).padStart(4)} | Türme: ${eng.towers.length} ${JSON.stringify(kindCounts)}`, + ) + } + guard++ +} + +console.log('---') +console.log(`Ergebnis: ${eng.phase === 'victory' ? 'SIEG' : 'NIEDERLAGE'} in Welle ${eng.waveNo}, Leben übrig: ${eng.lives}, Punkte: ${eng.score}`) +if (eng.phase !== 'victory') { + console.log(' → Balancing zu schwer! Gegner-Werte abschwächen.') +} else if (eng.lives > 14) { + console.log(' → Balancing zu leicht (zu viele Leben übrig).') +} diff --git a/scripts/test-mp.mts b/scripts/test-mp.mts new file mode 100644 index 0000000..ed516c5 --- /dev/null +++ b/scripts/test-mp.mts @@ -0,0 +1,72 @@ +/** + * Lockstep determinism check: two engines receiving the same actions at the + * same ticks must produce identical game state (the foundation of multiplayer + * without state sync). + */ +import { GameEngine } from '../src/game/engine.ts' + +const e1 = new GameEngine() +const e2 = new GameEngine() +for (const e of [e1, e2]) { + e.startGame('normal') + e.mpGameActive = true + e.mpMode = 'coop' + e.coopHpMul = 1.7 +} + +// scripted action stream (tick, action) – like the relayed server order +const script: [number, Parameters[0]][] = [ + [5, { type: 'build', kind: 'arrow', tx: 8, ty: 5 }], + [5, { type: 'build', kind: 'frost', tx: 3, ty: 0 }], + [10, { type: 'wave' }], + [200, { type: 'build', kind: 'cannon', tx: 7, ty: 0 }], + [400, { type: 'upgrade', towerId: 1 }], + [400, { type: 'build', kind: 'tesla', tx: 12, ty: 6 }], + [700, { type: 'wave' }], + [900, { type: 'obstacle', tx: 19, ty: 0 }], + [1200, { type: 'build', kind: 'laser', tx: 18, ty: 4 }], + [1500, { type: 'speed', s: 2 }], + [1800, { type: 'wave' }], +] + +let idx = 0 +const TOTAL_TICKS = 3600 // ~2 Minuten Spielzeit @30 Ticks/s +let p0: ReturnType = true +let p1 = p0 + +for (let tick = 1; tick <= TOTAL_TICKS; tick++) { + while (idx < script.length && script[idx][0] <= tick) { + const a = script[idx][1] + p0 = e1.applyAction(a, idx % 2) // alternate acting players + p1 = e2.applyAction(a, idx % 2) + idx++ + } + e1.update(1 / 30) + e2.update(1 / 30) +} + +function state(e: GameEngine): string { + return JSON.stringify({ + money: Math.floor(e.money * 1000), + lives: e.lives, + score: e.score, + kills: e.kills, + wave: e.waveNo, + phase: e.phase, + enemies: e.enemies.map((x) => [x.id, Math.round(x.hp * 1000), x.wp]), + towers: e.towers.map((t) => [t.id, t.kind, t.level, t.kills, Math.round(t.damageDealt)]), + queue: e.spawnQueue.map((s) => [Math.round(s.t * 1000), s.kind]), + blocked: e.blockedCells.size, + }) +} + +const s1 = state(e1) +const s2 = state(e2) +console.log('Aktionen ausgeführt:', idx, '| identisch:', p0 === p1) +console.log('Zustand identisch:', s1 === s2) +if (s1 !== s2) { + console.log('e1:', s1.slice(0, 400)) + console.log('e2:', s2.slice(0, 400)) + process.exit(1) +} +console.log(`OK: beide Engines identisch nach ${TOTAL_TICKS} Ticks | Welle ${e1.waveNo} | ❤ ${e1.lives} | 🪙 ${Math.floor(e1.money)} | Türme ${e1.towers.length}`) diff --git a/scripts/test-obstacle.mts b/scripts/test-obstacle.mts new file mode 100644 index 0000000..3ef1d37 --- /dev/null +++ b/scripts/test-obstacle.mts @@ -0,0 +1,43 @@ +import { GameEngine } from '../src/game/engine.ts' +import { store } from '../src/game/store.ts' + +const eng = new GameEngine() +eng.startGame('normal') + +// irgendein blockierendes Hindernis finden +const blocked = [...eng.blockedCells] +console.log('blockierte Zellen:', blocked.length) +const [tx, ty] = blocked[0].split(',').map(Number) +const item = eng.obstacleAt(tx, ty)! +console.log('Hindernis:', item.type, 'auf', tx, ty, '| canPlace davor:', eng.canPlace(tx, ty)) + +// per Klick auswählen (Klick auf Zellmitte); store wird in step() synchronisiert +eng.click(tx * 48 + 24, ty * 48 + 24) +eng.step(0) +console.log('Panel offen:', store.obstacle !== null, '| Typ:', store.obstacle?.type, '| Kosten:', store.obstacle?.cost) + +// zu wenig Gold -> Ablehnung +eng.money = 10 +eng.removeSelectedObstacle() +console.log('abgelehnt bei 10 Gold: Panel noch offen =', store.obstacle !== null, '| Gold =', eng.money) + +// genug Gold -> Entfernen klappt +eng.money = 500 +eng.removeSelectedObstacle() +eng.step(0) +console.log( + 'entfernt: Panel zu =', store.obstacle === null, + '| Gold -' + (500 - eng.money), + '| canPlace danach:', eng.canPlace(tx, ty), + '| Dekor weg:', eng.obstacleAt(tx, ty) === undefined, +) + +// Turm auf freigewordener Zelle bauen +console.log('Turmbau auf Zelle:', eng.placeTower('arrow', tx, ty)) + +// Neustart stellt Hindernisse wieder her +eng.startGame('normal') +console.log( + 'nach Neustart wieder blockiert:', eng.blockedCells.has(tx + ',' + ty), + '| Anzahl wie vorher:', eng.blockedCells.size === blocked.length, +) diff --git a/server/server.mjs b/server/server.mjs new file mode 100644 index 0000000..8ebe493 --- /dev/null +++ b/server/server.mjs @@ -0,0 +1,258 @@ +/** + * TRXTD multiplayer relay server with security hardening: + * - WebSocket maxPayload limit (prevents DoS) + * - Heartbeat (ping/pong) to clean dead sockets + * - Rate-limiting per connection (action flood protection) + * - Strict message schema validation + * - Inactive room cleanup timeout + */ +import { WebSocketServer } from 'ws' + +const PORT = Number(process.env.PORT || 3001) +const MAX_PAYLOAD_BYTES = 4096 // 4 KB is plenty for simple game actions +const MAX_ACTIONS_PER_SEC = 30 +const ROOM_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes max room life + +const wss = new WebSocketServer({ + port: PORT, + maxPayload: MAX_PAYLOAD_BYTES, +}) + +/** code -> { code, mode, created: number, seq: number, players: [{ ws, id, name, lastAct: number, actCount: number }] } */ +const rooms = new Map() + +const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + +function makeCode() { + let code + let attempts = 0 + do { + code = Array.from({ length: 4 }, () => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]).join('') + attempts++ + if (attempts > 1000) break + } while (rooms.has(code)) + return code +} + +function sanitizeName(raw) { + if (typeof raw !== 'string') return 'Spieler' + // keep alphanumeric, spaces, dashes and common accents, max 12 chars + const clean = raw.trim().replace(/[^\p{L}\p{N}_\- ]/gu, '').slice(0, 12) + return clean || 'Spieler' +} + +function send(ws, obj) { + if (ws && ws.readyState === 1) { + try { + ws.send(JSON.stringify(obj)) + } catch { + /* ignore socket send failure */ + } + } +} + +function roomOf(ws) { + const code = ws._room + return code ? rooms.get(code) : undefined +} + +function broadcast(room, obj, exceptWs) { + for (const p of room.players) { + if (p.ws !== exceptWs) send(p.ws, obj) + } +} + +function playerInfo(room) { + return room.players.map((p) => ({ id: p.id, name: p.name })) +} + +function leaveRoom(ws) { + const room = roomOf(ws) + if (!room) return + room.players = room.players.filter((p) => p.ws !== ws) + ws._room = undefined + if (room.players.length === 0) { + rooms.delete(room.code) + } else { + broadcast(room, { t: 'peer-left' }) + } +} + +function validateAction(a) { + if (!a || typeof a !== 'object') return false + const type = a.type + switch (type) { + case 'build': + return ( + ['arrow', 'cannon', 'frost', 'tesla', 'laser'].includes(a.kind) && + Number.isInteger(a.tx) && + a.tx >= 0 && + a.tx < 20 && + Number.isInteger(a.ty) && + a.ty >= 0 && + a.ty < 11 + ) + case 'upgrade': + case 'sell': + return Number.isInteger(a.towerId) && a.towerId > 0 + case 'targeting': + return ( + Number.isInteger(a.towerId) && + a.towerId > 0 && + ['first', 'last', 'strong', 'close'].includes(a.mode) + ) + case 'obstacle': + return Number.isInteger(a.tx) && a.tx >= 0 && a.tx < 20 && Number.isInteger(a.ty) && a.ty >= 0 && a.ty < 11 + case 'wave': + case 'rush': + return true + case 'speed': + return [1, 2, 3].includes(a.s) + default: + return false + } +} + +// Heartbeat interval to drop unresponsive clients +const pingInterval = setInterval(() => { + const now = Date.now() + for (const [code, room] of rooms.entries()) { + if (now - room.created > ROOM_TIMEOUT_MS) { + for (const p of room.players) { + send(p.ws, { t: 'error', msg: 'Raum-Zeitüberschreitung.' }) + p.ws._room = undefined + } + rooms.delete(code) + continue + } + } + + wss.clients.forEach((ws) => { + if (ws.isAlive === false) { + leaveRoom(ws) + return ws.terminate() + } + ws.isAlive = false + try { + ws.ping() + } catch { + leaveRoom(ws) + } + }) +}, 15000) + +wss.on('close', () => { + clearInterval(pingInterval) +}) + +wss.on('connection', (ws) => { + ws.isAlive = true + ws._actBucket = { count: 0, resetAt: Date.now() + 1000 } + + ws.on('pong', () => { + ws.isAlive = true + }) + + ws.on('message', (data) => { + ws.isAlive = true + if (typeof data !== 'string' && !Buffer.isBuffer(data)) return + + let m + try { + m = JSON.parse(data.toString()) + } catch { + return // drop invalid JSON silently + } + if (!m || typeof m !== 'object' || typeof m.t !== 'string') return + + // Rate-limiting check + const now = Date.now() + if (now > ws._actBucket.resetAt) { + ws._actBucket.count = 0 + ws._actBucket.resetAt = now + 1000 + } + ws._actBucket.count++ + if (ws._actBucket.count > MAX_ACTIONS_PER_SEC) { + send(ws, { t: 'error', msg: 'Zu viele Anfragen gesendet.' }) + return + } + + switch (m.t) { + case 'create': { + leaveRoom(ws) + if (m.mode !== 'coop' && m.mode !== 'duel') return + const code = makeCode() + const name = sanitizeName(m.name) + const room = { + code, + mode: m.mode, + created: Date.now(), + seq: 0, + players: [{ ws, id: 0, name }], + } + rooms.set(code, room) + ws._room = code + send(ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: 0 }) + break + } + case 'join': { + leaveRoom(ws) + const code = String(m.code || '').toUpperCase().trim() + const room = rooms.get(code) + if (!room) return send(ws, { t: 'error', msg: 'Raum nicht gefunden.' }) + if (room.players.length >= 2) return send(ws, { t: 'error', msg: 'Raum ist bereits voll.' }) + const name = sanitizeName(m.name) + room.players.push({ ws, id: 1, name }) + ws._room = code + for (const p of room.players) { + send(p.ws, { t: 'room', code, mode: room.mode, players: playerInfo(room), you: p.id }) + } + break + } + case 'start': { + const room = roomOf(ws) + if (!room || room.players.length !== 2) return + if (room.players[0].ws !== ws) return // only host may start + const seed = (Math.random() * 1e9) | 0 + for (const p of room.players) { + send(p.ws, { t: 'start', seed, mode: room.mode, players: playerInfo(room), you: p.id }) + } + break + } + case 'act': { + const room = roomOf(ws) + if (!room || room.players.length < 2) return + const from = room.players.findIndex((p) => p.ws === ws) + if (from === -1) return + if (!validateAction(m.a)) return + if (!Number.isInteger(m.tick) || m.tick < 0) return + + room.seq = (room.seq || 0) + 1 + for (const p of room.players) { + send(p.ws, { t: 'act', seq: room.seq, from, tick: m.tick, a: m.a }) + } + break + } + case 'prog': { + const room = roomOf(ws) + if (!room) return + const from = room.players.findIndex((p) => p.ws === ws) + if (from === -1) return + if (typeof m.tick !== 'number') return + broadcast(room, { t: 'prog', from, tick: m.tick }) + break + } + case 'leave': { + leaveRoom(ws) + break + } + } + }) + + ws.on('close', () => leaveRoom(ws)) + ws.on('error', () => leaveRoom(ws)) +}) + +wss.on('listening', () => { + console.log(`TRXTD Multiplayer-Server läuft auf ws://localhost:${PORT}`) +}) diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..17edb9a --- /dev/null +++ b/src/App.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/components/EndOverlay.vue b/src/components/EndOverlay.vue new file mode 100644 index 0000000..7638210 --- /dev/null +++ b/src/components/EndOverlay.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/src/components/GameCanvas.vue b/src/components/GameCanvas.vue new file mode 100644 index 0000000..2b7d77c --- /dev/null +++ b/src/components/GameCanvas.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/src/components/Hud.vue b/src/components/Hud.vue new file mode 100644 index 0000000..ec5decd --- /dev/null +++ b/src/components/Hud.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/src/components/LobbyScreen.vue b/src/components/LobbyScreen.vue new file mode 100644 index 0000000..1abf820 --- /dev/null +++ b/src/components/LobbyScreen.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/src/components/MPBar.vue b/src/components/MPBar.vue new file mode 100644 index 0000000..9bb908a --- /dev/null +++ b/src/components/MPBar.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/src/components/ObstaclePanel.vue b/src/components/ObstaclePanel.vue new file mode 100644 index 0000000..16192d1 --- /dev/null +++ b/src/components/ObstaclePanel.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/components/PauseOverlay.vue b/src/components/PauseOverlay.vue new file mode 100644 index 0000000..f9f5965 --- /dev/null +++ b/src/components/PauseOverlay.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/src/components/PiPCanvas.vue b/src/components/PiPCanvas.vue new file mode 100644 index 0000000..96ad7b7 --- /dev/null +++ b/src/components/PiPCanvas.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/src/components/StartScreen.vue b/src/components/StartScreen.vue new file mode 100644 index 0000000..b92aa34 --- /dev/null +++ b/src/components/StartScreen.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/src/components/TowerPanel.vue b/src/components/TowerPanel.vue new file mode 100644 index 0000000..8d93ff0 --- /dev/null +++ b/src/components/TowerPanel.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/src/components/TowerShop.vue b/src/components/TowerShop.vue new file mode 100644 index 0000000..0a8405c --- /dev/null +++ b/src/components/TowerShop.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/game/config.ts b/src/game/config.ts new file mode 100644 index 0000000..e496a37 --- /dev/null +++ b/src/game/config.ts @@ -0,0 +1,303 @@ +import type { DifficultyDef, DifficultyId, EnemyDef, EnemyKind, TowerDef, TowerKind, WaveGroup } from './types' + +export const TILE = 48 +export const COLS = 20 +export const ROWS = 11 +export const W = COLS * TILE // 960 +export const H = ROWS * TILE // 528 +export const TOTAL_WAVES = 20 +export const INTERMISSION = 20 // seconds between waves +export const SELL_RATIO = 0.7 + +/** cost to remove blocking obstacles */ +export const OBSTACLE_COST: Record<'tree' | 'rock', number> = { + tree: 60, + rock: 40, +} + +/** ground path in tile coords; -1/20 are outside the grid (spawn/exit) */ +export const MAP_WAYPOINTS: [number, number][] = [ + [-1, 5], + [2, 5], + [2, 1], + [6, 1], + [6, 9], + [10, 9], + [10, 1], + [14, 1], + [14, 9], + [17, 9], + [17, 5], + [20, 5], +] + +/** flying enemies fly straight across the middle */ +export const FLY_WAYPOINTS: [number, number][] = [ + [-1, 5], + [20, 5], +] + +export const TOWER_ORDER: TowerKind[] = ['arrow', 'cannon', 'frost', 'tesla', 'laser'] + +export const TOWERS: Record = { + arrow: { + kind: 'arrow', + name: 'Bogenturm', + icon: '🏹', + desc: 'Günstiger Standardturm mit schnellen Einzelschüssen.', + cost: 50, + upgradeCost: [60, 110], + levels: [ + { damage: 12, range: 110, rate: 1.7 }, + { damage: 22, range: 120, rate: 2.0 }, + { damage: 38, range: 132, rate: 2.4 }, + ], + hitsFlying: true, + projectileSpeed: 460, + color: '#9a6a33', + accent: '#e8c27a', + hotkey: '1', + }, + cannon: { + kind: 'cannon', + name: 'Kanone', + icon: '🧨', + desc: 'Flächenschaden durch Explosion – trifft aber keine Flieger.', + cost: 110, + upgradeCost: [100, 180], + levels: [ + { damage: 28, range: 118, rate: 0.55 }, + { damage: 50, range: 128, rate: 0.62 }, + { damage: 92, range: 140, rate: 0.7 }, + ], + hitsFlying: false, + projectileSpeed: 300, + splash: [42, 48, 56], + color: '#57616e', + accent: '#ff9c40', + hotkey: '2', + }, + frost: { + kind: 'frost', + name: 'Eisturm', + icon: '❄️', + desc: 'Frostpuls verlangsamt ALLE Gegner in Reichweite (auch Flieger).', + cost: 80, + upgradeCost: [70, 130], + levels: [ + { damage: 5, range: 95, rate: 0.9 }, + { damage: 9, range: 105, rate: 1.0 }, + { damage: 15, range: 118, rate: 1.1 }, + ], + hitsFlying: true, + slowFactor: [0.55, 0.45, 0.35], + slowDuration: [1.5, 1.9, 2.3], + color: '#4d8fb5', + accent: '#aef0ff', + hotkey: '3', + }, + tesla: { + kind: 'tesla', + name: 'Teslaturm', + icon: '⚡', + desc: 'Kettenblitz springt auf mehrere Gegner über.', + cost: 130, + upgradeCost: [120, 200], + levels: [ + { damage: 22, range: 105, rate: 1.0 }, + { damage: 38, range: 115, rate: 1.1 }, + { damage: 66, range: 128, rate: 1.2 }, + ], + hitsFlying: true, + chain: [3, 4, 5], + chainRange: 100, + color: '#6a5fb0', + accent: '#c8b4ff', + hotkey: '4', + }, + laser: { + kind: 'laser', + name: 'Laserturm', + icon: '💫', + desc: 'Soforttreffer mit hoher Reichweite und hohem Schaden.', + cost: 160, + upgradeCost: [150, 260], + levels: [ + { damage: 60, range: 170, rate: 0.5 }, + { damage: 105, range: 185, rate: 0.55 }, + { damage: 185, range: 205, rate: 0.6 }, + ], + hitsFlying: true, + color: '#b0483f', + accent: '#ff8a7a', + hotkey: '5', + }, +} + +export const ENEMIES: Record = { + normal: { + kind: 'normal', + name: 'Kriecher', + hp: 55, + speed: 55, + reward: 8, + radius: 11, + dmg: 1, + flying: false, + color: '#58c14b', + }, + fast: { + kind: 'fast', + name: 'Läufer', + hp: 34, + speed: 105, + reward: 9, + radius: 9, + dmg: 1, + flying: false, + color: '#ffd23e', + }, + tank: { + kind: 'tank', + name: 'Panzer', + hp: 210, + speed: 34, + reward: 20, + radius: 14, + dmg: 2, + flying: false, + color: '#7d8894', + }, + flyer: { + kind: 'flyer', + name: 'Flieger', + hp: 48, + speed: 72, + reward: 10, + radius: 10, + dmg: 1, + flying: true, + color: '#b06ee8', + }, + boss: { + kind: 'boss', + name: 'Boss', + hp: 1500, + speed: 42, + reward: 130, + radius: 20, + dmg: 5, + flying: false, + color: '#d84b3f', + }, +} + +/** hp multiplier for wave n */ +export function waveHpScale(n: number): number { + const m = n - 1 + return 1 + 0.18 * m + 0.02 * m * m +} + +/** speed multiplier for wave n */ +export function waveSpeedScale(n: number): number { + return Math.min(1.32, 1 + 0.008 * (n - 1)) +} + +/** reward multiplier for wave n */ +export function waveRewardScale(n: number): number { + return 1 + 0.04 * (n - 1) +} + +export function waveClearBonus(n: number): number { + return 35 + 8 * n +} + +export function earlyCallBonus(remainingSeconds: number): number { + return Math.round(remainingSeconds * 2.5) +} + +const G = (kind: EnemyKind, count: number, interval: number): WaveGroup => ({ kind, count, interval }) + +/** hand-tuned waves 1..20 */ +export const WAVES: WaveGroup[][] = [ + /* 1 */ [G('normal', 8, 1.1)], + /* 2 */ [G('normal', 12, 0.9)], + /* 3 */ [G('normal', 8, 0.9), G('fast', 5, 0.7)], + /* 4 */ [G('fast', 10, 0.55), G('normal', 8, 0.8)], + /* 5 */ [G('normal', 10, 0.75), G('tank', 3, 2.2)], + /* 6 */ [G('fast', 12, 0.5), G('tank', 4, 2.0)], + /* 7 */ [G('flyer', 8, 0.9), G('normal', 10, 0.7)], + /* 8 */ [G('fast', 10, 0.5), G('flyer', 10, 0.7)], + /* 9 */ [G('tank', 6, 1.6), G('fast', 12, 0.45), G('normal', 10, 0.6)], + /* 10 */ [G('boss', 1, 1), G('normal', 12, 0.8)], + /* 11 */ [G('flyer', 14, 0.55), G('fast', 12, 0.45)], + /* 12 */ [G('tank', 8, 1.3), G('normal', 16, 0.5)], + /* 13 */ [G('fast', 20, 0.35), G('flyer', 12, 0.5)], + /* 14 */ [G('tank', 10, 1.1), G('fast', 16, 0.4)], + /* 15 */ [G('boss', 2, 8), G('flyer', 10, 0.5)], + /* 16 */ [G('normal', 24, 0.35), G('tank', 8, 1.0)], + /* 17 */ [G('fast', 24, 0.3), G('flyer', 16, 0.45)], + /* 18 */ [G('tank', 12, 0.9), G('normal', 20, 0.4)], + /* 19 */ [G('fast', 20, 0.3), G('flyer', 20, 0.4), G('tank', 8, 0.9)], + /* 20 */ [G('tank', 8, 1.0), G('boss', 2, 8), G('fast', 16, 0.35)], +] + +/** endless mode waves after 20 */ +export function endlessWave(n: number): WaveGroup[] { + const m = n - 20 + const groups: WaveGroup[] = [] + if (n % 5 === 0) groups.push(G('boss', 1 + Math.floor(m / 5), 8)) + groups.push(G('tank', 8 + m * 2, Math.max(0.5, 1.1 - m * 0.02))) + groups.push(G('fast', 18 + m * 3, Math.max(0.15, 0.32 - m * 0.01))) + groups.push(G('flyer', 14 + m * 2, Math.max(0.25, 0.45 - m * 0.01))) + groups.push(G('normal', 20 + m * 4, Math.max(0.18, 0.35 - m * 0.01))) + return groups +} + +export function getWave(n: number): WaveGroup[] { + if (n <= WAVES.length) return WAVES[n - 1] + return endlessWave(n) +} + +export const DIFFICULTIES: Record = { + easy: { + id: 'easy', + name: 'Leicht', + desc: '30 Leben, mehr Startgold, schwächere Gegner', + lives: 30, + money: 320, + hpMul: 0.85, + }, + normal: { + id: 'normal', + name: 'Normal', + desc: '20 Leben, ausgewogenes Balancing', + lives: 20, + money: 260, + hpMul: 1, + }, + hard: { + id: 'hard', + name: 'Schwer', + desc: '12 Leben, weniger Gold, zähere Gegner', + lives: 12, + money: 220, + hpMul: 1.15, + }, +} + +export const ENEMY_COLORS: Record = { + normal: '#58c14b', + fast: '#ffd23e', + tank: '#7d8894', + flyer: '#b06ee8', + boss: '#d84b3f', +} + +export const ENEMY_ICONS: Record = { + normal: '🟢', + fast: '🟡', + tank: '⬜', + flyer: '🟣', + boss: '🔴', +} diff --git a/src/game/engine.ts b/src/game/engine.ts new file mode 100644 index 0000000..09a9538 --- /dev/null +++ b/src/game/engine.ts @@ -0,0 +1,1293 @@ +import { + DIFFICULTIES, + ENEMIES, + FLY_WAYPOINTS, + INTERMISSION, + MAP_WAYPOINTS, + OBSTACLE_COST, + SELL_RATIO, + TILE, + TOWERS, + TOTAL_WAVES, + earlyCallBonus, + getWave, + waveClearBonus, + waveHpScale, + waveRewardScale, + waveSpeedScale, +} from './config' +import { sound } from './sound' +import { bestScoreKey, loadBest, store } from './store' +import type { + DecorItem, + DifficultyId, + Effect, + Enemy, + EnemyKind, + MPAction, + MPMode, + Phase, + Projectile, + Pt, + SelectedTowerInfo, + TargetingMode, + Tower, + TowerKind, +} from './types' +import type { SfxName } from './sound' +import { angleDiff, angleTo, cellKey, clamp, dist, lerpAngle, mulberry32 } from './utils' +import { Renderer } from './render' + +/** implemented by the multiplayer controller (mpgame.ts) */ +export interface MPController { + frame(dtReal: number): void +} + +interface SpawnEntry { + t: number + kind: EnemyKind +} + +const TARGETING_LABEL: Record = { + first: 'Erster', + last: 'Letzter', + strong: 'Stärkster', + close: 'Nächster', +} + +export class GameEngine { + enemies: Enemy[] = [] + towers: Tower[] = [] + projectiles: Projectile[] = [] + effects: Effect[] = [] + decor: DecorItem[] = [] + spawnQueue: SpawnEntry[] = [] + + pathPx: Pt[] = [] + flyPathPx: Pt[] = [] + pathCells = new Set() + blockedCells = new Set() + + money = 0 + lives = 0 + maxLives = 0 + waveNo = 0 + score = 0 + kills = 0 + time = 0 + shake = 0 + phase: Phase = 'idle' + intermission = 0 + paused = false + speed = 1 + endless = false + difficulty: DifficultyId = 'normal' + + buildType: TowerKind | null = null + hover: { x: number; y: number; tx: number; ty: number; valid: boolean } | null = null + selectedId: number | null = null + selectedObstacle: { tx: number; ty: number } | null = null + + /** multiplayer: this engine takes part in a networked game */ + mpGameActive = false + mpMode: 'solo' | MPMode = 'solo' + localPlayerId = 0 + /** coop: enemy hp multiplier for two defenders */ + coopHpMul = 1 + /** ghost engines (remote boards in duel mode) produce no sound/effects */ + ghost = false + mpController: MPController | null = null + + private nextId = 1 + private renderer: Renderer | null = null + private raf = 0 + private lastFrame = 0 + + constructor() { + this.buildMap() + } + + /** sound/effect helpers so remote (ghost) boards stay silent */ + sfx(name: SfxName): void { + if (!this.ghost) sound.play(name) + } + + fx(e: Effect): void { + if (!this.ghost) this.effects.push(e) + } + + // ---------------------------------------------------------------- map + + private buildMap(): void { + const toPx = (p: [number, number]): Pt => ({ x: p[0] * TILE + TILE / 2, y: p[1] * TILE + TILE / 2 }) + this.pathPx = MAP_WAYPOINTS.map(toPx) + this.flyPathPx = FLY_WAYPOINTS.map(toPx) + + for (let i = 0; i < MAP_WAYPOINTS.length - 1; i++) { + const [ax, ay] = MAP_WAYPOINTS[i] + const [bx, by] = MAP_WAYPOINTS[i + 1] + const dx = Math.sign(bx - ax) + const dy = Math.sign(by - ay) + let x = ax + let y = ay + this.markPathCell(x, y) + while (x !== bx || y !== by) { + x += dx + y += dy + this.markPathCell(x, y) + } + } + + // deterministic decoration + this.decor = [] + this.blockedCells = new Set() + const rng = mulberry32(20260815) + const cellDistToPath = (tx: number, ty: number): number => { + let best = Infinity + for (const key of this.pathCells) { + const [px, py] = key.split(',').map(Number) + best = Math.min(best, Math.hypot(px - tx, py - ty)) + } + return best + } + const decorCells = new Set() + const tryDecor = (type: DecorItem['type'], count: number, minDist: number, maxDist: number): void => { + let placed = 0 + let attempts = 0 + while (placed < count && attempts < 800) { + attempts++ + const tx = Math.floor(rng() * 20) + const ty = Math.floor(rng() * 11) + const key = cellKey(tx, ty) + if (this.pathCells.has(key) || decorCells.has(key)) continue + const d = cellDistToPath(tx, ty) + if (d < minDist || d > maxDist) continue + decorCells.add(key) + if (type === 'tree' || type === 'rock') this.blockedCells.add(key) + this.decor.push({ type, x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2, s: 0.8 + rng() * 0.4, seed: rng() }) + placed++ + } + } + tryDecor('tree', 7, 2.1, 99) + tryDecor('rock', 5, 1.6, 99) + tryDecor('bush', 10, 1.05, 99) + tryDecor('flower', 22, 0, 99) + } + + private markPathCell(x: number, y: number): void { + if (x >= 0 && x < 20 && y >= 0 && y < 11) this.pathCells.add(cellKey(x, y)) + } + + canPlace(tx: number, ty: number): boolean { + if (tx < 0 || tx >= 20 || ty < 0 || ty >= 11) return false + const key = cellKey(tx, ty) + if (this.pathCells.has(key) || this.blockedCells.has(key)) return false + return !this.towers.some((t) => t.tx === tx && t.ty === ty) + } + + obstacleAt(tx: number, ty: number): (DecorItem & { type: 'tree' | 'rock' }) | undefined { + return this.decor.find( + (d): d is DecorItem & { type: 'tree' | 'rock' } => + (d.type === 'tree' || d.type === 'rock') && + Math.floor(d.x / TILE) === tx && + Math.floor(d.y / TILE) === ty, + ) + } + + removeSelectedObstacle(): void { + const sel = this.selectedObstacle + if (!sel) return + const item = this.obstacleAt(sel.tx, sel.ty) + if (!item) { + this.selectedObstacle = null + return + } + const cost = OBSTACLE_COST[item.type] + if (this.money < cost) { + this.sfx('error') + return + } + this.money -= cost + this.decor = this.decor.filter((d) => d !== item) + this.blockedCells.delete(cellKey(sel.tx, sel.ty)) + this.selectedObstacle = null + this.sfx('sell') + const color = item.type === 'tree' ? '#3b7c3c' : '#8b9298' + this.fx({ type: 'ring', x: item.x, y: item.y, r0: 6, r1: 38, life: 0.35, max: 0.35, color: '#d8c39a', width: 3 }) + for (let i = 0; i < 16; i++) { + const a = Math.random() * Math.PI * 2 + const sp = 40 + Math.random() * 90 + this.fx({ + type: 'particle', + x: item.x, + y: item.y, + vx: Math.cos(a) * sp, + vy: Math.sin(a) * sp - 60, + life: 0.5 + Math.random() * 0.3, + max: 0.8, + size: 2 + Math.random() * 3, + color: Math.random() < 0.7 ? color : '#d8c39a', + grav: 260, + }) + } + } + + // ---------------------------------------------------------------- lifecycle + + startGame(diff: DifficultyId): void { + const d = DIFFICULTIES[diff] + this.difficulty = diff + this.buildMap() // restores removed obstacles + this.enemies = [] + this.towers = [] + this.projectiles = [] + this.effects = [] + this.spawnQueue = [] + this.money = d.money + this.lives = d.lives + this.maxLives = d.lives + this.waveNo = 0 + this.score = 0 + this.kills = 0 + this.time = 0 + this.shake = 0 + this.endless = false + this.buildType = null + this.selectedId = null + this.selectedObstacle = null + this.hover = null + this.paused = false + this.speed = 1 + this.phase = 'intermission' + this.intermission = INTERMISSION + + store.screen = 'game' + store.paused = false + store.speed = 1 + store.muted = sound.muted + store.difficulty = diff + store.money = this.money + store.lives = this.lives + store.maxLives = this.lives + store.waveNo = 0 + store.endless = false + store.score = 0 + store.kills = 0 + store.phase = 'intermission' + store.nextWaveIn = INTERMISSION + store.waveActive = false + store.buildType = null + store.selected = null + store.obstacle = null + store.result = null + store.nextPreview = this.previewOf(1) + + sound.ensure() + this.announce('Bereit machen!', 'Welle 1 kommt in Kürze') + } + + continueEndless(): void { + this.endless = true + this.phase = 'intermission' + this.intermission = INTERMISSION + store.screen = 'game' + store.endless = true + store.phase = 'intermission' + store.result = null + store.selected = null + this.selectedId = null + store.nextPreview = this.previewOf(this.waveNo + 1) + this.announce('Endlos-Modus!', 'Wie weit kommst du?') + } + + attach(canvas: HTMLCanvasElement): void { + this.renderer = new Renderer(canvas) + this.renderer.render(this) + this.startLoop() + } + + /** adapts render resolution to a new CSS size (fullscreen layout) */ + resizeCanvas(cssW: number, cssH: number): void { + this.renderer?.setCssSize(cssW, cssH) + } + + detach(): void { + this.stopLoop() + this.renderer?.destroy() + this.renderer = null + } + + private intervalTimer: ReturnType | null = null + + private startLoop(): void { + if (typeof requestAnimationFrame === 'undefined') return + this.stopLoop() + this.lastFrame = performance.now() + const tick = (now: number): void => { + const dtReal = clamp((now - this.lastFrame) / 1000, 0, 0.1) + this.lastFrame = now + this.step(dtReal) + this.raf = requestAnimationFrame(tick) + } + this.raf = requestAnimationFrame(tick) + + // background tab fallback: setInterval runs when rAF is throttled + this.intervalTimer = setInterval(() => { + const now = performance.now() + const elapsed = (now - this.lastFrame) / 1000 + // if rAF hasn't fired for >100ms, the tab is in background – step forward + if (elapsed > 0.1) { + const dtReal = clamp(elapsed, 0, 0.2) + this.lastFrame = now + this.step(dtReal) + } + }, 50) + } + + private stopLoop(): void { + if (this.raf && typeof cancelAnimationFrame !== 'undefined') cancelAnimationFrame(this.raf) + this.raf = 0 + if (this.intervalTimer) clearInterval(this.intervalTimer) + this.intervalTimer = null + } + + /** one realtime frame: advance simulation (speed-scaled, sub-stepped) and render */ + step(dtReal: number): void { + if (this.mpController) { + // multiplayer lockstep: the controller advances both engines in sync ticks + this.mpController.frame(dtReal) + this.syncStore() + this.renderer?.render(this) + return + } + const active = store.screen === 'game' && !this.paused + if (active) { + let dt = dtReal * this.speed + while (dt > 0) { + const s = Math.min(dt, 1 / 30) + this.update(s) + dt -= s + } + } + this.syncStore() + this.renderer?.render(this) + } + + // ---------------------------------------------------------------- simulation + + update(dt: number): void { + this.time += dt + if (this.shake > 0) this.shake = Math.max(0, this.shake - dt * 3) + + if (this.phase === 'intermission') { + this.intermission -= dt + if (this.intermission <= 0) this.startWave(false) + } + + while (this.spawnQueue.length > 0 && this.spawnQueue[0].t <= this.time) { + const e = this.spawnQueue.shift()! + this.spawnEnemy(e.kind) + } + + for (const e of this.enemies) this.updateEnemy(e, dt) + for (const t of this.towers) this.updateTower(t, dt) + this.updateProjectiles(dt) + this.updateEffects(dt) + + this.enemies = this.enemies.filter((e) => !e.dead && !e.escaped) + this.projectiles = this.projectiles.filter((p) => p.life > 0) + + if (this.phase === 'wave' && this.spawnQueue.length === 0 && this.enemies.length === 0) { + this.waveCleared() + } + } + + private previewOf(n: number): { kind: EnemyKind; count: number }[] { + const merged = new Map() + for (const g of getWave(n)) merged.set(g.kind, (merged.get(g.kind) ?? 0) + g.count) + return [...merged.entries()].map(([kind, count]) => ({ kind, count })) + } + + startWave(early: boolean): void { + if (this.phase !== 'intermission') return + if (early && this.intermission > 0.5) { + const bonus = earlyCallBonus(this.intermission) + this.money += bonus + const base = this.pathPx[this.pathPx.length - 1] + this.fx({ + type: 'text', + x: base.x - 40, + y: base.y - 40, + vy: -26, + life: 1.4, + max: 1.4, + str: `+${bonus} 🪙 Frühstart`, + color: '#ffd23e', + size: 15, + }) + } + this.waveNo++ + this.phase = 'wave' + this.intermission = 0 + + const groups = getWave(this.waveNo) + let t = this.time + 1.0 + for (const g of groups) { + for (let i = 0; i < g.count; i++) { + this.spawnQueue.push({ t, kind: g.kind }) + t += g.interval + } + t += 1.4 + } + + store.waveNo = this.waveNo + store.phase = 'wave' + store.waveActive = true + store.nextPreview = [] + this.sfx('wave') + const isBoss = groups.some((g) => g.kind === 'boss') + this.announce(`Welle ${this.waveNo}`, isBoss ? 'Boss-Welle!' : `${groups.reduce((a, g) => a + g.count, 0)} Gegner`) + } + + private waveCleared(): void { + const bonus = waveClearBonus(this.waveNo) + this.money += bonus + this.score += 40 + 12 * this.waveNo + this.fx({ + type: 'text', + x: this.pathPx[this.pathPx.length - 1].x - 40, + y: this.pathPx[this.pathPx.length - 1].y - 30, + vy: -22, + life: 1.5, + max: 1.5, + str: `+${bonus} 🪙 Wellen-Bonus`, + color: '#ffd23e', + size: 14, + }) + + if (this.waveNo >= TOTAL_WAVES && !this.endless) { + this.victory() + return + } + this.phase = 'intermission' + this.intermission = INTERMISSION + store.phase = 'intermission' + store.waveActive = false + store.nextWaveIn = INTERMISSION + store.nextPreview = this.previewOf(this.waveNo + 1) + } + + private victory(): void { + this.phase = 'victory' + this.score += this.lives * 25 + this.sfx('victory') + for (let i = 0; i < 60; i++) { + this.fx({ + type: 'particle', + x: 200 + Math.random() * 560, + y: -10, + vx: (Math.random() - 0.5) * 60, + vy: 60 + Math.random() * 120, + life: 2.5, + max: 2.5, + size: 3 + Math.random() * 4, + color: ['#ffd23e', '#58c14b', '#b06ee8', '#7fd8ff'][Math.floor(Math.random() * 4)], + grav: 40, + }) + } + this.finish(true) + } + + private defeat(): void { + this.phase = 'gameover' + this.shake = 1 + this.sfx('defeat') + this.finish(false) + } + + private finish(win: boolean): void { + if (this.mpGameActive) return // multiplayer results are handled by the controller + const bestBefore = loadBest(this.difficulty) + const best = Math.max(bestBefore, this.score) + if (typeof localStorage !== 'undefined') { + localStorage.setItem(bestScoreKey(this.difficulty), String(best)) + } + store.result = { win, score: this.score, wave: this.waveNo, kills: this.kills, best, bestBefore } + store.screen = win ? 'victory' : 'gameover' + } + + private spawnEnemy(kind: EnemyKind): void { + const def = ENEMIES[kind] + const diff = DIFFICULTIES[this.difficulty] + const hp = Math.round(def.hp * diff.hpMul * this.coopHpMul * waveHpScale(this.waveNo)) + const path = def.flying ? this.flyPathPx : this.pathPx + this.enemies.push({ + id: this.nextId++, + kind, + flying: def.flying, + x: path[0].x, + y: path[0].y, + wp: 1, + hp, + maxHp: hp, + speedBase: def.speed * waveSpeedScale(this.waveNo), + reward: Math.max(1, Math.round(def.reward * waveRewardScale(this.waveNo))), + dmg: def.dmg, + r: def.radius, + traveled: 0, + slowUntil: 0, + slowFactor: 1, + dead: false, + escaped: false, + flash: 0, + wobble: Math.random() * Math.PI * 2, + laneOff: (Math.random() - 0.5) * 12, + heading: 0, + }) + } + + private updateEnemy(e: Enemy, dt: number): void { + if (e.dead || e.escaped) return + e.flash = Math.max(0, e.flash - dt * 6) + const slowed = this.time < e.slowUntil + const speed = e.speedBase * (slowed ? e.slowFactor : 1) + const path = e.flying ? this.flyPathPx : this.pathPx + let move = speed * dt + while (move > 0 && e.wp < path.length) { + const b = path[e.wp] + const d = dist(e.x, e.y, b.x, b.y) + if (d <= move) { + e.x = b.x + e.y = b.y + move -= d + e.wp++ + } else { + e.heading = angleTo(e.x, e.y, b.x, b.y) + e.x += ((b.x - e.x) / d) * move + e.y += ((b.y - e.y) / d) * move + move = 0 + } + } + e.traveled += speed * dt + if (e.wp >= path.length) this.leak(e) + } + + private leak(e: Enemy): void { + e.escaped = true + this.lives -= e.dmg + this.shake = Math.min(1, 0.3 + e.dmg * 0.1) + this.sfx('leak') + const base = this.pathPx[this.pathPx.length - 1] + this.fx({ + type: 'text', + x: base.x - 30, + y: base.y - 50, + vy: -30, + life: 1.2, + max: 1.2, + str: `−${e.dmg} ❤`, + color: '#ff5f4e', + size: 18, + }) + if (this.lives <= 0) { + this.lives = 0 + this.defeat() + } + } + + // ---------------------------------------------------------------- towers + + towerAt(tx: number, ty: number): Tower | undefined { + return this.towers.find((t) => t.tx === tx && t.ty === ty) + } + + placeTower(kind: TowerKind, tx: number, ty: number, owner: number | null = null): boolean { + const def = TOWERS[kind] + if (!this.canPlace(tx, ty) || this.money < def.cost) { + this.sfx('error') + return false + } + this.money -= def.cost + this.towers.push({ + id: this.nextId++, + kind, + tx, + ty, + x: tx * TILE + TILE / 2, + y: ty * TILE + TILE / 2, + level: 1, + cooldown: 0, + angle: -Math.PI / 2, + kills: 0, + damageDealt: 0, + targeting: 'first', + invested: def.cost, + recoil: 0, + pulse: 0, + targetId: null, + owner, + }) + this.sfx('build') + this.fx({ type: 'ring', x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2, r0: 6, r1: 40, life: 0.35, max: 0.35, color: def.accent, width: 3 }) + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2 + this.fx({ + type: 'particle', + x: tx * TILE + TILE / 2 + Math.cos(a) * 14, + y: ty * TILE + TILE / 2 + Math.sin(a) * 14, + vx: Math.cos(a) * 50, + vy: Math.sin(a) * 50 - 30, + life: 0.4, + max: 0.4, + size: 2.5, + color: '#d8c39a', + grav: 120, + }) + } + return true + } + + selectedTower(): Tower | undefined { + return this.selectedId === null ? undefined : this.towers.find((t) => t.id === this.selectedId) + } + + upgradeSelected(): void { + const t = this.selectedTower() + if (!t) return + this.upgradeTower(t) + } + + upgradeTower(t: Tower): void { + const def = TOWERS[t.kind] + if (t.level >= 3) return + const cost = def.upgradeCost[t.level - 1] + if (this.money < cost) { + this.sfx('error') + return + } + this.money -= cost + t.invested += cost + t.level = (t.level + 1) as Tower['level'] + this.sfx('upgrade') + this.fx({ type: 'ring', x: t.x, y: t.y, r0: 8, r1: 52, life: 0.4, max: 0.4, color: '#ffd23e', width: 3 }) + this.fx({ type: 'text', x: t.x, y: t.y - 26, vy: -30, life: 1, max: 1, str: 'Level ' + t.level, color: '#ffd23e', size: 13 }) + } + + sellSelected(): void { + const t = this.selectedTower() + if (!t) return + this.sellTower(t) + } + + sellTower(t: Tower): void { + const value = Math.round(t.invested * SELL_RATIO) + this.money += value + this.towers = this.towers.filter((x) => x.id !== t.id) + if (this.selectedId === t.id) this.selectedId = null + this.sfx('sell') + this.fx({ type: 'text', x: t.x, y: t.y - 10, vy: -28, life: 1.1, max: 1.1, str: `+${value} 🪙`, color: '#ffd23e', size: 13 }) + } + + setTargeting(t: Tower, mode: TargetingMode): void { + t.targeting = mode + this.sfx('click') + } + + cycleTargetingSelected(): void { + const t = this.selectedTower() + if (!t) return + const modes: TargetingMode[] = ['first', 'last', 'strong', 'close'] + this.setTargeting(t, modes[(modes.indexOf(t.targeting) + 1) % modes.length]) + } + + private towerStats(t: Tower) { + return TOWERS[t.kind].levels[t.level - 1] + } + + private acquireTarget(t: Tower): Enemy | undefined { + const def = TOWERS[t.kind] + const stats = this.towerStats(t) + let best: Enemy | undefined + let bestScore = -Infinity + for (const e of this.enemies) { + if (e.dead || e.escaped) continue + if (e.flying && !def.hitsFlying) continue + const d = dist(t.x, t.y, e.x, e.y) + if (d > stats.range + e.r) continue + let s: number + switch (t.targeting) { + case 'first': + s = e.traveled + break + case 'last': + s = -e.traveled + break + case 'strong': + s = e.hp + break + case 'close': + s = -d + break + } + if (s > bestScore) { + bestScore = s + best = e + } + } + return best + } + + private updateTower(t: Tower, dt: number): void { + const def = TOWERS[t.kind] + const stats = this.towerStats(t) + t.cooldown -= dt + t.recoil = Math.max(0, t.recoil - dt * 5) + t.pulse = Math.max(0, t.pulse - dt * 3) + + if (t.kind === 'frost') { + if (t.cooldown <= 0) { + let any = false + for (const e of this.enemies) { + if (e.dead || e.escaped) continue + if (dist(t.x, t.y, e.x, e.y) <= stats.range + e.r) { + any = true + break + } + } + if (any) { + t.cooldown = 1 / stats.rate + t.pulse = 1 + this.fx({ type: 'ring', x: t.x, y: t.y, r0: 12, r1: stats.range, life: 0.45, max: 0.45, color: '#aef0ff', width: 3 }) + this.sfx('pulse') + for (const e of this.enemies) { + if (e.dead || e.escaped) continue + if (dist(t.x, t.y, e.x, e.y) <= stats.range + e.r) { + const sf = def.slowFactor![t.level - 1] + const sd = def.slowDuration![t.level - 1] + if (this.time >= e.slowUntil || sf <= e.slowFactor) { + e.slowFactor = sf + e.slowUntil = this.time + sd + } + this.damageEnemy(e, stats.damage, t) + } + } + } + } + return + } + + // validate / reacquire target + let target: Enemy | undefined = + t.targetId !== null ? this.enemies.find((e) => e.id === t.targetId && !e.dead && !e.escaped) : undefined + if (target && (dist(t.x, t.y, target.x, target.y) > stats.range + target.r || (target.flying && !def.hitsFlying))) { + target = undefined + } + if (!target) { + target = this.acquireTarget(t) + t.targetId = target?.id ?? null + } + + if (target) { + const desired = angleTo(t.x, t.y, target.x, target.y) + t.angle = lerpAngle(t.angle, desired, Math.min(1, dt * 12)) + const aimed = t.kind === 'tesla' || angleDiff(t.angle, desired) < 0.35 + if (t.cooldown <= 0 && aimed) { + t.cooldown = 1 / stats.rate + t.recoil = 1 + this.fire(t, target, stats) + } + } + } + + private fire(t: Tower, target: Enemy, stats: { damage: number; range: number; rate: number }): void { + const def = TOWERS[t.kind] + const bx = t.x + Math.cos(t.angle) * 16 + const by = t.y + Math.sin(t.angle) * 16 + + switch (t.kind) { + case 'arrow': { + this.projectiles.push({ + kind: 'arrow', + x: bx, + y: by, + angle: t.angle, + speed: def.projectileSpeed!, + damage: stats.damage, + splash: 0, + target, + tx: target.x, + ty: target.y, + life: 3, + trail: 0, + ownerId: t.id, + }) + this.sfx('shootArrow') + break + } + case 'cannon': { + this.projectiles.push({ + kind: 'ball', + x: bx, + y: by, + angle: t.angle, + speed: def.projectileSpeed!, + damage: stats.damage, + splash: def.splash![t.level - 1], + target, + tx: target.x, + ty: target.y, + life: 3, + trail: 0, + ownerId: t.id, + }) + this.sfx('shootCannon') + // muzzle smoke + for (let i = 0; i < 5; i++) { + this.fx({ + type: 'particle', + x: bx, + y: by, + vx: Math.cos(t.angle) * 60 + (Math.random() - 0.5) * 40, + vy: Math.sin(t.angle) * 60 + (Math.random() - 0.5) * 40 - 20, + life: 0.5, + max: 0.5, + size: 3, + color: '#c9c9c9', + grav: -20, + }) + } + break + } + case 'tesla': { + const chainCount = def.chain![t.level - 1] + const hit: Enemy[] = [target] + const dmg = stats.damage + this.damageEnemy(target, dmg, t) + let prev = target + while (hit.length < chainCount) { + let next: Enemy | undefined + let nd = def.chainRange! + for (const e of this.enemies) { + if (e.dead || e.escaped || hit.includes(e)) continue + const d = dist(prev.x, prev.y, e.x, e.y) + if (d < nd) { + nd = d + next = e + } + } + if (!next) break + hit.push(next) + this.damageEnemy(next, dmg * Math.pow(0.75, hit.length - 1), t) + prev = next + } + const pts: Pt[] = [{ x: t.x, y: t.y - 14 }, ...hit.map((e) => ({ x: e.x, y: e.y }))] + this.fx({ type: 'bolt', pts, life: 0.16, max: 0.16, color: def.accent }) + this.sfx('tesla') + break + } + case 'laser': { + this.damageEnemy(target, stats.damage, t) + this.fx({ type: 'beam', x1: bx, y1: by, x2: target.x, y2: target.y, life: 0.18, max: 0.18, color: def.accent }) + for (let i = 0; i < 5; i++) { + const a = Math.random() * Math.PI * 2 + this.fx({ + type: 'particle', + x: target.x, + y: target.y, + vx: Math.cos(a) * 90, + vy: Math.sin(a) * 90, + life: 0.3, + max: 0.3, + size: 2.5, + color: '#ffb0a0', + grav: 0, + }) + } + this.sfx('laser') + break + } + case 'frost': + break + } + } + + damageEnemy(e: Enemy, dmg: number, source?: Tower): void { + if (e.dead || e.escaped) return + const applied = Math.min(e.hp, dmg) + e.hp -= dmg + e.flash = 1 + if (source) source.damageDealt += applied + if (e.hp <= 0) { + e.dead = true + this.kills++ + this.money += e.reward + this.score += e.reward + this.waveNo * 2 + if (source) source.kills++ + this.sfx('death') + const def = ENEMIES[e.kind] + for (let i = 0; i < (e.kind === 'boss' ? 26 : 10); i++) { + const a = Math.random() * Math.PI * 2 + const sp = 40 + Math.random() * (e.kind === 'boss' ? 160 : 80) + this.fx({ + type: 'particle', + x: e.x, + y: e.y, + vx: Math.cos(a) * sp, + vy: Math.sin(a) * sp - 30, + life: 0.5 + Math.random() * 0.3, + max: 0.8, + size: 2 + Math.random() * 3, + color: def.color, + grav: 160, + }) + } + this.fx({ type: 'text', x: e.x, y: e.y - 8, vy: -34, life: 0.9, max: 0.9, str: `+${e.reward}`, color: '#ffd23e', size: e.kind === 'boss' ? 17 : 12 }) + if (e.kind === 'boss') { + this.shake = 0.8 + this.fx({ type: 'ring', x: e.x, y: e.y, r0: 10, r1: 90, life: 0.5, max: 0.5, color: '#ffd23e', width: 4 }) + } + } + } + + // ---------------------------------------------------------------- projectiles + + private updateProjectiles(dt: number): void { + for (const p of this.projectiles) { + p.life -= dt + if (p.target && !p.target.dead && !p.target.escaped) { + p.tx = p.target.x + p.ty = p.target.y + } + const d = dist(p.x, p.y, p.tx, p.ty) + const step = p.speed * dt + if (d <= step || d < 4) { + this.impact(p) + continue + } + p.angle = angleTo(p.x, p.y, p.tx, p.ty) + p.x += (Math.cos(p.angle) * step) + p.y += (Math.sin(p.angle) * step) + if (p.kind === 'ball') { + p.trail -= dt + if (p.trail <= 0) { + p.trail = 0.05 + this.fx({ + type: 'particle', + x: p.x, + y: p.y, + vx: (Math.random() - 0.5) * 20, + vy: (Math.random() - 0.5) * 20 - 10, + life: 0.35, + max: 0.35, + size: 2.5, + color: '#b9b9b9', + grav: -30, + }) + } + } + } + this.projectiles = this.projectiles.filter((p) => p.life > 0) + } + + private impact(p: Projectile): void { + p.life = 0 + const owner = this.towers.find((t) => t.id === p.ownerId) + if (p.kind === 'arrow') { + if (p.target && !p.target.dead && !p.target.escaped) { + this.damageEnemy(p.target, p.damage, owner) + } + return + } + // cannon ball: splash on ground units + this.fx({ type: 'ring', x: p.x, y: p.y, r0: 8, r1: p.splash, life: 0.3, max: 0.3, color: '#ffb46e', width: 4 }) + for (let i = 0; i < 12; i++) { + const a = Math.random() * Math.PI * 2 + const sp = 50 + Math.random() * 110 + this.fx({ + type: 'particle', + x: p.x, + y: p.y, + vx: Math.cos(a) * sp, + vy: Math.sin(a) * sp - 40, + life: 0.4 + Math.random() * 0.25, + max: 0.65, + size: 2.5 + Math.random() * 2.5, + color: Math.random() < 0.5 ? '#ff9c40' : '#6e6259', + grav: 200, + }) + } + for (const e of this.enemies) { + if (e.dead || e.escaped || e.flying) continue + if (dist(p.x, p.y, e.x, e.y) <= p.splash + e.r) { + this.damageEnemy(e, p.damage, owner) + } + } + this.shake = Math.max(this.shake, 0.15) + } + + private updateEffects(dt: number): void { + for (const fx of this.effects) { + fx.life -= dt + if (fx.type === 'particle') { + fx.x += fx.vx * dt + fx.y += fx.vy * dt + fx.vy += fx.grav * dt + } else if (fx.type === 'text') { + fx.y += fx.vy * dt + } + } + this.effects = this.effects.filter((fx) => fx.life > 0) + if (this.effects.length > 500) this.effects.splice(0, this.effects.length - 500) + } + + announce(str: string, sub: string): void { + this.fx({ type: 'announce', str, sub, life: 1.8, max: 1.8 }) + } + + // ---------------------------------------------------------------- input + + setHover(x: number, y: number): void { + const tx = Math.floor(x / TILE) + const ty = Math.floor(y / TILE) + this.hover = { x, y, tx, ty, valid: this.buildType !== null && this.canPlace(tx, ty) } + } + + clearHover(): void { + this.hover = null + } + + click(x: number, y: number): void { + const tx = Math.floor(x / TILE) + const ty = Math.floor(y / TILE) + if (this.buildType) { + const def = TOWERS[this.buildType] + if (this.canPlace(tx, ty) && this.money >= def.cost) { + this.placeTower(this.buildType, tx, ty, this.mpGameActive ? this.localPlayerId : null) + } else { + this.sfx('error') + } + return + } + this.selectAt(tx, ty) + } + + /** select a tower or obstacle on a tile (local UI state only, no game action) */ + selectAt(tx: number, ty: number): boolean { + const t = this.towerAt(tx, ty) + if (t) { + this.selectedId = t.id + this.selectedObstacle = null + this.sfx('click') + return true + } + const ob = this.obstacleAt(tx, ty) + if (ob) { + this.selectedObstacle = { tx, ty } + this.selectedId = null + this.sfx('click') + return true + } + this.selectedId = null + this.selectedObstacle = null + return false + } + + // ---------------------------------------------------------------- multiplayer actions + + /** applies a synchronized player action (solo: local, MP: lockstep-ordered) */ + applyAction(a: MPAction, from: number): boolean { + const local = !this.mpGameActive || from === this.localPlayerId + switch (a.type) { + case 'build': { + const def = TOWERS[a.kind] + if (!this.canPlace(a.tx, a.ty) || this.money < def.cost) { + if (local) this.sfx('error') + return false + } + this.placeTower(a.kind, a.tx, a.ty, this.mpGameActive ? from : null) + return true + } + case 'upgrade': { + const t = this.towers.find((x) => x.id === a.towerId) + if (!t || t.level >= 3) return false + if (t.owner !== null && t.owner !== from) { + if (local) this.sfx('error') + return false + } + this.upgradeTower(t) + return true + } + case 'sell': { + const t = this.towers.find((x) => x.id === a.towerId) + if (!t) return false + if (t.owner !== null && t.owner !== from) { + if (local) this.sfx('error') + return false + } + this.sellTower(t) + return true + } + case 'targeting': { + const t = this.towers.find((x) => x.id === a.towerId) + if (!t) return false + if (t.owner !== null && t.owner !== from) return false + t.targeting = a.mode + if (local) this.sfx('click') + return true + } + case 'obstacle': { + const item = this.obstacleAt(a.tx, a.ty) + if (!item) return false + const cost = OBSTACLE_COST[item.type] + if (this.money < cost) { + if (local) this.sfx('error') + return false + } + this.selectedObstacle = { tx: a.tx, ty: a.ty } + this.removeSelectedObstacle() + return true + } + case 'wave': { + if (this.phase !== 'intermission') return false + this.startWave(true) + return true + } + case 'speed': { + this.setSpeedLocal(a.s) + return true + } + case 'rush': + // handled by the multiplayer controller (spawns on the opponent board) + return true + } + } + + /** solo shortcut: apply an action from the local player immediately */ + applyLocal(a: MPAction): boolean { + return this.applyAction(a, this.localPlayerId) + } + + setSpeedLocal(s: number): void { + this.speed = s + store.speed = s + } + + /** 1v1: paid rush – spawns extra fast enemies on this (opponent) board */ + spawnRush(): void { + const n = 3 + Math.floor(this.waveNo / 3) + let t = this.time + 1.5 + for (let i = 0; i < n; i++) { + this.spawnQueue.push({ t, kind: 'fast' }) + t += 0.5 + } + this.spawnQueue.sort((p, q) => p.t - q.t) + this.announce('Rush!', `${n} Läufer unterwegs`) + this.sfx('wave') + } + + setBuildType(kind: TowerKind | null): void { + this.buildType = kind + if (kind) { + this.selectedId = null + this.selectedObstacle = null + } + if (this.hover) this.setHover(this.hover.x, this.hover.y) + this.sfx('click') + } + + cancelMode(): void { + if (this.buildType) this.buildType = null + else { + this.selectedId = null + this.selectedObstacle = null + } + } + + togglePause(): void { + if (store.screen !== 'game' || this.mpGameActive) return + this.paused = !this.paused + store.paused = this.paused + this.sfx('click') + } + + setPaused(p: boolean): void { + this.paused = p + store.paused = p + } + + setSpeed(s: number): void { + this.speed = s + store.speed = s + this.sfx('click') + } + + toggleMute(): void { + sound.ensure() + sound.setMuted(!sound.muted) + store.muted = sound.muted + } + + // ---------------------------------------------------------------- store sync + + private syncStore(): void { + store.money = Math.floor(this.money) + store.lives = this.lives + store.waveNo = this.waveNo + store.score = this.score + store.kills = this.kills + store.nextWaveIn = Math.max(0, Math.ceil(this.intermission)) + store.buildType = this.buildType + store.hoverValid = this.hover?.valid ?? false + store.phase = this.phase + + const obSel = this.selectedObstacle + if (!obSel) { + store.obstacle = null + } else { + const item = this.obstacleAt(obSel.tx, obSel.ty) + if (!item) { + store.obstacle = null + } else { + store.obstacle = { tx: obSel.tx, ty: obSel.ty, type: item.type, cost: OBSTACLE_COST[item.type] } + } + } + + const t = this.selectedTower() + if (!t) { + store.selected = null + } else { + const def = TOWERS[t.kind] + const stats = def.levels[t.level - 1] + const maxed = t.level >= 3 + let special = '' + if (def.splash) special = `Fläche: ${def.splash[t.level - 1]}px` + if (def.slowFactor) special = `Slow: ${Math.round((1 - def.slowFactor[t.level - 1]) * 100)}% für ${def.slowDuration![t.level - 1]}s` + if (def.chain) special = `Kette: ${def.chain[t.level - 1]} Ziele` + if (t.kind === 'laser') special = 'Soforttreffer' + const info: SelectedTowerInfo = { + id: t.id, + kind: t.kind, + name: def.name, + icon: def.icon, + desc: def.desc, + level: t.level, + maxLevel: maxed, + damage: stats.damage, + range: stats.range, + rate: stats.rate, + dps: Math.round(stats.damage * stats.rate * (def.chain ? def.chain[t.level - 1] * 0.8 : 1)), + upgradeCost: maxed ? null : def.upgradeCost[t.level - 1], + sellValue: Math.round(t.invested * SELL_RATIO), + targeting: t.targeting, + kills: t.kills, + damageDealt: Math.round(t.damageDealt), + hitsFlying: def.hitsFlying, + special, + } + store.selected = info + } + } +} + +export const engine = new GameEngine() +export { TARGETING_LABEL } diff --git a/src/game/mpgame.ts b/src/game/mpgame.ts new file mode 100644 index 0000000..f13b2f3 --- /dev/null +++ b/src/game/mpgame.ts @@ -0,0 +1,404 @@ +import { GameEngine, engine } from './engine' +import { MPClient, serverUrl } from './net' +import { sound } from './sound' +import { store } from './store' +import type { MPAction, MPMode, TargetingMode } from './types' + +/** simulation tick rate in multiplayer (both clients advance identical ticks) */ +const TICK_DT = 1 / 30 +/** actions are applied DELAY ticks after sending so both clients hold them in order */ +const DELAY_TICKS = 3 +/** coop: enemies are tougher because two players defend together */ +const COOP_HP_MUL = 1.7 +/** 1v1 rush pricing */ +const RUSH_BASE_COST = 60 +const RUSH_COST_PER_WAVE = 8 + +interface InboxEntry { + seq: number + from: number + tick: number + a: MPAction +} + +/** + * Multiplayer controller. + * + * Coop: one shared engine – both players build on the same board with a shared + * gold pot and shared lives (towers belong to their builder). + * Duel (1v1): each player defends their own board; both boards are simulated + * on both clients (deterministic lockstep) so you can watch the opponent live. + */ +class MpGameController { + private net = new MPClient({ + onRoom: (info) => this.handleRoom(info), + onStart: (info) => this.handleStart(info), + onAct: (msg) => { + // guard inbox against excessive queuing + if (this.inbox.length < 500) { + this.inbox.push(msg) + } + }, + onProg: (_from, tick) => { + if (tick > this.net.remoteTick) this.net.remoteTick = tick + }, + onPeerLeft: () => this.handlePeerLeft(), + onError: (msg) => this.setLobbyError(msg), + }) + + active = false + mode: MPMode = 'coop' + engines: GameEngine[] = [] + remoteEngine: GameEngine | null = null + myId = 0 + private myTick = 0 + private acc = 0 + private inbox: InboxEntry[] = [] + private heartbeat: ReturnType | null = null + private ended = false + private connecting = false + + get isHost(): boolean { + return store.mp.myId === 0 + } + + // ---------------------------------------------------------------- lobby + + private setLobbyError(msg: string): void { + if (!this.active) { + store.mp.status = msg + store.mp.inLobby = false + store.screen = 'menu' + this.net.close() + } + } + + private handleRoom(info: { code: string; mode: MPMode; players: { id: number; name: string }[]; you: number }): void { + store.mp.roomCode = info.code + store.mp.mode = info.mode + store.mp.players = info.players + store.mp.myId = info.you + store.mp.isHost = info.you === 0 + store.mp.inLobby = true + store.mp.status = '' + store.screen = 'lobby' + } + + async create(mode: MPMode, name: string): Promise { + if (this.connecting) return + this.connecting = true + store.mp.status = 'Verbinde mit Server…' + try { + await this.net.connect(serverUrl()) + store.mp.name = name + store.mp.active = false + this.net.createRoom(mode, name) + } catch (err) { + store.mp.status = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.' + } finally { + this.connecting = false + } + } + + async join(code: string, name: string): Promise { + if (this.connecting) return + this.connecting = true + store.mp.status = 'Verbinde mit Server…' + try { + await this.net.connect(serverUrl()) + store.mp.name = name + store.mp.active = false + this.net.joinRoom(code, name) + } catch (err) { + store.mp.status = err instanceof Error ? err.message : 'Verbindung fehlgeschlagen.' + } finally { + this.connecting = false + } + } + + requestStart(): void { + this.net.startGame() + } + + leave(): void { + this.disposeGame() + this.net.leave() + this.resetStoreMp() + store.screen = 'menu' + store.result = null + } + + private resetStoreMp(): void { + store.mp.active = false + store.mp.inLobby = false + store.mp.status = '' + store.mp.roomCode = '' + store.mp.players = [] + store.mp.resultMsg = '' + } + + // ---------------------------------------------------------------- game start + + private handleStart(info: { you: number; mode: MPMode; players: { id: number; name: string }[] }): void { + this.active = true + this.ended = false + this.mode = info.mode + this.myId = info.you + this.myTick = 0 + this.acc = 0 + this.inbox = [] + + const me = info.players.find((p) => p.id === info.you) + const peer = info.players.find((p) => p.id !== info.you) + + if (info.mode === 'coop') { + engine.startGame('normal') + engine.mpGameActive = true + engine.mpMode = 'coop' + engine.localPlayerId = info.you + engine.coopHpMul = COOP_HP_MUL + engine.mpController = this + this.engines = [engine] + this.remoteEngine = null + } else { + engine.startGame('normal') + engine.mpGameActive = true + engine.mpMode = 'duel' + engine.localPlayerId = info.you + engine.mpController = this + const remote = new GameEngine() + remote.startGame('normal') + remote.mpGameActive = true + remote.mpMode = 'duel' + remote.localPlayerId = 1 - info.you + remote.ghost = true + this.engines = [engine, remote] + this.remoteEngine = remote + } + + store.mp.active = true + store.mp.mode = info.mode + store.mp.name = me?.name ?? 'Du' + store.mp.peerName = peer?.name ?? 'Gegner' + store.mp.myId = info.you + store.mp.resultMsg = '' + store.mp.opponentLives = this.remoteEngine ? this.remoteEngine.lives : 0 + store.screen = 'game' + store.paused = false + sound.play('wave') + + this.net.remoteTick = -1 + if (this.heartbeat) clearInterval(this.heartbeat) + this.heartbeat = setInterval(() => this.net.sendProgress(this.myTick), 100) + + engine.announce( + info.mode === 'coop' ? 'Coop-Modus!' : '1v1-Duell!', + info.mode === 'coop' ? 'Gemeinsames Gold – Teamwork!' : 'Verteidige deine Basis!', + ) + } + + private disposeGame(): void { + if (this.heartbeat) clearInterval(this.heartbeat) + this.heartbeat = null + for (const e of this.engines) { + e.mpController = null + e.mpGameActive = false + e.mpMode = 'solo' + e.coopHpMul = 1 + e.ghost = false + e.localPlayerId = 0 + e.setSpeedLocal(1) + } + this.engines = [] + this.remoteEngine = null + this.active = false + this.ended = true + this.inbox = [] + } + + private handlePeerLeft(): void { + if (this.active && !this.ended) { + const msg = + this.mode === 'coop' ? 'Dein Mitspieler hat das Spiel verlassen.' : 'Gegner hat verlassen – du gewinnst!' + this.endGame(this.mode === 'duel', msg) + } else if (store.mp.inLobby) { + // peer left the lobby: back to room view with only us + store.mp.players = store.mp.players.filter((p) => p.id === store.mp.myId) + } + } + + // ---------------------------------------------------------------- lockstep loop + + /** called from engine.step() every animation frame */ + frame(dtReal: number): void { + if (!this.active || this.ended) return + this.acc = Math.min(this.acc + dtReal * engine.speed, 0.4) + + while (this.acc >= TICK_DT) { + this.acc -= TICK_DT + this.myTick++ + + const due = this.inbox + .filter((m) => m.tick <= this.myTick) + .sort((p, q) => p.tick - q.tick || p.seq - q.seq) + this.inbox = this.inbox.filter((m) => m.tick > this.myTick) + for (const m of due) this.route(m.a, m.from) + + for (const e of this.engines) e.update(TICK_DT) + } + + this.syncDuelInfo() + this.checkEnd() + } + + /** applies an action on the right board(s) – must behave identically on all clients */ + private route(a: MPAction, from: number): void { + if (a.type === 'rush') { + this.doRush(from) + return + } + if (a.type === 'speed') { + for (const e of this.engines) e.setSpeedLocal(a.s) + return + } + if (this.mode === 'coop') { + this.engines[0].applyAction(a, from) + return + } + if (a.type === 'wave') { + // duel: waves always start on BOTH boards (stays symmetric) + for (const e of this.engines) e.applyAction(a, from) + return + } + this.engines[from]?.applyAction(a, from) + } + + private doRush(from: number): void { + const sender = this.engines[from] + const target = this.engines[1 - from] + if (!sender || !target) return + const cost = this.rushCost(sender) + if (sender.money < cost) { + if (from === this.myId) sound.play('error') + return + } + sender.money -= cost + target.spawnRush() + } + + rushCost(e: GameEngine): number { + return RUSH_BASE_COST + e.waveNo * RUSH_COST_PER_WAVE + } + + private syncDuelInfo(): void { + if (this.mode !== 'duel' || !this.remoteEngine) return + store.mp.opponentLives = this.remoteEngine.lives + store.mp.opponentGold = Math.floor(this.remoteEngine.money) + store.mp.opponentWave = this.remoteEngine.waveNo + store.mp.rushCost = this.rushCost(engine) + } + + // ---------------------------------------------------------------- end conditions + + private checkEnd(): void { + if (this.ended) return + if (this.mode === 'coop') { + if (engine.phase === 'gameover') this.endGame(false, 'Eure Basis ist gefallen…') + else if (engine.phase === 'victory') this.endGame(true, 'Alle 20 Wellen gemeinsam geschafft!') + return + } + const mine = this.engines[this.myId] + const theirs = this.engines[1 - this.myId] + if (!mine || !theirs) return + if (mine.phase === 'gameover') { + this.endGame(false, `${store.mp.peerName} hat deine Basis zerstört.`) + } else if (theirs.phase === 'gameover') { + this.endGame(true, `Basis von ${store.mp.peerName} zerstört – du gewinnst!`) + } else if (mine.phase === 'victory' && theirs.phase === 'victory') { + if (mine.lives > theirs.lives) this.endGame(true, 'Nach 20 Wellen hast du mehr Leben übrig!') + else if (theirs.lives > mine.lives) this.endGame(false, 'Nach 20 Wellen hat der Gegner mehr Leben übrig.') + else this.endGame(mine.score >= theirs.score, 'Gleichstand nach Leben – die Punkte entscheiden!') + } + } + + private endGame(win: boolean, msg: string): void { + this.ended = true + if (this.heartbeat) clearInterval(this.heartbeat) + this.heartbeat = null + store.mp.resultMsg = msg + store.result = { + win, + score: engine.score, + wave: engine.waveNo, + kills: engine.kills, + best: 0, + bestBefore: 0, + } + store.screen = win ? 'victory' : 'gameover' + sound.play(win ? 'victory' : 'defeat') + } + + // ---------------------------------------------------------------- outgoing actions + + send(a: MPAction): void { + // immediate local precheck for responsive error feedback; the authoritative + // application happens when the server-relayed action reaches its tick + if (a.type === 'build') { + const ok = engine.canPlace(a.tx, a.ty) && engine.money >= 50 // cost check repeated on apply + if (!ok) { + sound.play('error') + return + } + } + this.net.sendAction(a, this.myTick + DELAY_TICKS) + } +} + +export const mpgame = new MpGameController() + +// ------------------------------------------------------------------ UI facade +// Components call these; they route to the network in multiplayer and to the +// engine directly in solo mode. + +export function submitAction(a: MPAction): void { + if (mpgame.active) mpgame.send(a) + else engine.applyLocal(a) +} + +export function uiStartWaveEarly(): void { + if (mpgame.active) mpgame.send({ type: 'wave' }) + else engine.startWave(true) +} + +export function uiSetSpeed(s: number): void { + if (mpgame.active) mpgame.send({ type: 'speed', s }) + else engine.setSpeed(s) +} + +export function uiTogglePause(): void { + if (!mpgame.active) engine.togglePause() +} + +export function uiUpgradeSelected(): void { + const t = engine.selectedTower() + if (!t) return + submitAction({ type: 'upgrade', towerId: t.id }) +} + +export function uiSellSelected(): void { + const t = engine.selectedTower() + if (!t) return + submitAction({ type: 'sell', towerId: t.id }) +} + +export function uiSetTargeting(mode: TargetingMode): void { + const t = engine.selectedTower() + if (!t) return + submitAction({ type: 'targeting', towerId: t.id, mode }) +} + +export function uiRemoveObstacle(): void { + const ob = engine.selectedObstacle + if (!ob) return + submitAction({ type: 'obstacle', tx: ob.tx, ty: ob.ty }) +} diff --git a/src/game/net.ts b/src/game/net.ts new file mode 100644 index 0000000..f08651a --- /dev/null +++ b/src/game/net.ts @@ -0,0 +1,157 @@ +import type { MPAction, MPMode } from './types' + +export interface RoomInfo { + code: string + mode: MPMode + players: { id: number; name: string }[] + you: number +} + +export interface StartInfo { + you: number + seed: number + mode: MPMode + players: { id: number; name: string }[] +} + +export interface ActMsg { + seq: number + from: number + /** simulation tick at which this action must be applied */ + tick: number + a: MPAction +} + +export interface NetHandlers { + onRoom: (info: RoomInfo) => void + onStart: (info: StartInfo) => void + onAct: (msg: ActMsg) => void + onProg: (from: number, tick: number) => void + onPeerLeft: () => void + onError: (msg: string) => void +} + +export function serverUrl(): string { + if (typeof location === 'undefined') return 'ws://localhost:3001' + const custom = new URLSearchParams(location.search).get('server') + if (custom) return custom + return `ws://${location.hostname}:3001` +} + +/** Thin WebSocket wrapper for the TRXTD room/action protocol. */ +export class MPClient { + private ws: WebSocket | null = null + remoteTick = -1 + handlers: NetHandlers + + constructor(handlers: NetHandlers) { + this.handlers = handlers + } + + get connected(): boolean { + return this.ws !== null && this.ws.readyState === 1 + } + + connect(url = serverUrl()): Promise { + this.close() + return new Promise((resolve, reject) => { + let ws: WebSocket + try { + ws = new WebSocket(url) + } catch (err) { + reject(err) + return + } + const timeout = setTimeout(() => { + ws.close() + reject(new Error('Zeitüberschreitung beim Verbinden.')) + }, 4000) + ws.onopen = () => { + clearTimeout(timeout) + this.ws = ws + resolve() + } + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error('Server nicht erreichbar (npm run server).')) + } + ws.onclose = () => { + if (this.ws === ws) this.ws = null + } + ws.onmessage = (ev) => this.onMessage(String(ev.data)) + }) + } + + private onMessage(raw: string): void { + let m: Record + try { + m = JSON.parse(raw) + } catch { + return + } + switch (m.t) { + case 'room': + this.handlers.onRoom(m as unknown as RoomInfo) + break + case 'start': + this.handlers.onStart(m as unknown as StartInfo) + break + case 'act': + this.handlers.onAct(m as unknown as ActMsg) + break + case 'prog': + this.handlers.onProg(Number(m.from), Number(m.tick)) + break + case 'peer-left': + this.handlers.onPeerLeft() + break + case 'error': + this.handlers.onError(String(m.msg)) + break + } + } + + private send(obj: Record): void { + if (this.connected && this.ws) this.ws.send(JSON.stringify(obj)) + } + + createRoom(mode: MPMode, name: string): void { + this.send({ t: 'create', mode, name }) + } + + joinRoom(code: string, name: string): void { + this.send({ t: 'join', code, name }) + } + + startGame(): void { + this.send({ t: 'start' }) + } + + sendAction(a: MPAction, targetTick: number): void { + this.send({ t: 'act', tick: targetTick, a }) + } + + sendProgress(myTick: number): void { + this.send({ t: 'prog', tick: myTick }) + } + + leave(): void { + this.send({ t: 'leave' }) + this.close() + } + + close(): void { + if (this.ws) { + this.ws.onclose = null + this.ws.onerror = null + this.ws.onmessage = null + try { + this.ws.close() + } catch { + /* ignore */ + } + this.ws = null + } + this.remoteTick = -1 + } +} diff --git a/src/game/render.ts b/src/game/render.ts new file mode 100644 index 0000000..52fa6a9 --- /dev/null +++ b/src/game/render.ts @@ -0,0 +1,993 @@ +import { ENEMIES, H, TILE, TOWERS, W } from './config' +import type { GameEngine } from './engine' +import type { Enemy, Tower } from './types' +import { mulberry32 } from './utils' + +/** + * Draws the whole game to a canvas. Static background (grass, path, decor) + * is pre-rendered once to an offscreen canvas. The backing store resolution + * follows the CSS size of the canvas so the game stays sharp at any scale. + */ +export class Renderer { + private canvas: HTMLCanvasElement + private ctx: CanvasRenderingContext2D + private bg: HTMLCanvasElement + private dpr = 1 + /** physical pixels per logical game pixel (960x528 coordinate space) */ + private pxScale = 1 + private cssW = W + private cssH = H + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas + this.ctx = canvas.getContext('2d')! + this.dpr = Math.min(2, (typeof window !== 'undefined' && window.devicePixelRatio) || 1) + this.bg = document.createElement('canvas') + this.setCssSize(W, H) + if (typeof window !== 'undefined') { + window.addEventListener('resize', this.onResize) + } + } + + private onResize = (): void => { + this.dpr = Math.min(2, (typeof window !== 'undefined' && window.devicePixelRatio) || 1) + this.setCssSize(this.cssW, this.cssH) + } + + /** adapts backing-store resolution to the element's CSS size (keeps 960:528 ratio) */ + setCssSize(cssW: number, cssH: number): void { + this.cssW = cssW + this.cssH = cssH + this.pxScale = Math.min(3, (this.dpr * cssW) / W) + this.canvas.width = Math.round(W * this.pxScale) + this.canvas.height = Math.round(H * this.pxScale) + this.buildBackground() + } + + destroy(): void { + if (typeof window !== 'undefined') { + window.removeEventListener('resize', this.onResize) + } + } + + // ------------------------------------------------------------ background + + private buildBackground(): void { + const c = this.bg + c.width = this.canvas.width + c.height = this.canvas.height + const g = c.getContext('2d')! + g.setTransform(this.pxScale, 0, 0, this.pxScale, 0, 0) + + // grass gradient + const grad = g.createLinearGradient(0, 0, 0, H) + grad.addColorStop(0, '#3d6b39') + grad.addColorStop(1, '#48783f') + g.fillStyle = grad + g.fillRect(0, 0, W, H) + + // subtle checkerboard + for (let ty = 0; ty < 11; ty++) { + for (let tx = 0; tx < 20; tx++) { + if ((tx + ty) % 2 === 0) { + g.fillStyle = 'rgba(255,255,255,0.022)' + g.fillRect(tx * TILE, ty * TILE, TILE, TILE) + } + } + } + + // grass speckles + const rng = mulberry32(1337) + for (let i = 0; i < 500; i++) { + const x = rng() * W + const y = rng() * H + g.fillStyle = rng() < 0.5 ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.04)' + g.fillRect(x, y, 2, 2) + } + + // grid lines + g.strokeStyle = 'rgba(0,0,0,0.06)' + g.lineWidth = 1 + for (let x = 0; x <= 20; x++) { + g.beginPath() + g.moveTo(x * TILE + 0.5, 0) + g.lineTo(x * TILE + 0.5, H) + g.stroke() + } + for (let y = 0; y <= 11; y++) { + g.beginPath() + g.moveTo(0, y * TILE + 0.5) + g.lineTo(W, y * TILE + 0.5) + g.stroke() + } + } + + private drawPath(g: CanvasRenderingContext2D, eng: GameEngine): void { + const pts = eng.pathPx + g.lineJoin = 'round' + g.lineCap = 'round' + g.beginPath() + g.moveTo(pts[0].x, pts[0].y) + for (let i = 1; i < pts.length; i++) g.lineTo(pts[i].x, pts[i].y) + + g.strokeStyle = '#5f4a33' + g.lineWidth = TILE * 0.78 + g.stroke() + g.strokeStyle = '#7a6142' + g.lineWidth = TILE * 0.7 + g.stroke() + g.strokeStyle = '#c2a276' + g.lineWidth = TILE * 0.58 + g.stroke() + + // dashed center line + g.save() + g.strokeStyle = 'rgba(216,195,154,0.55)' + g.lineWidth = 3 + g.setLineDash([12, 16]) + g.beginPath() + g.moveTo(pts[0].x, pts[0].y) + for (let i = 1; i < pts.length; i++) g.lineTo(pts[i].x, pts[i].y) + g.stroke() + g.restore() + + // pebbles on the path + const rng = mulberry32(777) + for (let i = 0; i < 90; i++) { + const seg = 1 + Math.floor(rng() * (pts.length - 1)) + const a = pts[seg - 1] + const b = pts[seg] + const t = rng() + const off = (rng() - 0.5) * TILE * 0.45 + const dx = b.x - a.x + const dy = b.y - a.y + const len = Math.hypot(dx, dy) || 1 + const px = a.x + dx * t + (-dy / len) * off + const py = a.y + dy * t + (dx / len) * off + g.fillStyle = rng() < 0.5 ? 'rgba(95,74,51,0.5)' : 'rgba(230,210,175,0.5)' + g.beginPath() + g.arc(px, py, 1.2 + rng() * 1.6, 0, Math.PI * 2) + g.fill() + } + } + + private drawDecor(g: CanvasRenderingContext2D, eng: GameEngine): void { + for (const d of eng.decor) { + const { x, y, s } = d + if (d.type === 'flower') { + const cols = ['#e8657f', '#f5d55b', '#ffffff', '#c17ee0'] + const col = cols[Math.floor(d.seed * cols.length)] + g.fillStyle = col + for (let i = 0; i < 3; i++) { + const fx = x + (i - 1) * 6 * s + (d.seed * 10 - 5) + const fy = y + ((i * 13) % 7 - 3) * s + g.beginPath() + g.arc(fx, fy, 2.2 * s, 0, Math.PI * 2) + g.fill() + } + } else if (d.type === 'bush') { + g.fillStyle = 'rgba(0,0,0,0.15)' + g.beginPath() + g.ellipse(x, y + 8 * s, 11 * s, 4 * s, 0, 0, Math.PI * 2) + g.fill() + g.fillStyle = '#3e7a3a' + g.beginPath() + g.ellipse(x, y, 10 * s, 7 * s, 0, 0, Math.PI * 2) + g.fill() + g.fillStyle = '#4f8f45' + g.beginPath() + g.ellipse(x - 3 * s, y - 2 * s, 6 * s, 4.5 * s, 0, 0, Math.PI * 2) + g.fill() + } else if (d.type === 'rock') { + g.fillStyle = 'rgba(0,0,0,0.18)' + g.beginPath() + g.ellipse(x, y + 10 * s, 13 * s, 4 * s, 0, 0, Math.PI * 2) + g.fill() + g.fillStyle = '#8b9298' + g.beginPath() + g.moveTo(x - 12 * s, y + 8 * s) + g.lineTo(x - 9 * s, y - 7 * s) + g.lineTo(x + 2 * s, y - 11 * s) + g.lineTo(x + 12 * s, y - 2 * s) + g.lineTo(x + 10 * s, y + 8 * s) + g.closePath() + g.fill() + g.fillStyle = 'rgba(255,255,255,0.22)' + g.beginPath() + g.moveTo(x - 8 * s, y - 5 * s) + g.lineTo(x + 1 * s, y - 9 * s) + g.lineTo(x + 5 * s, y - 3 * s) + g.closePath() + g.fill() + } else { + // tree + g.fillStyle = 'rgba(0,0,0,0.22)' + g.beginPath() + g.ellipse(x, y + 12 * s, 15 * s, 5 * s, 0, 0, Math.PI * 2) + g.fill() + g.fillStyle = '#6b4526' + g.fillRect(x - 3.5 * s, y - 4 * s, 7 * s, 16 * s) + const rng = mulberry32(Math.floor(d.seed * 1e9)) + for (let i = 0; i < 4; i++) { + const cx = x + (rng() - 0.5) * 16 * s + const cy = y - 8 * s - rng() * 12 * s + const r = (8 + rng() * 5) * s + g.fillStyle = i % 2 === 0 ? '#2f6b33' : '#3b7c3c' + g.beginPath() + g.arc(cx, cy, r, 0, Math.PI * 2) + g.fill() + } + g.fillStyle = 'rgba(255,255,255,0.12)' + g.beginPath() + g.arc(x - 4 * s, y - 16 * s, 6 * s, 0, Math.PI * 2) + g.fill() + } + } + } + + // ------------------------------------------------------------ main render + + render(eng: GameEngine): void { + const g = this.ctx + g.setTransform(this.pxScale, 0, 0, this.pxScale, 0, 0) + g.clearRect(0, 0, W, H) + + g.save() + if (eng.shake > 0) { + const s = eng.shake * 5 + g.translate((Math.random() - 0.5) * s, (Math.random() - 0.5) * s) + } + + g.drawImage(this.bg, 0, 0, W, H) + this.drawPath(g, eng) + this.drawDecor(g, eng) + this.drawSpawn(g, eng) + this.drawBase(g, eng) + + if (eng.buildType) this.drawBuildHints(g, eng) + + for (const t of eng.towers) this.drawTower(g, eng, t, false) + + // ground enemies first, then flying on top + for (const e of eng.enemies) if (!e.flying) this.drawEnemy(g, eng, e) + for (const p of eng.projectiles) this.drawProjectile(g, p.kind, p.x, p.y, p.angle) + for (const e of eng.enemies) if (e.flying) this.drawEnemy(g, eng, e) + + this.drawEffects(g, eng) + this.drawBossBars(g, eng) + + // hover ghost + if (eng.buildType && eng.hover && eng.hover.x >= 0 && eng.hover.x < W && eng.hover.y >= 0 && eng.hover.y < H) { + this.drawGhost(g, eng) + } + + // selected tower highlight + const sel = eng.selectedTower() + if (sel) { + const def = TOWERS[sel.kind] + const range = def.levels[sel.level - 1].range + g.save() + g.strokeStyle = 'rgba(255,255,255,0.8)' + g.setLineDash([8, 6]) + g.lineWidth = 2 + g.beginPath() + g.arc(sel.x, sel.y, range, 0, Math.PI * 2) + g.stroke() + g.fillStyle = 'rgba(255,255,255,0.06)' + g.fill() + g.restore() + } + + // selected obstacle highlight + const ob = eng.selectedObstacle + if (ob) { + g.save() + g.strokeStyle = 'rgba(255,255,255,0.85)' + g.setLineDash([8, 6]) + g.lineWidth = 2 + g.strokeRect(ob.tx * TILE + 3, ob.ty * TILE + 3, TILE - 6, TILE - 6) + g.restore() + } + + g.restore() + } + + private drawSpawn(g: CanvasRenderingContext2D, eng: GameEngine): void { + const p = eng.pathPx[0] + g.save() + g.translate(p.x + 18, p.y) + g.fillStyle = '#2a1e3d' + g.beginPath() + g.ellipse(0, 0, 26, 30, 0, 0, Math.PI * 2) + g.fill() + const t = eng.time * 2 + for (let i = 0; i < 3; i++) { + g.strokeStyle = `rgba(178,120,255,${0.7 - i * 0.2})` + g.lineWidth = 3 + g.beginPath() + g.arc(0, 0, 8 + i * 7 + Math.sin(t + i * 2) * 3, t * (i % 2 === 0 ? 1 : -1), t * (i % 2 === 0 ? 1 : -1) + 4.2) + g.stroke() + } + g.fillStyle = 'rgba(200,160,255,0.9)' + g.beginPath() + g.arc(0, 0, 3.5 + Math.sin(t * 3) * 1.2, 0, Math.PI * 2) + g.fill() + g.restore() + } + + private drawBase(g: CanvasRenderingContext2D, eng: GameEngine): void { + const p = eng.pathPx[eng.pathPx.length - 1] + const x = p.x - 26 + const y = p.y + g.save() + // shadow + g.fillStyle = 'rgba(0,0,0,0.25)' + g.beginPath() + g.ellipse(x, y + 26, 30, 8, 0, 0, Math.PI * 2) + g.fill() + // keep walls + g.fillStyle = '#8d8577' + g.fillRect(x - 24, y - 30, 48, 56) + g.fillStyle = '#a49b8b' + g.fillRect(x - 24, y - 30, 48, 8) + // crenellations + g.fillStyle = '#8d8577' + for (let i = 0; i < 4; i++) g.fillRect(x - 24 + i * 13, y - 38, 8, 8) + // gate + g.fillStyle = '#4c3a28' + g.beginPath() + g.moveTo(x - 10, y + 26) + g.lineTo(x - 10, y + 2) + g.arc(x, y + 2, 10, Math.PI, 0) + g.lineTo(x + 10, y + 26) + g.closePath() + g.fill() + // banner + g.strokeStyle = '#5b4a35' + g.lineWidth = 3 + g.beginPath() + g.moveTo(x, y - 38) + g.lineTo(x, y - 62) + g.stroke() + const wave = Math.sin(eng.time * 4) * 3 + g.fillStyle = '#d84b3f' + g.beginPath() + g.moveTo(x + 1, y - 62) + g.lineTo(x + 22 + wave, y - 56) + g.lineTo(x + 1, y - 48) + g.closePath() + g.fill() + // hearts = lives + g.fillStyle = '#fff' + g.font = 'bold 11px system-ui' + g.textAlign = 'center' + g.fillText('❤ ' + eng.lives, x, y - 12) + g.restore() + } + + private drawBuildHints(g: CanvasRenderingContext2D, eng: GameEngine): void { + g.save() + g.strokeStyle = 'rgba(255,255,255,0.1)' + g.lineWidth = 1 + for (let tx = 0; tx < 20; tx++) { + for (let ty = 0; ty < 11; ty++) { + const ok = eng.canPlace(tx, ty) + if (!ok) continue + g.strokeRect(tx * TILE + 3.5, ty * TILE + 3.5, TILE - 7, TILE - 7) + } + } + g.restore() + } + + // ------------------------------------------------------------ towers + + private drawTower(g: CanvasRenderingContext2D, eng: GameEngine, t: Tower, ghost: boolean): void { + const def = TOWERS[t.kind] + g.save() + if (ghost) g.globalAlpha = 0.55 + g.translate(t.x, t.y) + + // base plate + g.fillStyle = 'rgba(0,0,0,0.25)' + g.beginPath() + g.ellipse(2, 6, 19, 12, 0, 0, Math.PI * 2) + g.fill() + g.fillStyle = '#39404a' + this.roundRect(g, -18, -16, 36, 34, 7) + g.fill() + const baseGrad = g.createLinearGradient(0, -16, 0, 18) + baseGrad.addColorStop(0, def.color) + baseGrad.addColorStop(1, this.shade(def.color, -30)) + g.fillStyle = baseGrad + this.roundRect(g, -16, -16, 32, 32, 6) + g.fill() + g.strokeStyle = 'rgba(0,0,0,0.35)' + g.lineWidth = 1.5 + this.roundRect(g, -16, -16, 32, 32, 6) + g.stroke() + + // multiplayer owner ring + if (t.owner !== null) { + g.strokeStyle = t.owner === 0 ? '#4da3ff' : '#ffb14d' + g.lineWidth = 2.5 + g.beginPath() + g.arc(0, 2, 21, 0, Math.PI * 2) + g.stroke() + } + + // level pips + for (let i = 0; i < t.level; i++) { + g.fillStyle = '#ffd23e' + g.beginPath() + g.arc(-8 + i * 8, 13, 2.6, 0, Math.PI * 2) + g.fill() + } + + const recoil = t.recoil * 4 + const time = eng.time + + switch (t.kind) { + case 'arrow': { + g.rotate(t.angle) + g.translate(-recoil, 0) + // crossbow + g.fillStyle = '#5b4a35' + g.fillRect(-8, -3, 18, 6) + g.strokeStyle = '#e8c27a' + g.lineWidth = 2.5 + g.beginPath() + g.moveTo(4, -10) + g.quadraticCurveTo(11, 0, 4, 10) + g.stroke() + g.strokeStyle = 'rgba(255,255,255,0.7)' + g.lineWidth = 1 + g.beginPath() + g.moveTo(4, -10) + g.lineTo(-4, 0) + g.lineTo(4, 10) + g.stroke() + if (t.cooldown <= 0) { + g.strokeStyle = '#fff' + g.lineWidth = 1.5 + g.beginPath() + g.moveTo(-4, 0) + g.lineTo(10, 0) + g.stroke() + } + break + } + case 'cannon': { + g.rotate(t.angle) + // barrel + g.fillStyle = '#2f353d' + this.roundRect(g, -6 - recoil, -5.5, 24, 11, 4) + g.fill() + g.fillStyle = '#454d57' + this.roundRect(g, -2 - recoil, -3.5, 18, 7, 3) + g.fill() + g.fillStyle = '#22262c' + g.beginPath() + g.arc(-6 - recoil, 0, 8, 0, Math.PI * 2) + g.fill() + g.fillStyle = def.accent + g.beginPath() + g.arc(-6 - recoil, 0, 3, 0, Math.PI * 2) + g.fill() + break + } + case 'frost': { + const pulse = 1 + Math.sin(time * 3) * 0.06 + t.pulse * 0.35 + g.rotate(time * 0.6) + g.scale(pulse, pulse) + g.fillStyle = '#d8f4ff' + g.beginPath() + g.moveTo(0, -13) + g.lineTo(8, 0) + g.lineTo(0, 13) + g.lineTo(-8, 0) + g.closePath() + g.fill() + g.fillStyle = '#8fd8f0' + g.beginPath() + g.moveTo(0, -9) + g.lineTo(5.5, 0) + g.lineTo(0, 9) + g.lineTo(-5.5, 0) + g.closePath() + g.fill() + break + } + case 'tesla': { + g.fillStyle = '#3b3550' + g.fillRect(-3, -14, 6, 20) + const glow = 0.5 + Math.sin(time * 5) * 0.2 + g.fillStyle = `rgba(200,180,255,${glow * 0.35})` + g.beginPath() + g.arc(0, -16, 12, 0, Math.PI * 2) + g.fill() + g.fillStyle = def.accent + g.beginPath() + g.arc(0, -16, 6.5, 0, Math.PI * 2) + g.fill() + g.fillStyle = '#fff' + g.beginPath() + g.arc(-1.5, -17.5, 2.2, 0, Math.PI * 2) + g.fill() + // idle sparks + if (Math.random() < 0.12) { + g.strokeStyle = 'rgba(200,180,255,0.8)' + g.lineWidth = 1.5 + const a = Math.random() * Math.PI * 2 + g.beginPath() + g.moveTo(Math.cos(a) * 7, -16 + Math.sin(a) * 7) + g.lineTo(Math.cos(a) * 12 + 3, -16 + Math.sin(a) * 12 + 2) + g.stroke() + } + break + } + case 'laser': { + g.rotate(t.angle) + const charge = t.cooldown <= 0 ? 1 : Math.max(0, 1 - t.cooldown * def.levels[t.level - 1].rate) + // dish + g.fillStyle = '#3a3f47' + this.roundRect(g, -10, -8, 14, 16, 4) + g.fill() + g.fillStyle = '#565e69' + this.roundRect(g, 0, -4, 14, 8, 3) + g.fill() + g.fillStyle = `rgba(255,138,122,${0.3 + charge * 0.7})` + g.beginPath() + g.arc(14, 0, 3 + charge * 1.5, 0, Math.PI * 2) + g.fill() + break + } + } + g.restore() + } + + // ------------------------------------------------------------ enemies + + private drawEnemy(g: CanvasRenderingContext2D, eng: GameEngine, e: Enemy): void { + const def = ENEMIES[e.kind] + const slowed = eng.time < e.slowUntil + // lane offset perpendicular to heading + const px = -Math.sin(e.heading) * e.laneOff + const py = Math.cos(e.heading) * e.laneOff + const x = e.x + px + const y = e.y + py + const bob = e.flying ? Math.sin(eng.time * 6 + e.wobble) * 3 - 12 : 0 + + g.save() + // shadow + g.fillStyle = 'rgba(0,0,0,0.28)' + g.beginPath() + g.ellipse(x, e.flying ? y + e.r + 6 : y + e.r * 0.7, e.r * 0.8, e.r * 0.3, 0, 0, Math.PI * 2) + g.fill() + + g.translate(x, y + bob) + + const squish = 1 + Math.sin(eng.time * 8 + e.wobble) * 0.07 + const body = def.color + + switch (e.kind) { + case 'normal': { + g.scale(1, squish) + g.fillStyle = body + g.beginPath() + g.arc(0, 0, e.r, 0, Math.PI * 2) + g.fill() + g.fillStyle = 'rgba(0,0,0,0.18)' + g.beginPath() + g.arc(0, e.r * 0.45, e.r * 0.8, 0, Math.PI) + g.fill() + this.eyes(g, e.r) + break + } + case 'fast': { + g.rotate(e.heading) + g.scale(1, squish) + g.fillStyle = body + g.beginPath() + g.moveTo(e.r + 3, 0) + g.lineTo(-e.r, -e.r * 0.8) + g.lineTo(-e.r * 0.4, 0) + g.lineTo(-e.r, e.r * 0.8) + g.closePath() + g.fill() + g.fillStyle = 'rgba(0,0,0,0.2)' + g.beginPath() + g.moveTo(e.r + 3, 0) + g.lineTo(-e.r * 0.4, 0) + g.lineTo(-e.r, e.r * 0.8) + g.closePath() + g.fill() + break + } + case 'tank': { + g.scale(1, 1) + // treads + g.fillStyle = '#3d434c' + this.roundRect(g, -e.r, -e.r - 2, e.r * 2, 6, 3) + g.fill() + this.roundRect(g, -e.r, e.r - 4, e.r * 2, 6, 3) + g.fill() + g.strokeStyle = 'rgba(255,255,255,0.25)' + g.lineWidth = 1.5 + const treadOff = (e.traveled / 6) % 8 + for (let i = 0; i < 4; i++) { + const tx = -e.r + 3 + ((i * 8 + treadOff) % (e.r * 2 - 4)) + g.beginPath() + g.moveTo(tx, -e.r - 1) + g.lineTo(tx, -e.r + 3) + g.stroke() + g.beginPath() + g.moveTo(tx, e.r - 3) + g.lineTo(tx, e.r + 1) + g.stroke() + } + // hull + const hg = g.createLinearGradient(0, -e.r, 0, e.r) + hg.addColorStop(0, '#8f9aa6') + hg.addColorStop(1, '#68727d') + g.fillStyle = hg + this.roundRect(g, -e.r + 2, -e.r + 3, e.r * 2 - 4, e.r * 2 - 6, 4) + g.fill() + g.fillStyle = '#4c545e' + g.beginPath() + g.arc(0, 0, 5, 0, Math.PI * 2) + g.fill() + g.fillStyle = '#2f353d' + g.fillRect(0, -2, e.r + 2, 4) + break + } + case 'flyer': { + const flap = Math.sin(eng.time * 12 + e.wobble) + g.fillStyle = 'rgba(220,190,255,0.75)' + g.beginPath() + g.ellipse(-e.r * 0.9, -2, e.r * 0.85, 4 + flap * 3, -0.5 + flap * 0.25, 0, Math.PI * 2) + g.fill() + g.beginPath() + g.ellipse(e.r * 0.9, -2, e.r * 0.85, 4 + flap * 3, 0.5 - flap * 0.25, 0, Math.PI * 2) + g.fill() + g.fillStyle = body + g.beginPath() + g.ellipse(0, 0, e.r * 0.85, e.r, 0, 0, Math.PI * 2) + g.fill() + this.eyes(g, e.r * 0.9) + break + } + case 'boss': { + // spikes + g.fillStyle = '#8e2f27' + for (let i = 0; i < 10; i++) { + const a = (i / 10) * Math.PI * 2 + eng.time * 0.5 + g.beginPath() + g.moveTo(Math.cos(a) * (e.r - 3), Math.sin(a) * (e.r - 3)) + g.lineTo(Math.cos(a + 0.18) * (e.r + 7), Math.sin(a + 0.18) * (e.r + 7)) + g.lineTo(Math.cos(a + 0.36) * (e.r - 3), Math.sin(a + 0.36) * (e.r - 3)) + g.closePath() + g.fill() + } + const bg = g.createRadialGradient(-4, -4, 3, 0, 0, e.r) + bg.addColorStop(0, '#f07a5f') + bg.addColorStop(1, body) + g.fillStyle = bg + g.beginPath() + g.arc(0, 0, e.r, 0, Math.PI * 2) + g.fill() + // crown + g.fillStyle = '#ffd23e' + g.beginPath() + g.moveTo(-8, -e.r - 2) + g.lineTo(-8, -e.r - 10) + g.lineTo(-4, -e.r - 5) + g.lineTo(0, -e.r - 11) + g.lineTo(4, -e.r - 5) + g.lineTo(8, -e.r - 10) + g.lineTo(8, -e.r - 2) + g.closePath() + g.fill() + // angry eyes + g.fillStyle = '#fff' + g.beginPath() + g.arc(-5, -3, 3.4, 0, Math.PI * 2) + g.arc(5, -3, 3.4, 0, Math.PI * 2) + g.fill() + g.fillStyle = '#301010' + g.beginPath() + g.arc(-4.2, -2.5, 1.7, 0, Math.PI * 2) + g.arc(5.8, -2.5, 1.7, 0, Math.PI * 2) + g.fill() + break + } + } + + // slow indicator + if (slowed) { + g.strokeStyle = 'rgba(140,220,255,0.9)' + g.lineWidth = 2 + g.beginPath() + g.arc(0, 0, e.r + 3.5, 0, Math.PI * 2) + g.stroke() + g.fillStyle = 'rgba(160,230,255,0.25)' + g.beginPath() + g.arc(0, 0, e.r + 1, 0, Math.PI * 2) + g.fill() + } + + // hit flash + if (e.flash > 0) { + g.fillStyle = `rgba(255,255,255,${e.flash * 0.6})` + g.beginPath() + g.arc(0, 0, e.r + 1, 0, Math.PI * 2) + g.fill() + } + g.restore() + + // hp bar + const pct = Math.max(0, e.hp / e.maxHp) + if (pct < 1 || e.kind === 'boss') { + const bw = Math.max(20, e.r * 2.2) + const bx = x - bw / 2 + const by = y + bob - e.r - (e.kind === 'boss' ? 16 : 9) + g.fillStyle = 'rgba(0,0,0,0.55)' + g.fillRect(bx - 1, by - 1, bw + 2, 5.5) + g.fillStyle = pct > 0.5 ? '#5cd74c' : pct > 0.25 ? '#ffd23e' : '#ff5f4e' + g.fillRect(bx, by, bw * pct, 3.5) + } + } + + private eyes(g: CanvasRenderingContext2D, r: number): void { + g.fillStyle = '#fff' + g.beginPath() + g.arc(-r * 0.32, -r * 0.15, r * 0.3, 0, Math.PI * 2) + g.arc(r * 0.32, -r * 0.15, r * 0.3, 0, Math.PI * 2) + g.fill() + g.fillStyle = '#1c2026' + g.beginPath() + g.arc(-r * 0.28, -r * 0.1, r * 0.14, 0, Math.PI * 2) + g.arc(r * 0.36, -r * 0.1, r * 0.14, 0, Math.PI * 2) + g.fill() + } + + // ------------------------------------------------------------ projectiles & effects + + private drawProjectile(g: CanvasRenderingContext2D, kind: 'arrow' | 'ball', x: number, y: number, angle: number): void { + g.save() + g.translate(x, y) + g.rotate(angle) + if (kind === 'arrow') { + g.strokeStyle = '#d8b56a' + g.lineWidth = 2.5 + g.beginPath() + g.moveTo(-7, 0) + g.lineTo(5, 0) + g.stroke() + g.fillStyle = '#fff' + g.beginPath() + g.moveTo(8, 0) + g.lineTo(3, -3) + g.lineTo(3, 3) + g.closePath() + g.fill() + } else { + g.fillStyle = '#26292e' + g.beginPath() + g.arc(0, 0, 5, 0, Math.PI * 2) + g.fill() + g.fillStyle = 'rgba(255,255,255,0.35)' + g.beginPath() + g.arc(-1.5, -1.5, 1.8, 0, Math.PI * 2) + g.fill() + } + g.restore() + } + + private drawEffects(g: CanvasRenderingContext2D, eng: GameEngine): void { + for (const fx of eng.effects) { + const p = fx.life / fx.max + switch (fx.type) { + case 'particle': { + g.globalAlpha = Math.min(1, p * 1.6) + g.fillStyle = fx.color + g.beginPath() + g.arc(fx.x, fx.y, fx.size * (0.4 + p * 0.6), 0, Math.PI * 2) + g.fill() + g.globalAlpha = 1 + break + } + case 'ring': { + const r = fx.r0 + (fx.r1 - fx.r0) * (1 - p) + g.globalAlpha = p + g.strokeStyle = fx.color + g.lineWidth = fx.width + g.beginPath() + g.arc(fx.x, fx.y, r, 0, Math.PI * 2) + g.stroke() + g.globalAlpha = 1 + break + } + case 'beam': { + g.save() + g.globalAlpha = p + g.globalCompositeOperation = 'lighter' + g.strokeStyle = fx.color + g.lineWidth = 7 * p + 1 + g.beginPath() + g.moveTo(fx.x1, fx.y1) + g.lineTo(fx.x2, fx.y2) + g.stroke() + g.strokeStyle = '#fff' + g.lineWidth = 2.5 + g.beginPath() + g.moveTo(fx.x1, fx.y1) + g.lineTo(fx.x2, fx.y2) + g.stroke() + g.restore() + break + } + case 'bolt': { + g.save() + g.globalAlpha = p + g.globalCompositeOperation = 'lighter' + for (let pass = 0; pass < 2; pass++) { + g.strokeStyle = pass === 0 ? fx.color : '#fff' + g.lineWidth = pass === 0 ? 4 : 1.8 + g.beginPath() + g.moveTo(fx.pts[0].x, fx.pts[0].y) + for (let i = 1; i < fx.pts.length; i++) { + const a = fx.pts[i - 1] + const b = fx.pts[i] + const mx = (a.x + b.x) / 2 + (Math.random() - 0.5) * 14 + const my = (a.y + b.y) / 2 + (Math.random() - 0.5) * 14 + g.lineTo(mx, my) + g.lineTo(b.x, b.y) + } + g.stroke() + } + g.restore() + break + } + case 'text': { + g.globalAlpha = Math.min(1, p * 2) + g.fillStyle = fx.color + g.font = `bold ${fx.size}px system-ui` + g.textAlign = 'center' + g.strokeStyle = 'rgba(0,0,0,0.6)' + g.lineWidth = 3 + g.strokeText(fx.str, fx.x, fx.y) + g.fillText(fx.str, fx.x, fx.y) + g.globalAlpha = 1 + break + } + case 'announce': { + const inP = Math.min(1, (1 - p) * 5) + const y = H / 2 - 30 + g.globalAlpha = Math.min(1, p * 2.5) + g.textAlign = 'center' + g.font = `bold ${Math.round(30 + (1 - inP) * 20)}px system-ui` + g.strokeStyle = 'rgba(0,0,0,0.75)' + g.lineWidth = 6 + g.strokeText(fx.str, W / 2, y) + g.fillStyle = '#fff' + g.fillText(fx.str, W / 2, y) + g.font = 'bold 15px system-ui' + g.strokeText(fx.sub, W / 2, y + 26) + g.fillStyle = '#ffd23e' + g.fillText(fx.sub, W / 2, y + 26) + g.globalAlpha = 1 + break + } + } + } + } + + private drawBossBars(g: CanvasRenderingContext2D, eng: GameEngine): void { + const bosses = eng.enemies.filter((e) => e.kind === 'boss' && !e.dead) + if (bosses.length === 0) return + const bw = 300 + for (let i = 0; i < bosses.length; i++) { + const e = bosses[i] + const pct = Math.max(0, e.hp / e.maxHp) + const x = W / 2 - bw / 2 + const y = 14 + i * 20 + g.fillStyle = 'rgba(0,0,0,0.6)' + this.roundRect(g, x - 2, y - 2, bw + 4, 14, 6) + g.fill() + g.fillStyle = '#7a2620' + this.roundRect(g, x, y, bw, 10, 4) + g.fill() + g.fillStyle = '#e04b3a' + this.roundRect(g, x, y, bw * pct, 10, 4) + g.fill() + g.fillStyle = '#fff' + g.font = 'bold 10px system-ui' + g.textAlign = 'center' + g.fillText(`BOSS ${Math.ceil(e.hp)}`, W / 2, y + 8) + } + } + + // ------------------------------------------------------------ ghost + + private drawGhost(g: CanvasRenderingContext2D, eng: GameEngine): void { + if (!eng.hover || !eng.buildType) return + const def = TOWERS[eng.buildType] + const tx = eng.hover.tx + const ty = eng.hover.ty + const cx = tx * TILE + TILE / 2 + const cy = ty * TILE + TILE / 2 + const valid = eng.canPlace(tx, ty) && eng.money >= def.cost + + const range = def.levels[0].range + g.save() + g.fillStyle = valid ? 'rgba(90,220,120,0.1)' : 'rgba(255,80,60,0.12)' + g.strokeStyle = valid ? 'rgba(90,220,120,0.7)' : 'rgba(255,80,60,0.8)' + g.lineWidth = 2 + g.setLineDash([8, 6]) + g.beginPath() + g.arc(cx, cy, range, 0, Math.PI * 2) + g.stroke() + g.fill() + g.setLineDash([]) + + g.strokeStyle = valid ? 'rgba(255,255,255,0.9)' : 'rgba(255,80,60,0.9)' + g.strokeRect(tx * TILE + 2, ty * TILE + 2, TILE - 4, TILE - 4) + + if (valid) { + this.drawTower( + g, + eng, + { + id: -1, + kind: eng.buildType, + tx, + ty, + x: cx, + y: cy, + level: 1, + cooldown: 0, + angle: -Math.PI / 2, + kills: 0, + damageDealt: 0, + targeting: 'first', + invested: 0, + recoil: 0, + pulse: 0, + targetId: null, + owner: null, + }, + true, + ) + } else { + g.strokeStyle = 'rgba(255,80,60,0.95)' + g.lineWidth = 4 + g.beginPath() + g.moveTo(cx - 10, cy - 10) + g.lineTo(cx + 10, cy + 10) + g.moveTo(cx + 10, cy - 10) + g.lineTo(cx - 10, cy + 10) + g.stroke() + } + g.restore() + } + + // ------------------------------------------------------------ helpers + + private roundRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void { + g.beginPath() + g.moveTo(x + r, y) + g.arcTo(x + w, y, x + w, y + h, r) + g.arcTo(x + w, y + h, x, y + h, r) + g.arcTo(x, y + h, x, y, r) + g.arcTo(x, y, x + w, y, r) + g.closePath() + } + + private shade(hex: string, amt: number): string { + const n = parseInt(hex.slice(1), 16) + const r = Math.max(0, Math.min(255, (n >> 16) + amt)) + const gg = Math.max(0, Math.min(255, ((n >> 8) & 0xff) + amt)) + const b = Math.max(0, Math.min(255, (n & 0xff) + amt)) + return `rgb(${r},${gg},${b})` + } +} diff --git a/src/game/sound.ts b/src/game/sound.ts new file mode 100644 index 0000000..334e990 --- /dev/null +++ b/src/game/sound.ts @@ -0,0 +1,171 @@ +/** + * Tiny WebAudio synth – all sound effects are generated, no audio assets needed. + */ +export type SfxName = + | 'shootArrow' + | 'shootCannon' + | 'pulse' + | 'tesla' + | 'laser' + | 'death' + | 'build' + | 'upgrade' + | 'sell' + | 'leak' + | 'wave' + | 'victory' + | 'defeat' + | 'click' + | 'error' + +class SoundManager { + private ctx: AudioContext | null = null + private master: GainNode | null = null + private lastPlay = new Map() + muted = false + + constructor() { + if (typeof localStorage !== 'undefined') { + this.muted = localStorage.getItem('trxtd-muted') === '1' + } + } + + /** must be called from a user gesture at least once */ + ensure(): void { + if (typeof AudioContext === 'undefined') return + if (!this.ctx) { + try { + this.ctx = new AudioContext() + this.master = this.ctx.createGain() + this.master.gain.value = 0.4 + this.master.connect(this.ctx.destination) + } catch { + this.ctx = null + } + } + if (this.ctx && this.ctx.state === 'suspended') void this.ctx.resume() + } + + setMuted(m: boolean): void { + this.muted = m + if (typeof localStorage !== 'undefined') { + localStorage.setItem('trxtd-muted', m ? '1' : '0') + } + } + + private tone( + freq: number, + dur: number, + type: OscillatorType, + vol: number, + slideTo?: number, + delay = 0, + ): void { + if (!this.ctx || !this.master) return + const t0 = this.ctx.currentTime + delay + const osc = this.ctx.createOscillator() + const gain = this.ctx.createGain() + osc.type = type + osc.frequency.setValueAtTime(freq, t0) + if (slideTo !== undefined) osc.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), t0 + dur) + gain.gain.setValueAtTime(vol, t0) + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur) + osc.connect(gain) + gain.connect(this.master) + osc.start(t0) + osc.stop(t0 + dur + 0.02) + } + + private noise(dur: number, vol: number, filterFreq: number, delay = 0): void { + if (!this.ctx || !this.master) return + const t0 = this.ctx.currentTime + delay + const len = Math.max(1, Math.floor(this.ctx.sampleRate * dur)) + const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len) + const src = this.ctx.createBufferSource() + src.buffer = buf + const filter = this.ctx.createBiquadFilter() + filter.type = 'lowpass' + filter.frequency.value = filterFreq + const gain = this.ctx.createGain() + gain.gain.value = vol + src.connect(filter) + filter.connect(gain) + gain.connect(this.master) + src.start(t0) + } + + play(name: SfxName): void { + if (this.muted || !this.ctx) return + // rate limit identical sounds (avoids buzzing at 3x speed) + const now = this.ctx.currentTime + const last = this.lastPlay.get(name) ?? -1 + if (now - last < 0.035) return + this.lastPlay.set(name, now) + + switch (name) { + case 'shootArrow': + this.tone(660, 0.06, 'square', 0.06, 880) + break + case 'shootCannon': + this.noise(0.18, 0.5, 900) + this.tone(110, 0.16, 'triangle', 0.25, 55) + break + case 'pulse': + this.tone(950, 0.22, 'sine', 0.1, 380) + break + case 'tesla': + this.noise(0.08, 0.25, 6000) + this.tone(520, 0.1, 'square', 0.06, 120) + break + case 'laser': + this.tone(1250, 0.2, 'sawtooth', 0.12, 180) + break + case 'death': + this.tone(320, 0.14, 'triangle', 0.14, 90) + break + case 'build': + this.tone(420, 0.07, 'square', 0.12) + this.tone(640, 0.09, 'square', 0.12, undefined, 0.07) + break + case 'upgrade': + this.tone(440, 0.09, 'square', 0.1) + this.tone(560, 0.09, 'square', 0.1, undefined, 0.08) + this.tone(720, 0.12, 'square', 0.1, undefined, 0.16) + break + case 'sell': + this.tone(600, 0.08, 'triangle', 0.12, 320) + this.tone(380, 0.1, 'triangle', 0.1, 200, 0.08) + break + case 'leak': + this.tone(190, 0.16, 'sawtooth', 0.2) + this.tone(140, 0.22, 'sawtooth', 0.2, undefined, 0.16) + break + case 'wave': + this.tone(330, 0.1, 'square', 0.1) + this.tone(392, 0.1, 'square', 0.1, undefined, 0.1) + this.tone(494, 0.16, 'square', 0.12, undefined, 0.2) + break + case 'victory': + this.tone(523, 0.14, 'square', 0.12) + this.tone(659, 0.14, 'square', 0.12, undefined, 0.14) + this.tone(784, 0.14, 'square', 0.12, undefined, 0.28) + this.tone(1047, 0.3, 'square', 0.14, undefined, 0.42) + break + case 'defeat': + this.tone(400, 0.25, 'sawtooth', 0.14, 300) + this.tone(300, 0.3, 'sawtooth', 0.14, 200, 0.25) + this.tone(200, 0.5, 'sawtooth', 0.14, 90, 0.5) + break + case 'click': + this.tone(800, 0.04, 'square', 0.05) + break + case 'error': + this.tone(160, 0.12, 'square', 0.1, 120) + break + } + } +} + +export const sound = new SoundManager() diff --git a/src/game/store.ts b/src/game/store.ts new file mode 100644 index 0000000..d62ef83 --- /dev/null +++ b/src/game/store.ts @@ -0,0 +1,67 @@ +import { reactive } from 'vue' +import type { DifficultyId, EnemyKind, MPMode, Phase, Screen, SelectedTowerInfo, TowerKind } from './types' + +/** + * Reactive bridge between the (non-reactive) game engine and the Vue UI. + * The engine writes, the components read. + */ +export const store = reactive({ + screen: 'menu' as Screen, + paused: false, + speed: 1, + muted: false, + difficulty: 'normal' as DifficultyId, + + money: 0, + lives: 0, + maxLives: 0, + waveNo: 0, + totalWaves: 20, + endless: false, + + phase: 'idle' as Phase, + nextWaveIn: 0, + waveActive: false, + + score: 0, + kills: 0, + + buildType: null as TowerKind | null, + hoverValid: false, + selected: null as SelectedTowerInfo | null, + obstacle: null as { tx: number; ty: number; type: 'tree' | 'rock'; cost: number } | null, + + /** composition of the upcoming wave, for the preview chips */ + nextPreview: [] as { kind: EnemyKind; count: number }[], + + result: null as { win: boolean; score: number; wave: number; kills: number; best: number; bestBefore: number } | null, + + /** multiplayer state (lobby + in-game info) */ + mp: { + active: false, + mode: 'coop' as MPMode, + status: '', + name: '', + peerName: '', + roomCode: '', + players: [] as { id: number; name: string }[], + myId: 0, + isHost: false, + inLobby: false, + // duel: live stats of the opponent board + opponentLives: 0, + opponentGold: 0, + opponentWave: 0, + rushCost: 0, + resultMsg: '', + }, +}) + +export function bestScoreKey(diff: DifficultyId): string { + return `trxtd-best-${diff}` +} + +export function loadBest(diff: DifficultyId): number { + if (typeof localStorage === 'undefined') return 0 + return Number(localStorage.getItem(bestScoreKey(diff)) ?? 0) +} diff --git a/src/game/types.ts b/src/game/types.ts new file mode 100644 index 0000000..3dc188c --- /dev/null +++ b/src/game/types.ts @@ -0,0 +1,181 @@ +export type TowerKind = 'arrow' | 'cannon' | 'frost' | 'tesla' | 'laser' +export type EnemyKind = 'normal' | 'fast' | 'tank' | 'flyer' | 'boss' +export type TargetingMode = 'first' | 'last' | 'strong' | 'close' +export type Phase = 'idle' | 'intermission' | 'wave' | 'gameover' | 'victory' +export type Screen = 'menu' | 'lobby' | 'game' | 'gameover' | 'victory' +export type DifficultyId = 'easy' | 'normal' | 'hard' +export type MPMode = 'coop' | 'duel' + +/** synchronized player actions (lockstep); applied identically on all clients */ +export type MPAction = + | { type: 'build'; kind: TowerKind; tx: number; ty: number } + | { type: 'upgrade'; towerId: number } + | { type: 'sell'; towerId: number } + | { type: 'targeting'; towerId: number; mode: TargetingMode } + | { type: 'obstacle'; tx: number; ty: number } + | { type: 'wave' } + | { type: 'speed'; s: number } + | { type: 'rush' } + +export interface Pt { + x: number + y: number +} + +export interface EnemyDef { + kind: EnemyKind + name: string + hp: number + speed: number + reward: number + radius: number + dmg: number + flying: boolean + color: string +} + +export interface TowerLevelStats { + damage: number + range: number + /** shots per second */ + rate: number +} + +export interface TowerDef { + kind: TowerKind + name: string + icon: string + desc: string + cost: number + /** upgrade cost to level 2 and 3 */ + upgradeCost: [number, number] + levels: [TowerLevelStats, TowerLevelStats, TowerLevelStats] + hitsFlying: boolean + projectileSpeed?: number + /** splash radius per level (cannon) */ + splash?: [number, number, number] + /** speed multiplier applied while slowed, per level (frost) */ + slowFactor?: [number, number, number] + slowDuration?: [number, number, number] + /** chain lightning targets per level (tesla) */ + chain?: [number, number, number] + chainRange?: number + color: string + accent: string + hotkey: string +} + +export interface Enemy { + id: number + kind: EnemyKind + flying: boolean + x: number + y: number + /** index of the waypoint this enemy is moving towards */ + wp: number + hp: number + maxHp: number + speedBase: number + reward: number + dmg: number + r: number + traveled: number + slowUntil: number + slowFactor: number + dead: boolean + escaped: boolean + flash: number + wobble: number + laneOff: number + heading: number +} + +export interface Tower { + id: number + kind: TowerKind + tx: number + ty: number + x: number + y: number + level: 1 | 2 | 3 + cooldown: number + angle: number + kills: number + damageDealt: number + targeting: TargetingMode + invested: number + recoil: number + pulse: number + targetId: number | null + /** multiplayer: which player built this tower (null in solo) */ + owner: number | null +} + +export interface Projectile { + kind: 'arrow' | 'ball' + x: number + y: number + angle: number + speed: number + damage: number + splash: number + target: Enemy | null + tx: number + ty: number + life: number + trail: number + ownerId: number +} + +export type Effect = + | { type: 'particle'; x: number; y: number; vx: number; vy: number; life: number; max: number; size: number; color: string; grav: number } + | { type: 'ring'; x: number; y: number; r0: number; r1: number; life: number; max: number; color: string; width: number } + | { type: 'beam'; x1: number; y1: number; x2: number; y2: number; life: number; max: number; color: string } + | { type: 'bolt'; pts: Pt[]; life: number; max: number; color: string } + | { type: 'text'; x: number; y: number; vy: number; life: number; max: number; str: string; color: string; size: number } + | { type: 'announce'; str: string; sub: string; life: number; max: number } + +export interface SelectedTowerInfo { + id: number + kind: TowerKind + name: string + icon: string + desc: string + level: number + maxLevel: boolean + damage: number + range: number + rate: number + dps: number + upgradeCost: number | null + sellValue: number + targeting: TargetingMode + kills: number + damageDealt: number + hitsFlying: boolean + special: string +} + +export interface WaveGroup { + kind: EnemyKind + count: number + /** seconds between spawns */ + interval: number +} + +export interface DifficultyDef { + id: DifficultyId + name: string + desc: string + lives: number + money: number + hpMul: number +} + +export interface DecorItem { + type: 'tree' | 'rock' | 'flower' | 'bush' + x: number + y: number + s: number + seed: number +} diff --git a/src/game/utils.ts b/src/game/utils.ts new file mode 100644 index 0000000..1f633c7 --- /dev/null +++ b/src/game/utils.ts @@ -0,0 +1,46 @@ +export function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v +} + +export function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +export function dist(ax: number, ay: number, bx: number, by: number): number { + return Math.hypot(bx - ax, by - ay) +} + +export function angleTo(ax: number, ay: number, bx: number, by: number): number { + return Math.atan2(by - ay, bx - ax) +} + +/** shortest-path angle interpolation */ +export function lerpAngle(a: number, b: number, t: number): number { + let d = b - a + while (d > Math.PI) d -= Math.PI * 2 + while (d < -Math.PI) d += Math.PI * 2 + return a + d * t +} + +export function angleDiff(a: number, b: number): number { + let d = b - a + while (d > Math.PI) d -= Math.PI * 2 + while (d < -Math.PI) d += Math.PI * 2 + return Math.abs(d) +} + +/** deterministic RNG (mulberry32) */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export function cellKey(tx: number, ty: number): string { + return tx + ',' + ty +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..fe5bae3 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..dcd9f47 --- /dev/null +++ b/src/style.css @@ -0,0 +1,59 @@ +:root { + --bg: #0e131a; + --panel: #161d27; + --panel-inset: #0e141c; + --panel-border: #263140; + --text: #e8edf4; + --text-dim: #93a1b3; + --accent: #f5c542; + --accent-dim: #8a7434; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background: + radial-gradient(1200px 500px at 50% -100px, rgba(64, 110, 59, 0.25), transparent), + var(--bg); + color: var(--text); + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + min-height: 100vh; +} + +#app { + min-height: 100vh; +} + +button { + font-family: inherit; +} + +.btn { + background: var(--panel-inset); + border: 1px solid var(--panel-border); + color: var(--text); + font-weight: 700; + border-radius: 9px; + padding: 8px 12px; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} +.btn:hover { + border-color: var(--accent-dim); +} + +@keyframes pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(224, 86, 63, 0.5); } + 50% { box-shadow: 0 0 0 8px rgba(224, 86, 63, 0); } +} +.pulse { + animation: pulse 1.6s infinite; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..38fb553 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "esModuleInterop": true, + "useDefineForClassFields": true, + "jsx": "preserve", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "scripts/**/*.mts", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..a52797c --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +})