slop game oder so
@@ -0,0 +1,23 @@
|
||||
|
||||
|
||||
Impact Sounds (1.0)
|
||||
|
||||
Created/distributed by Kenney (www.kenney.nl)
|
||||
Creation date: 19-12-2019
|
||||
|
||||
------------------------------
|
||||
|
||||
License: (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
This content is free to use in personal, educational and commercial projects.
|
||||
|
||||
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
||||
|
||||
------------------------------
|
||||
|
||||
Donate: http://support.kenney.nl
|
||||
Request: http://request.kenney.nl
|
||||
Patreon: http://patreon.com/kenney/
|
||||
|
||||
Follow on Twitter for updates:
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
|
||||
Interface Sounds (1.0)
|
||||
|
||||
Created/distributed by Kenney (www.kenney.nl)
|
||||
Creation date: 11-02-2020
|
||||
|
||||
------------------------------
|
||||
|
||||
License: (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
This content is free to use in personal, educational and commercial projects.
|
||||
|
||||
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
||||
|
||||
------------------------------
|
||||
|
||||
Donate: http://support.kenney.nl
|
||||
Patreon: http://patreon.com/kenney/
|
||||
|
||||
Follow on Twitter for updates:
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
|
||||
Music Jingles
|
||||
|
||||
by Kenney Vleugels (Kenney.nl)
|
||||
|
||||
------------------------------
|
||||
|
||||
License (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
You may use these assets in personal and commercial projects.
|
||||
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
|
||||
|
||||
------------------------------
|
||||
|
||||
Donate: http://support.kenney.nl
|
||||
Request: http://request.kenney.nl
|
||||
|
||||
Follow on Twitter for updates:
|
||||
@KenneyNL
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
|
||||
Sci-Fi Sounds (1.0)
|
||||
|
||||
Created/distributed by Kenney (www.kenney.nl)
|
||||
Creation date: 11-10-2020
|
||||
|
||||
------------------------------
|
||||
|
||||
License: (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
This content is free to use in personal, educational and commercial projects.
|
||||
|
||||
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
||||
|
||||
------------------------------
|
||||
|
||||
Donate: http://support.kenney.nl
|
||||
Patreon: http://patreon.com/kenney/
|
||||
|
||||
Follow on Twitter for updates:
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
|
||||
Particle Pack (1.1)
|
||||
|
||||
by Kenney Vleugels (Kenney.nl)
|
||||
|
||||
Additional credit for creating filter templates:
|
||||
Indigo Ray, Craig Nisbet, Zoltan Erdokovy, Heliagon, ThreeDee, Killst4r and Tim2501
|
||||
|
||||
------------------------------
|
||||
|
||||
License (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
You may use these assets in personal and commercial projects.
|
||||
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
|
||||
|
||||
------------------------------
|
||||
|
||||
Donate: http://support.kenney.nl
|
||||
Request: http://request.kenney.nl
|
||||
|
||||
Follow on Twitter for updates: @KenneyNL (www.twitter.com/kenneynl)
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,21 @@
|
||||
|
||||
|
||||
Topdown (Shooter) Pack
|
||||
|
||||
by Kenney Vleugels (Kenney.nl)
|
||||
|
||||
------------------------------
|
||||
|
||||
License (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
You may use these assets in personal and commercial projects.
|
||||
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
|
||||
|
||||
------------------------------
|
||||
|
||||
Donate: http://support.kenney.nl
|
||||
Request: http://request.kenney.nl
|
||||
|
||||
Follow on Twitter for updates:
|
||||
@KenneyWings
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<!-- Blechsoldat (Blechlegion, mittel): Roboter mit Waffenarm. Eigenes Werk, CC0. -->
|
||||
<rect x="14" y="5" width="16" height="10" rx="3" fill="#7d8798" stroke="#3a414d" stroke-width="1.6"/>
|
||||
<rect x="14" y="33" width="16" height="10" rx="3" fill="#7d8798" stroke="#3a414d" stroke-width="1.6"/>
|
||||
<rect x="24" y="31" width="22" height="6" rx="1.5" fill="#3a414d"/>
|
||||
<rect x="40" y="32" width="7.5" height="4" fill="#2a2f38"/>
|
||||
<rect x="11" y="12" width="22" height="24" rx="5" fill="#9aa4b5" stroke="#3a414d" stroke-width="1.8"/>
|
||||
<path d="M14 18 H30 M14 30 H30" stroke="#535c6b" stroke-width="1.4"/>
|
||||
<path d="M12.5 21 h4 l-3 5 h-1 z" fill="#f2c14e"/>
|
||||
<circle cx="26" cy="24" r="7" fill="#b3bccb" stroke="#3a414d" stroke-width="1.6"/>
|
||||
<rect x="28" y="20" width="3.5" height="8" rx="1.5" fill="#ff3b30"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 906 B |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 84" width="84" height="84">
|
||||
<!-- Brutmutter (Schwärmer, Elite): aufgeblähter Hinterleib mit Eiern. Eigenes Werk, CC0. -->
|
||||
<g stroke="#33471a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M36 26 L30 10 L22 4"/><path d="M46 26 L48 8 L56 2.5"/><path d="M54 31 L64 16 L72 14"/>
|
||||
<path d="M36 58 L30 74 L22 80"/><path d="M46 58 L48 76 L56 81.5"/><path d="M54 53 L64 68 L72 70"/>
|
||||
</g>
|
||||
<ellipse cx="30" cy="42" rx="26" ry="22" fill="#6f9a2c" stroke="#33471a" stroke-width="2.5"/>
|
||||
<path d="M14 26 Q24 42 14 58 M26 21 Q36 42 26 63" stroke="#4f6d1e" stroke-width="2" fill="none"/>
|
||||
<g fill="#e6d38c" stroke="#9c8a3e" stroke-width="1.2">
|
||||
<ellipse cx="18" cy="34" rx="5" ry="4"/>
|
||||
<ellipse cx="22" cy="46" rx="5.5" ry="4.5"/>
|
||||
<ellipse cx="33" cy="38" rx="4.5" ry="3.8"/>
|
||||
<ellipse cx="12" cy="44" rx="4" ry="3.5"/>
|
||||
<ellipse cx="31" cy="51" rx="4" ry="3.2"/>
|
||||
</g>
|
||||
<ellipse cx="56" cy="42" rx="11" ry="10" fill="#8fbf3a" stroke="#33471a" stroke-width="2.5"/>
|
||||
<circle cx="69" cy="42" r="7" fill="#8fbf3a" stroke="#33471a" stroke-width="2.5"/>
|
||||
<path d="M74 37 Q81 36 82.5 40 M74 47 Q81 48 82.5 44" stroke="#33471a" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="71" cy="39" r="1.8" fill="#ffe066"/>
|
||||
<circle cx="71" cy="45" r="1.8" fill="#ffe066"/>
|
||||
<circle cx="68" cy="42" r="1.2" fill="#ffe066"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||
<!-- Emblem der fiktiven „Vereinigten Erde“: Planet mit Umlaufbahn und Mond, Strahlenkranz.
|
||||
Bewusst ohne Lorbeerzweige oder Sternenkreis, um keinem realen Emblem zu ähneln.
|
||||
Eigenes Werk für Glory Defense, CC0. -->
|
||||
<circle cx="100" cy="100" r="95" fill="#0d1321" stroke="#f2c14e" stroke-width="8"/>
|
||||
<circle cx="100" cy="100" r="80" fill="none" stroke="#c1121f" stroke-width="4"/>
|
||||
<g stroke="#f2c14e" stroke-width="4" stroke-linecap="round" opacity="0.55">
|
||||
<path d="M100 22 v12 M100 166 v12 M22 100 h12 M166 100 h12"/>
|
||||
<path d="M45 45 l8 8 M147 147 l8 8 M155 45 l-8 8 M53 147 l-8 8"/>
|
||||
</g>
|
||||
<circle cx="100" cy="100" r="40" fill="#c1121f" stroke="#7a0a12" stroke-width="3"/>
|
||||
<path d="M70 92 Q88 80 104 88 Q120 96 132 90 Q128 110 110 112 Q92 116 72 106 Z" fill="#a50f1a"/>
|
||||
<ellipse cx="100" cy="100" rx="80" ry="24" fill="none" stroke="#f2c14e" stroke-width="7" transform="rotate(-20 100 100)"/>
|
||||
<circle cx="170" cy="70" r="9" fill="#f4ecd8" stroke="#b8892a" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36">
|
||||
<!-- Krabbler (Schwärmer, klein). Eigenes Werk für Glory Defense, CC0. -->
|
||||
<g stroke="#33471a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M14 13 L10 5 L6 3"/><path d="M18 13 L18 4 L15 1.5"/><path d="M22 13 L26 5 L30 3"/>
|
||||
<path d="M14 23 L10 31 L6 33"/><path d="M18 23 L18 32 L15 34.5"/><path d="M22 23 L26 31 L30 33"/>
|
||||
</g>
|
||||
<ellipse cx="11" cy="18" rx="8" ry="6.5" fill="#6f9a2c" stroke="#33471a" stroke-width="1.5"/>
|
||||
<path d="M7 14 Q11 18 7 22 M11 12.5 Q15 18 11 23.5" stroke="#4f6d1e" stroke-width="1.2" fill="none"/>
|
||||
<ellipse cx="20" cy="18" rx="5" ry="4.5" fill="#8fbf3a" stroke="#33471a" stroke-width="1.5"/>
|
||||
<circle cx="26" cy="18" r="4" fill="#8fbf3a" stroke="#33471a" stroke-width="1.5"/>
|
||||
<path d="M29 15.5 Q33 15 34.5 17 M29 20.5 Q33 21 34.5 19" stroke="#33471a" stroke-width="1.8" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="27.5" cy="16.2" r="1.1" fill="#ffe066"/>
|
||||
<circle cx="27.5" cy="19.8" r="1.1" fill="#ffe066"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36">
|
||||
<!-- Nieter-Drohne (Blechlegion, klein): schwebende Drohne mit Sensorlinse. Eigenes Werk, CC0. -->
|
||||
<ellipse cx="14" cy="6" rx="8" ry="3.5" fill="#535c6b" stroke="#3a414d" stroke-width="1.2"/>
|
||||
<ellipse cx="14" cy="30" rx="8" ry="3.5" fill="#535c6b" stroke="#3a414d" stroke-width="1.2"/>
|
||||
<rect x="24" y="16" width="11" height="4" rx="1" fill="#3a414d"/>
|
||||
<circle cx="17" cy="18" r="10" fill="#9aa4b5" stroke="#3a414d" stroke-width="1.8"/>
|
||||
<circle cx="17" cy="18" r="6" fill="none" stroke="#535c6b" stroke-width="1.5"/>
|
||||
<circle cx="22" cy="18" r="3.2" fill="#ff3b30" stroke="#7a0a12" stroke-width="1"/>
|
||||
<circle cx="23" cy="17" r="1" fill="#ffd0c0"/>
|
||||
<g fill="#3a414d">
|
||||
<circle cx="11" cy="13" r="0.9"/><circle cx="11" cy="23" r="0.9"/>
|
||||
<circle cx="17" cy="9.5" r="0.9"/><circle cx="17" cy="26.5" r="0.9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 926 B |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<!-- Präsident Maximilian Valor (frei erfunden), Draufsicht, Blick nach rechts.
|
||||
Geschlossener Helm ohne Gesicht; Umhang in den Farben der fiktiven Vereinigten Erde.
|
||||
Eigenes Werk für Glory Defense, CC0. -->
|
||||
<path d="M31 13 C19 11 8 18 3 32 C8 46 19 53 31 51 L28 32 Z" fill="#c1121f" stroke="#7a0a12" stroke-width="1.5"/>
|
||||
<path d="M27 16.5 C17.5 16.5 10 23 7 32 C10 41 17.5 47.5 27 47.5" fill="none" stroke="#f2c14e" stroke-width="2"/>
|
||||
<circle cx="15" cy="32" r="4.6" fill="#0d1321" stroke="#f2c14e" stroke-width="1.2"/>
|
||||
<ellipse cx="15" cy="32" rx="7.2" ry="2.3" fill="none" stroke="#f2c14e" stroke-width="1.1" transform="rotate(-25 15 32)"/>
|
||||
<ellipse cx="32" cy="17" rx="9" ry="6" fill="#e9e4d8" stroke="#b8892a" stroke-width="1.5"/>
|
||||
<ellipse cx="32" cy="47" rx="9" ry="6" fill="#e9e4d8" stroke="#b8892a" stroke-width="1.5"/>
|
||||
<path d="M26 16 h12 M26 48 h12" stroke="#f2c14e" stroke-width="1.6"/>
|
||||
<rect x="36" y="38" width="27" height="5" rx="1.5" fill="#2b3040"/>
|
||||
<rect x="40" y="36.2" width="11" height="8.6" rx="2" fill="#3a414d"/>
|
||||
<circle cx="32" cy="32" r="13" fill="#f4ecd8" stroke="#b8892a" stroke-width="2"/>
|
||||
<path d="M24 26 L30 32 L24 38" fill="none" stroke="#e0d6bd" stroke-width="2"/>
|
||||
<circle cx="34" cy="32" r="8.5" fill="#e9e4d8" stroke="#b8892a" stroke-width="1.5"/>
|
||||
<rect x="25.5" y="31" width="11" height="2" rx="1" fill="#f2c14e"/>
|
||||
<ellipse cx="39.2" cy="32" rx="3.1" ry="6.2" fill="#0d1321"/>
|
||||
<ellipse cx="40.1" cy="29.4" rx="1" ry="2.1" fill="#6f9ccf" opacity="0.85"/>
|
||||
<circle cx="45" cy="40.5" r="3" fill="#e9e4d8" stroke="#b8892a" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<!-- Sprungkralle (Schwärmer, mittel): kräftige Sprungbeine, Sensenklauen. Eigenes Werk, CC0. -->
|
||||
<g stroke="#33471a" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M14 18 L7 8 L14 3.5"/><path d="M14 30 L7 40 L14 44.5"/>
|
||||
<path d="M22 19 L20 10 L17 8"/><path d="M22 29 L20 38 L17 40"/>
|
||||
</g>
|
||||
<path d="M28 19 L38 11.5 L46.5 13.5 L39 15.5 L30.5 21.5 Z" fill="#b5d65f" stroke="#33471a" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M28 29 L38 36.5 L46.5 34.5 L39 32.5 L30.5 26.5 Z" fill="#b5d65f" stroke="#33471a" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<ellipse cx="12" cy="24" rx="10" ry="7" fill="#6f9a2c" stroke="#33471a" stroke-width="1.5"/>
|
||||
<path d="M6 19 Q10 24 6 29 M11 17.5 Q15 24 11 30.5" stroke="#4f6d1e" stroke-width="1.3" fill="none"/>
|
||||
<ellipse cx="25" cy="24" rx="7" ry="5" fill="#8fbf3a" stroke="#33471a" stroke-width="1.5"/>
|
||||
<path d="M30 20 L38.5 24 L30 28 Z" fill="#8fbf3a" stroke="#33471a" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<circle cx="32" cy="22.2" r="1.4" fill="#ff5a3c"/>
|
||||
<circle cx="32" cy="25.8" r="1.4" fill="#ff5a3c"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<!-- Nachschubkapsel »Fürsorgepaket«: Kapsel mit Geschenkband (bewusst kein Rotkreuz-Symbol,
|
||||
das ist ein geschütztes Zeichen). Eigenes Werk für Glory Defense, CC0. -->
|
||||
<path d="M10 10 L4 4 M38 10 L44 4 M10 38 L4 44 M38 38 L44 44" stroke="#2f7d74" stroke-width="4" stroke-linecap="round"/>
|
||||
<rect x="7" y="7" width="34" height="34" rx="9" fill="#4fd1c5" stroke="#1e5a54" stroke-width="2.5"/>
|
||||
<rect x="12" y="12" width="24" height="24" rx="5" fill="#f4ecd8"/>
|
||||
<path d="M24 12 V36 M12 24 H36" stroke="#f2c14e" stroke-width="4"/>
|
||||
<path d="M24 24 C19 16 14 19 18 23 Z M24 24 C29 16 34 19 30 23 Z" fill="#f2c14e" stroke="#b8892a" stroke-width="1"/>
|
||||
<circle cx="24" cy="24" r="2.8" fill="#b8892a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 808 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" width="52" height="52">
|
||||
<!-- Geschütz »Wachsamer Nachbar«. Eigenes Werk für Glory Defense, CC0. -->
|
||||
<path d="M16 4 H36 L48 16 V36 L36 48 H16 L4 36 V16 Z" fill="#1b2438" stroke="#9be7ff" stroke-width="2"/>
|
||||
<path d="M16 9 H36 M16 43 H36" stroke="#f2c14e" stroke-width="2" stroke-dasharray="4 3"/>
|
||||
<rect x="26" y="22" width="25" height="8" rx="2" fill="#3a414d" stroke="#9be7ff" stroke-width="1"/>
|
||||
<circle cx="26" cy="26" r="11" fill="#2f3a52" stroke="#9be7ff" stroke-width="2"/>
|
||||
<circle cx="26" cy="26" r="4" fill="#9be7ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 607 B |
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 88" width="88" height="88">
|
||||
<!-- Walzmeister (Blechlegion, Elite): Kettenkoloss mit Zwillingsrohren. Eigenes Werk, CC0. -->
|
||||
<defs>
|
||||
<clipPath id="walzmeister-hazard"><rect x="12" y="20" width="10" height="48" rx="3"/></clipPath>
|
||||
</defs>
|
||||
<rect x="6" y="4" width="70" height="16" rx="6" fill="#2a2f38"/>
|
||||
<rect x="6" y="68" width="70" height="16" rx="6" fill="#2a2f38"/>
|
||||
<g stroke="#535c6b" stroke-width="3">
|
||||
<path d="M14 6 v12 M24 6 v12 M34 6 v12 M44 6 v12 M54 6 v12 M64 6 v12"/>
|
||||
<path d="M14 70 v12 M24 70 v12 M34 70 v12 M44 70 v12 M54 70 v12 M64 70 v12"/>
|
||||
</g>
|
||||
<rect x="10" y="16" width="62" height="56" rx="10" fill="#7d8798" stroke="#3a414d" stroke-width="2.5"/>
|
||||
<g clip-path="url(#walzmeister-hazard)">
|
||||
<rect x="12" y="20" width="10" height="48" fill="#f2c14e"/>
|
||||
<path d="M8 24 l16 -8 v6 l-16 8z M8 36 l16 -8 v6 l-16 8z M8 48 l16 -8 v6 l-16 8z M8 60 l16 -8 v6 l-16 8z M8 72 l16 -8 v6 l-16 8z" fill="#1b1f27"/>
|
||||
</g>
|
||||
<rect x="50" y="34" width="37" height="7" rx="2" fill="#2a2f38"/>
|
||||
<rect x="50" y="47" width="37" height="7" rx="2" fill="#2a2f38"/>
|
||||
<circle cx="46" cy="44" r="17" fill="#9aa4b5" stroke="#3a414d" stroke-width="2.5"/>
|
||||
<circle cx="46" cy="44" r="10" fill="none" stroke="#535c6b" stroke-width="2"/>
|
||||
<circle cx="55" cy="44" r="4.5" fill="#ff3b30" stroke="#7a0a12" stroke-width="1.5"/>
|
||||
<circle cx="56.5" cy="42.5" r="1.4" fill="#ffd0c0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Glory Defense – Seiten- und Overlay-Styles.
|
||||
* Das Spiel selbst zeichnet Phaser ins Canvas; hier liegen nur Seitenrahmen,
|
||||
* der Disclaimer und die HTML-Overlays (Dialoge, Namenseingabe).
|
||||
*/
|
||||
|
||||
:root {
|
||||
--gd-navy: #0d1321;
|
||||
--gd-navy-2: #1b2438;
|
||||
--gd-crimson: #c1121f;
|
||||
--gd-gold: #f2c14e;
|
||||
--gd-cream: #f4ecd8;
|
||||
--gd-muted: #a9b1c4;
|
||||
/* Nur Systemschriften: nichts wird mitgeliefert, keine Lizenzfragen. */
|
||||
--gd-font: Impact, Haettenschweiler, "Arial Narrow Bold", "Arial Black", sans-serif;
|
||||
--gd-font-body: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--gd-navy);
|
||||
color: var(--gd-cream);
|
||||
font-family: var(--gd-font-body);
|
||||
}
|
||||
|
||||
a { color: var(--gd-gold); }
|
||||
|
||||
/* ------------------------------------------------------------------ Spielseite */
|
||||
|
||||
.game-page { overflow: hidden; }
|
||||
|
||||
#game-shell {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
#game {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Canvas nicht auswählbar/verschiebbar, sonst stört das beim schnellen Klicken. */
|
||||
#game canvas {
|
||||
display: block;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.boot-message {
|
||||
position: absolute;
|
||||
inset: auto 0 45% 0;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-family: var(--gd-font);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--gd-gold);
|
||||
}
|
||||
|
||||
.boot-message--error { color: #ff8a8a; }
|
||||
|
||||
/* ------------------------------------------------------------------ Overlays */
|
||||
|
||||
#overlay-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(8, 11, 20, 0.72);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.overlay__panel {
|
||||
width: min(720px, 100%);
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 28px 32px;
|
||||
background: var(--gd-navy-2);
|
||||
border: 3px solid var(--gd-gold);
|
||||
box-shadow: 0 0 0 6px var(--gd-crimson), 0 18px 48px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.overlay__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Disclaimer */
|
||||
|
||||
.disclaimer__title {
|
||||
margin: 0 0 12px;
|
||||
font-family: var(--gd-font);
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gd-gold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.disclaimer__lead { font-size: 1.15rem; }
|
||||
|
||||
.disclaimer__list {
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.disclaimer__list li + li { margin-top: 8px; }
|
||||
|
||||
/* ------------------------------------------------------------------ Formulare */
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 10px 22px;
|
||||
border: 2px solid var(--gd-gold);
|
||||
background: var(--gd-crimson);
|
||||
color: var(--gd-cream);
|
||||
font: 700 1rem var(--gd-font-body);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:hover, .button:focus-visible {
|
||||
background: var(--gd-gold);
|
||||
color: var(--gd-navy);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.button--secondary { background: transparent; }
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.field__label {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--gd-gold);
|
||||
}
|
||||
|
||||
.field__input {
|
||||
padding: 10px 12px;
|
||||
border: 2px solid var(--gd-muted);
|
||||
background: var(--gd-navy);
|
||||
color: var(--gd-cream);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.field__input:focus { border-color: var(--gd-gold); outline: none; }
|
||||
|
||||
.field__hint { color: var(--gd-muted); font-size: 0.9rem; }
|
||||
.field__error { color: #ff8a8a; min-height: 1.2em; }
|
||||
|
||||
/* ------------------------------------------------------------------ Game Over */
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
|
||||
.stats__row {
|
||||
padding: 10px 12px;
|
||||
background: var(--gd-navy);
|
||||
border-left: 4px solid var(--gd-crimson);
|
||||
}
|
||||
|
||||
.stats dt { color: var(--gd-muted); font-size: 0.85rem; }
|
||||
.stats dd { margin: 4px 0 0; font: 600 1.35rem var(--gd-font); color: var(--gd-gold); }
|
||||
|
||||
.highscore-list {
|
||||
margin: 16px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.highscore-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 3em 1fr auto 4em;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid rgba(169, 177, 196, 0.2);
|
||||
}
|
||||
|
||||
.highscore-list__score, .highscore-list__wave { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.highscore-list__own { background: rgba(242, 193, 78, 0.18); color: var(--gd-gold); font-weight: 600; }
|
||||
|
||||
/* ------------------------------------------------------------------ Eigenständige Seiten */
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page__panel {
|
||||
width: min(760px, 100%);
|
||||
padding: 32px;
|
||||
background: var(--gd-navy-2);
|
||||
border: 3px solid var(--gd-gold);
|
||||
box-shadow: 0 0 0 6px var(--gd-crimson);
|
||||
}
|
||||
|
||||
.page__actions { margin-top: 28px; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.overlay__panel, .page__panel { padding: 20px 18px; }
|
||||
.disclaimer__title { font-size: 1.25rem; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- Eigenes Emblem der fiktiven „Vereinigten Erde“: Planet mit Umlaufbahn. -->
|
||||
<circle cx="32" cy="32" r="30" fill="#0d1321" stroke="#f2c14e" stroke-width="3"/>
|
||||
<circle cx="32" cy="32" r="13" fill="#c1121f"/>
|
||||
<ellipse cx="32" cy="32" rx="25" ry="8" fill="none" stroke="#f2c14e" stroke-width="3" transform="rotate(-25 32 32)"/>
|
||||
<circle cx="52" cy="22" r="3.5" fill="#f4ecd8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Zentrale Spielkonfiguration: alle Balancing-Werte an einem Ort.
|
||||
*
|
||||
* Keine Magic Numbers im restlichen Code – wer das Spiel ausbalancieren will,
|
||||
* ändert nur diese Datei. Einheiten: Pixel, Pixel pro Sekunde, Millisekunden.
|
||||
* Die Werte sind in docs/GAME_DESIGN.md erläutert.
|
||||
*
|
||||
* Achtung: Die Punkte-Obergrenze im Backend (app/config.py, GAME_RULE_DEFAULTS)
|
||||
* ist auf diese Werte abgestimmt. Wer Punkte stark erhöht, muss sie dort anpassen.
|
||||
*/
|
||||
|
||||
/** Darstellung und Spielwelt. */
|
||||
export const GAME = Object.freeze({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
backgroundColor: '#11151f',
|
||||
/** Die Arena ist größer als der Bildschirm; die Kamera folgt dem Präsidenten. */
|
||||
world: Object.freeze({ width: 2400, height: 1600 }),
|
||||
/** Gegner erscheinen so weit außerhalb des sichtbaren Randes der Arena. */
|
||||
spawnMargin: 40,
|
||||
cameraLerp: 0.12,
|
||||
/** Tiefe (Zeichenreihenfolge) der Ebenen. */
|
||||
depth: Object.freeze({
|
||||
ground: 0,
|
||||
markers: 5,
|
||||
pickups: 8,
|
||||
enemies: 10,
|
||||
player: 20,
|
||||
projectiles: 25,
|
||||
effects: 30,
|
||||
hud: 100,
|
||||
}),
|
||||
});
|
||||
|
||||
/** Präsident Maximilian Valor. */
|
||||
export const PLAYER = Object.freeze({
|
||||
maxHp: 100,
|
||||
speed: 240,
|
||||
radius: 18,
|
||||
/** Kurze Unverwundbarkeit nach einem Treffer, damit ein Schwarm nicht in einem Frame tötet. */
|
||||
hitInvulnerabilityMs: 500,
|
||||
/** Heilung nach jeder überstandenen Welle – belohnt Fortschritt ohne Nachschub. */
|
||||
waveClearHeal: 15,
|
||||
dodge: Object.freeze({
|
||||
speed: 640,
|
||||
durationMs: 320,
|
||||
cooldownMs: 1500,
|
||||
}),
|
||||
/** Waffen in Slot-Reihenfolge (Tasten 1–3). */
|
||||
weaponSlots: Object.freeze(['carbine', 'shotgun', 'pistol']),
|
||||
});
|
||||
|
||||
/**
|
||||
* Waffen. `reserveMax: null` bedeutet unbegrenzte Reserve (Dienstpistole als
|
||||
* Rückfalloption, damit der Spieler ohne Munition nie wehrlos ist).
|
||||
*/
|
||||
export const WEAPONS = Object.freeze({
|
||||
carbine: Object.freeze({
|
||||
id: 'carbine',
|
||||
name: 'Karabiner »Zuversicht«',
|
||||
shortName: 'Karabiner',
|
||||
magazineSize: 30,
|
||||
reserveStart: 180,
|
||||
reserveMax: 270,
|
||||
fireIntervalMs: 110,
|
||||
reloadMs: 1500,
|
||||
damage: 14,
|
||||
pellets: 1,
|
||||
spreadDeg: 3,
|
||||
bulletSpeed: 900,
|
||||
rangePx: 820,
|
||||
automatic: true,
|
||||
}),
|
||||
shotgun: Object.freeze({
|
||||
id: 'shotgun',
|
||||
name: 'Schrotflinte »Überzeugungskraft«',
|
||||
shortName: 'Schrotflinte',
|
||||
magazineSize: 6,
|
||||
reserveStart: 24,
|
||||
reserveMax: 48,
|
||||
fireIntervalMs: 650,
|
||||
reloadMs: 1900,
|
||||
damage: 11,
|
||||
pellets: 8,
|
||||
spreadDeg: 24,
|
||||
bulletSpeed: 820,
|
||||
rangePx: 420,
|
||||
automatic: false,
|
||||
}),
|
||||
pistol: Object.freeze({
|
||||
id: 'pistol',
|
||||
name: 'Dienstpistole »Anstand«',
|
||||
shortName: 'Pistole',
|
||||
magazineSize: 12,
|
||||
reserveStart: null,
|
||||
reserveMax: null,
|
||||
fireIntervalMs: 260,
|
||||
reloadMs: 1100,
|
||||
damage: 16,
|
||||
pellets: 1,
|
||||
spreadDeg: 2,
|
||||
bulletSpeed: 860,
|
||||
rangePx: 700,
|
||||
automatic: false,
|
||||
}),
|
||||
});
|
||||
|
||||
/** Faktionen (für Farben, Texte und Wellenzusammensetzung). */
|
||||
export const FACTIONS = Object.freeze({
|
||||
swarm: Object.freeze({ id: 'swarm', name: 'Die Schwärmer' }),
|
||||
legion: Object.freeze({ id: 'legion', name: 'Die Blechlegion' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Gegnertypen. `cost` ist der Preis im Wellenbudget, `behavior` wählt die KI.
|
||||
* Verhalten:
|
||||
* - swarm: umzingeln (jeder Krabbler hat einen eigenen Anflugwinkel), nah dran direkt angreifen
|
||||
* - leaper: verfolgen, dann mit Vorwarnung einen Sprungangriff ausführen
|
||||
* - brood: langsam verfolgen und dabei Krabbler ausbrüten
|
||||
* - kite: Abstand halten, seitlich ausweichen, aus der Distanz schießen
|
||||
* - flank: seitlich versetzt zum Spieler positionieren (Flanke), Salven schießen
|
||||
* - siege: großer Abstand, Fächersalven und Mörser mit Bodenmarkierung
|
||||
*/
|
||||
export const ENEMY_TYPES = Object.freeze({
|
||||
krabbler: Object.freeze({
|
||||
id: 'krabbler',
|
||||
name: 'Krabbler',
|
||||
faction: 'swarm',
|
||||
tier: 'small',
|
||||
behavior: 'swarm',
|
||||
hp: 20,
|
||||
speed: 175,
|
||||
radius: 12,
|
||||
contactDamage: 6,
|
||||
contactCooldownMs: 700,
|
||||
points: 10,
|
||||
cost: 1,
|
||||
groupSize: Object.freeze({ min: 4, max: 8 }),
|
||||
/** Abstand, ab dem der Krabbler den Umzingelungspunkt verlässt und direkt angreift. */
|
||||
engageRange: 170,
|
||||
encircleRadius: 130,
|
||||
}),
|
||||
sprungkralle: Object.freeze({
|
||||
id: 'sprungkralle',
|
||||
name: 'Sprungkralle',
|
||||
faction: 'swarm',
|
||||
tier: 'medium',
|
||||
behavior: 'leaper',
|
||||
hp: 60,
|
||||
speed: 125,
|
||||
radius: 17,
|
||||
contactDamage: 14,
|
||||
contactCooldownMs: 800,
|
||||
points: 30,
|
||||
cost: 3,
|
||||
groupSize: Object.freeze({ min: 1, max: 3 }),
|
||||
leap: Object.freeze({
|
||||
triggerRange: 260,
|
||||
windupMs: 450,
|
||||
speed: 560,
|
||||
durationMs: 380,
|
||||
cooldownMs: 2400,
|
||||
}),
|
||||
}),
|
||||
brutmutter: Object.freeze({
|
||||
id: 'brutmutter',
|
||||
name: 'Brutmutter',
|
||||
faction: 'swarm',
|
||||
tier: 'elite',
|
||||
behavior: 'brood',
|
||||
hp: 450,
|
||||
speed: 58,
|
||||
radius: 34,
|
||||
contactDamage: 28,
|
||||
contactCooldownMs: 1000,
|
||||
points: 250,
|
||||
cost: 15,
|
||||
groupSize: Object.freeze({ min: 1, max: 1 }),
|
||||
brood: Object.freeze({
|
||||
intervalMs: 5000,
|
||||
spawnCount: 3,
|
||||
/** Obergrenze lebender Nachkommen, damit eine ignorierte Brutmutter nicht eskaliert. */
|
||||
maxChildren: 6,
|
||||
childType: 'krabbler',
|
||||
}),
|
||||
}),
|
||||
nieter: Object.freeze({
|
||||
id: 'nieter',
|
||||
name: 'Nieter-Drohne',
|
||||
faction: 'legion',
|
||||
tier: 'small',
|
||||
behavior: 'kite',
|
||||
hp: 30,
|
||||
speed: 95,
|
||||
radius: 13,
|
||||
contactDamage: 5,
|
||||
contactCooldownMs: 800,
|
||||
points: 20,
|
||||
cost: 2,
|
||||
groupSize: Object.freeze({ min: 2, max: 3 }),
|
||||
range: Object.freeze({ min: 220, max: 340 }),
|
||||
attack: Object.freeze({
|
||||
cooldownMs: 1500,
|
||||
burst: 1,
|
||||
burstIntervalMs: 0,
|
||||
count: 1,
|
||||
spreadDeg: 0,
|
||||
damage: 7,
|
||||
speed: 330,
|
||||
/** Nur feuern, wenn der Spieler näher als das ist (etwas mehr als range.max). */
|
||||
maxDistance: 480,
|
||||
}),
|
||||
}),
|
||||
blechsoldat: Object.freeze({
|
||||
id: 'blechsoldat',
|
||||
name: 'Blechsoldat',
|
||||
faction: 'legion',
|
||||
tier: 'medium',
|
||||
behavior: 'flank',
|
||||
hp: 90,
|
||||
speed: 78,
|
||||
radius: 18,
|
||||
contactDamage: 10,
|
||||
contactCooldownMs: 900,
|
||||
points: 50,
|
||||
cost: 4,
|
||||
groupSize: Object.freeze({ min: 1, max: 3 }),
|
||||
range: Object.freeze({ min: 200, max: 380 }),
|
||||
/** Winkelversatz der Flankenposition relativ zur Linie Spieler→Gegner. */
|
||||
flankAngleDeg: 70,
|
||||
flankDistance: 290,
|
||||
attack: Object.freeze({
|
||||
cooldownMs: 2300,
|
||||
burst: 3,
|
||||
burstIntervalMs: 140,
|
||||
count: 1,
|
||||
spreadDeg: 4,
|
||||
damage: 8,
|
||||
speed: 400,
|
||||
maxDistance: 520,
|
||||
}),
|
||||
}),
|
||||
walzmeister: Object.freeze({
|
||||
id: 'walzmeister',
|
||||
name: 'Walzmeister',
|
||||
faction: 'legion',
|
||||
tier: 'elite',
|
||||
behavior: 'siege',
|
||||
hp: 700,
|
||||
speed: 44,
|
||||
radius: 36,
|
||||
contactDamage: 25,
|
||||
contactCooldownMs: 1000,
|
||||
points: 400,
|
||||
cost: 18,
|
||||
groupSize: Object.freeze({ min: 1, max: 1 }),
|
||||
range: Object.freeze({ min: 260, max: 460 }),
|
||||
attack: Object.freeze({
|
||||
cooldownMs: 2700,
|
||||
burst: 1,
|
||||
burstIntervalMs: 0,
|
||||
count: 7,
|
||||
spreadDeg: 50,
|
||||
damage: 10,
|
||||
speed: 290,
|
||||
maxDistance: 640,
|
||||
}),
|
||||
mortar: Object.freeze({
|
||||
cooldownMs: 5200,
|
||||
delayMs: 1400,
|
||||
radius: 90,
|
||||
damage: 26,
|
||||
maxDistance: 700,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Allgemeine Steuerungswerte der Gegner-KI. */
|
||||
export const AI = Object.freeze({
|
||||
/** Abstand, unter dem sich Gegner gegenseitig wegschieben (verhindert Klumpen). */
|
||||
separationRadius: 34,
|
||||
separationWeight: 1.4,
|
||||
/** Richtungswechsel beim seitlichen Ausweichen (Strafen) in diesem Zeitfenster. */
|
||||
strafeSwitchMs: Object.freeze({ min: 1400, max: 3200 }),
|
||||
/** Wie stark die Bewegung pro Frame zur Wunschrichtung nachgeführt wird (0..1). */
|
||||
steeringResponsiveness: 0.18,
|
||||
});
|
||||
|
||||
/** Wellen-Generator (siehe logic/waves.js). */
|
||||
export const WAVES = Object.freeze({
|
||||
/** Budget(n) = firstBudget + growth * (n - 1) ^ exponent */
|
||||
firstBudget: 8,
|
||||
budgetGrowth: 5,
|
||||
budgetExponent: 1.15,
|
||||
/** Wellen 1–2 nur Schwärmer, 3–4 nur Blechlegion, danach gemischt. */
|
||||
legionFromWave: 3,
|
||||
mixedFromWave: 5,
|
||||
/** Jede n-te Welle bringt Elite-Gegner. */
|
||||
eliteEvery: 5,
|
||||
maxElitesPerWave: 4,
|
||||
/** Anteil des Restbudgets, der in mittlere Gegner fließt – steigt mit der Welle. */
|
||||
mediumShareBase: 0.1,
|
||||
mediumSharePerWave: 0.04,
|
||||
mediumShareMax: 0.5,
|
||||
/** Ab dieser Welle werden Gegner zäher und schneller. */
|
||||
scalingFromWave: 6,
|
||||
hpScalePerWave: 0.07,
|
||||
speedScalePerWave: 0.012,
|
||||
speedScaleMax: 1.3,
|
||||
/** Zeitlicher Abstand zwischen zwei Spawn-Gruppen. */
|
||||
groupDelayMs: Object.freeze({ min: 1300, max: 3000 }),
|
||||
/** Spätere Wellen spawnen schneller, aber nie schneller als dieser Faktor. */
|
||||
groupDelayMinFactor: 0.45,
|
||||
groupDelayFactorPerWave: 0.035,
|
||||
/** Solange so viele Gegner leben, wartet der Spawner. */
|
||||
maxAliveEnemies: 60,
|
||||
intermissionMs: 8000,
|
||||
firstWaveDelayMs: 1500,
|
||||
});
|
||||
|
||||
/** Punkte und Serien-Multiplikator (siehe logic/scoring.js). */
|
||||
export const SCORING = Object.freeze({
|
||||
/** Abschüsse innerhalb dieses Fensters setzen die Serie fort. */
|
||||
streakWindowMs: 3000,
|
||||
/** Alle `stepKills` Abschüsse steigt der Multiplikator um `stepValue`. */
|
||||
stepKills: 5,
|
||||
stepValue: 0.5,
|
||||
maxMultiplier: 4,
|
||||
/** Treffer am Präsidenten beenden die Serie. */
|
||||
damageBreaksStreak: true,
|
||||
waveBonusPerWave: 100,
|
||||
});
|
||||
|
||||
/**
|
||||
* Orbital-Befehle: Taste `holdKey` gedrückt halten, dann die Richtungsfolge mit
|
||||
* Pfeiltasten oder WASD eingeben. Ziel ist die Mausposition (auf maxTargetRange begrenzt).
|
||||
* Keine Folge darf Anfang einer anderen sein (wird in den Tests geprüft). Die Codes sind
|
||||
* eigene Erfindungen; jeder beginnt mit einer anderen Richtung, damit man sich nach
|
||||
* dem ersten Tastendruck nicht mehr vertippen kann.
|
||||
*/
|
||||
export const ORBITAL = Object.freeze({
|
||||
holdKey: 'Q',
|
||||
maxTargetRange: 560,
|
||||
commands: Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'strike',
|
||||
name: 'Orbitalschlag »Morgenröte«',
|
||||
shortName: 'Orbitalschlag',
|
||||
sequence: Object.freeze(['up', 'right', 'down', 'down']),
|
||||
cooldownMs: 30000,
|
||||
delayMs: 2500,
|
||||
radius: 150,
|
||||
damage: 260,
|
||||
/** Auch der Präsident steht nicht über der Physik. */
|
||||
playerDamage: 40,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'supply',
|
||||
name: 'Nachschubkapsel »Fürsorgepaket«',
|
||||
shortName: 'Nachschub',
|
||||
sequence: Object.freeze(['left', 'left', 'down', 'right']),
|
||||
cooldownMs: 45000,
|
||||
delayMs: 3000,
|
||||
radius: 70,
|
||||
damage: 120,
|
||||
playerDamage: 20,
|
||||
heal: 50,
|
||||
pickupRadius: 46,
|
||||
lifetimeMs: 30000,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'turret',
|
||||
name: 'Geschütz »Wachsamer Nachbar«',
|
||||
shortName: 'Geschütz',
|
||||
sequence: Object.freeze(['right', 'left', 'right', 'up', 'up']),
|
||||
cooldownMs: 60000,
|
||||
delayMs: 3000,
|
||||
radius: 60,
|
||||
damage: 80,
|
||||
playerDamage: 15,
|
||||
durationMs: 25000,
|
||||
fireIntervalMs: 150,
|
||||
range: 480,
|
||||
bulletDamage: 12,
|
||||
bulletSpeed: 950,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
/** Darstellung von Kombinationspfeilen im HUD. */
|
||||
export const DIRECTION_SYMBOLS = Object.freeze({ up: '↑', down: '↓', left: '←', right: '→' });
|
||||
|
||||
/** Farben (Platzhaltergrafiken, HUD, Effekte). */
|
||||
export const COLORS = Object.freeze({
|
||||
navy: 0x0d1321,
|
||||
crimson: 0xc1121f,
|
||||
gold: 0xf2c14e,
|
||||
cream: 0xf4ecd8,
|
||||
muted: 0xa9b1c4,
|
||||
ground: 0x2a2f24,
|
||||
groundAlt: 0x31372a,
|
||||
player: 0xf4ecd8,
|
||||
playerCape: 0xc1121f,
|
||||
swarm: 0x8fbf3a,
|
||||
swarmDark: 0x4f6d1e,
|
||||
legion: 0x9aa4b5,
|
||||
legionDark: 0x535c6b,
|
||||
playerBullet: 0xffe28a,
|
||||
enemyBullet: 0xff5a3c,
|
||||
turretBullet: 0x9be7ff,
|
||||
strike: 0xff7b00,
|
||||
supply: 0x4fd1c5,
|
||||
turret: 0x9be7ff,
|
||||
mortar: 0xff3d3d,
|
||||
hpBar: 0x5ad469,
|
||||
hpBarLow: 0xe0453a,
|
||||
});
|
||||
|
||||
/** Standardlautstärken (0..1) und Ton-Parameter. */
|
||||
export const AUDIO = Object.freeze({
|
||||
defaultVolumes: Object.freeze({ master: 0.8, music: 0.5, sfx: 0.8 }),
|
||||
/** Mindestabstand zwischen gleichen Effekten, damit Dauerfeuer nicht übersteuert. */
|
||||
sfxMinIntervalMs: 45,
|
||||
});
|
||||
|
||||
/** Schlüssel für localStorage (versioniert, damit Formatänderungen alte Daten nicht brechen). */
|
||||
export const STORAGE_KEYS = Object.freeze({
|
||||
disclaimerAccepted: 'gloryDefense.disclaimerAccepted.v1',
|
||||
volumes: 'gloryDefense.volumes.v1',
|
||||
lastName: 'gloryDefense.lastName.v1',
|
||||
});
|
||||
|
||||
/** Grenzen, die mit dem Backend übereinstimmen müssen (app/validation.py). */
|
||||
export const HIGHSCORE = Object.freeze({
|
||||
nameMaxLength: 16,
|
||||
requestTimeoutMs: 8000,
|
||||
});
|
||||
|
||||
/** Effekte und Rückmeldung. */
|
||||
export const FX = Object.freeze({
|
||||
screenShake: Object.freeze({
|
||||
hit: Object.freeze({ durationMs: 120, intensity: 0.004 }),
|
||||
explosion: Object.freeze({ durationMs: 350, intensity: 0.012 }),
|
||||
}),
|
||||
hitFlashMs: 80,
|
||||
damageNumberMs: 600,
|
||||
announcementMs: 3800,
|
||||
deathDelayMs: 1600,
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Alle Spieltexte an einem Ort (Deutsch).
|
||||
*
|
||||
* Tonfall: überzeichnete Propaganda einer frei erfundenen Regierung. Der Spott gilt
|
||||
* Militarismus und Propaganda im Allgemeinen – keine realen Personen, Parteien,
|
||||
* Länder oder Zitate. Namen und Begriffe sind bewusst eigenständig und stammen
|
||||
* aus keinem anderen Spiel oder Film.
|
||||
* @module content/texts
|
||||
*/
|
||||
|
||||
export const TITLE = 'GLORY DEFENSE';
|
||||
export const TAGLINE = 'Für Freiheit und Frieden!';
|
||||
export const PRESIDENT = 'Präsident Maximilian Valor';
|
||||
|
||||
export const MENU = Object.freeze({
|
||||
start: 'Mission starten',
|
||||
highscores: 'Ehrentafel',
|
||||
controls: 'Steuerung',
|
||||
disclaimer: 'Disclaimer',
|
||||
credits: 'Credits',
|
||||
footer: 'Satire · Reine Fiktion · Alle Figuren sind frei erfunden',
|
||||
hint: '↑ ↓ oder Maus zum Wählen · Enter zum Bestätigen',
|
||||
touchNotice: 'Hinweis: Glory Defense wird mit Tastatur und Maus gespielt.',
|
||||
});
|
||||
|
||||
export const DISCLAIMER = Object.freeze({
|
||||
accept: 'Verstanden – zum Hauptmenü',
|
||||
close: 'Schließen',
|
||||
openPage: 'Als eigene Seite öffnen',
|
||||
missingTemplate:
|
||||
'Glory Defense ist Satire und reine Fiktion. Alle Figuren und Organisationen sind frei erfunden. ' +
|
||||
'Ähnlichkeiten mit realen Personen sind nicht beabsichtigt. Das Spiel vertritt keine politische Position.',
|
||||
});
|
||||
|
||||
/**
|
||||
* Missionsbriefings; eines wird zufällig gewählt.
|
||||
* @type {readonly {title: string, lines: readonly string[]}[]}
|
||||
*/
|
||||
export const BRIEFINGS = Object.freeze([
|
||||
Object.freeze({
|
||||
title: 'EINSATZBEFEHL NR. 7-G · STRENG ÖFFENTLICH',
|
||||
lines: Object.freeze([
|
||||
'Präsident Maximilian Valor, Hüter des ewigen Friedens!',
|
||||
'Außerirdische Aggressoren bedrohen die Vereinigte Erde. Der Hohe Rat hat einstimmig beschlossen, dass Sie den Frieden persönlich verteidigen – mit allen Mitteln, die der Frieden erfordert.',
|
||||
'Feindaufklärung: Die Schwärmer sind schnell, zahlreich und haben schlechte Manieren. Die Blechlegion ist langsam, gründlich und schießt aus der Ferne.',
|
||||
'Ziel: Halten Sie so viele Angriffswellen wie möglich stand.',
|
||||
'Das Ministerium für Zuversicht versichert: Die Lage ist unter Kontrolle. Sie war nie außer Kontrolle. Es gibt keine Lage.',
|
||||
]),
|
||||
}),
|
||||
Object.freeze({
|
||||
title: 'LAGEBESPRECHUNG · SIEG BEREITS BESCHLOSSEN',
|
||||
lines: Object.freeze([
|
||||
'Herr Präsident, die Feinde des Friedens sind gelandet.',
|
||||
'Die Orbitalflotte steht bereit. Sie trifft stets präzise – wo sie einschlägt, war per Definition das Ziel.',
|
||||
'Halten Sie Q gedrückt und geben Sie die Befehlscodes ein. Der Rest ist Geschichte. Genauer: Geschichtsschreibung.',
|
||||
'Ziel: Überleben. Nebenziel: Überleben mit Stil.',
|
||||
'Für Freiheit und Frieden! Und für die Einschaltquote.',
|
||||
]),
|
||||
}),
|
||||
Object.freeze({
|
||||
title: 'TAGESBEFEHL · NICHT ZUR DISKUSSION',
|
||||
lines: Object.freeze([
|
||||
'Bürgerinnen und Bürger schauen zu. Der Präsident kämpft an vorderster Front, denn die Front ist, wo der Präsident ist.',
|
||||
'Die Schwärmer greifen in Rudeln an und umzingeln. Die Blechlegion hält Abstand und flankiert. Beide lehnen den Frieden ab. Wir bieten ihn trotzdem an – mit Nachdruck.',
|
||||
'Ziel: Jede Welle abwehren. Munition knapp? Fürsorgepaket anfordern.',
|
||||
'Das Ministerium für Zuversicht erinnert: Zweifel sind ab sofort meldepflichtig.',
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const BRIEFING_CONTINUE = 'Leertaste oder Klick: Einsatz beginnen · Esc: zurück';
|
||||
|
||||
/** Propaganda-Durchsagen zwischen den Wellen. */
|
||||
export const PROPAGANDA = Object.freeze([
|
||||
'Bürgerinnen und Bürger! Jede abgewehrte Welle bringt uns dem ewigen Frieden näher. Die nächste auch.',
|
||||
'Das Ministerium für Zuversicht meldet: Verluste sind statistisch nicht existent.',
|
||||
'Frieden ist die kürzeste Pause zwischen zwei Siegen.',
|
||||
'Erinnerung: Zweifel sind ab sofort meldepflichtig.',
|
||||
'Der Präsident kämpft persönlich. Die Front ist, wo der Präsident ist.',
|
||||
'Unsere Orbitalflotte trifft stets präzise. Wo sie trifft, war immer das Ziel.',
|
||||
'Heute ist ein historischer Tag. Wie jeder Tag seit Beginn der Durchsagen.',
|
||||
'Die Rationen werden um 20 % erhöht. Zuvor wurden sie um 40 % gesenkt. Ein Gewinn für alle!',
|
||||
'Zuversicht ist Bürgerpflicht. Wer nicht zuversichtlich ist, wird es bald sein.',
|
||||
'Wer Frieden will, muss ihn verteidigen. Wer ihn verteidigt, muss kämpfen. Wer kämpft, will Frieden. Logisch.',
|
||||
'Das Wetter: heiter bis wolkig, mit vereinzelten Orbitalschlägen.',
|
||||
'Die Schwärmer sind zahlreich. Wir sind zahlreicher – in unseren Herzen.',
|
||||
'Kritik an der Kriegsführung schwächt die Moral. Moral ist kriegswichtig. Bitte weiterklatschen.',
|
||||
'Fürsorgepakete sind ein Geschenk des Präsidenten. Bitte nicht darunter stehen.',
|
||||
'Neue Umfrage: 100 % der Befragten sind zufrieden. Befragt wurde das Ministerium.',
|
||||
'Der Feind hat keine Chance. Das wurde gestern beschlossen.',
|
||||
]);
|
||||
|
||||
export const WAVE = Object.freeze({
|
||||
incoming: (n) => `WELLE ${n}`,
|
||||
preparing: 'VORBEREITUNG',
|
||||
factionLine: Object.freeze({
|
||||
swarm: 'Die Schwärmer greifen an!',
|
||||
legion: 'Die Blechlegion rückt vor!',
|
||||
mixed: 'Schwärmer und Blechlegion – gemeinsam gegen den Frieden!',
|
||||
}),
|
||||
cleared: (n) => `Welle ${n} abgewehrt!`,
|
||||
bonus: (points) => `Wellenbonus +${points}`,
|
||||
heal: (hp) => `Das Ministerium für Gesundheit spendiert ${hp} Lebenspunkte.`,
|
||||
nextIn: (seconds) => `Nächste Welle in ${seconds}`,
|
||||
remaining: (count) => `Feinde: ${count}`,
|
||||
elite: Object.freeze({
|
||||
brutmutter: 'ACHTUNG: Eine Brutmutter wurde gesichtet!',
|
||||
walzmeister: 'ACHTUNG: Ein Walzmeister rollt heran!',
|
||||
}),
|
||||
});
|
||||
|
||||
export const ORBITAL_TEXT = Object.freeze({
|
||||
holdHint: 'Q halten + Pfeiltasten/WASD',
|
||||
panelTitle: 'ORBITAL-BEFEHLE · Q HALTEN',
|
||||
requested: (name) => `${name} angefordert – Einschlag steht bevor!`,
|
||||
notReady: (name, seconds) => `${name} lädt noch (${seconds} s)`,
|
||||
invalid: 'Befehlscode ungültig',
|
||||
selfHit: 'Kollateralschaden am Präsidenten – im Bericht als Heldentum vermerkt.',
|
||||
supplyPickup: (heal) => `Fürsorgepaket erhalten: +${heal} Leben, Munition aufgefüllt.`,
|
||||
turretOnline: 'Geschütz »Wachsamer Nachbar« ist einsatzbereit.',
|
||||
});
|
||||
|
||||
export const PLAYER_TEXT = Object.freeze({
|
||||
reloading: 'Nachladen …',
|
||||
empty: 'Leer',
|
||||
lowAmmo: (code) => `Munition knapp! Nachschub anfordern: Q + ${code}`,
|
||||
outOfAmmo: 'Keine Munition – Dienstpistole gezogen.',
|
||||
dodgeReady: 'Rolle',
|
||||
});
|
||||
|
||||
export const GAME_OVER = Object.freeze({
|
||||
title: 'DER PRÄSIDENT IST GEFALLEN',
|
||||
subtitles: Object.freeze([
|
||||
'Das Ministerium für Zuversicht meldet: planmäßiger Rückzug in die Unsterblichkeit.',
|
||||
'Ein Denkmal wurde bereits in Auftrag gegeben. Es war schon vorher in Auftrag gegeben.',
|
||||
'Offizielle Sprachregelung: Der Präsident ruht sich kurz aus. Sehr gründlich.',
|
||||
'Die Nachrichten melden einen triumphalen Sieg. Details folgen nie.',
|
||||
]),
|
||||
stats: Object.freeze({
|
||||
score: 'Punkte',
|
||||
wave: 'Erreichte Welle',
|
||||
kills: 'Abschüsse',
|
||||
bestStreak: 'Längste Serie',
|
||||
duration: 'Einsatzdauer',
|
||||
}),
|
||||
nameLabel: 'Name für die Ehrentafel',
|
||||
nameHint: 'Höchstens 16 Zeichen. Wird öffentlich angezeigt.',
|
||||
submit: 'Eintragen',
|
||||
skip: 'Nicht eintragen',
|
||||
retry: 'Erneut verteidigen',
|
||||
menu: 'Hauptmenü',
|
||||
submitting: 'Übermittle an das Archiv …',
|
||||
saved: (rank) => `Eingetragen auf Platz ${rank}. Die Geschichtsbücher sind informiert.`,
|
||||
offline: 'Das Archiv ist nicht erreichbar. Der Punktestand wird mündlich überliefert.',
|
||||
noToken: 'Diese Runde konnte nicht beim Archiv angemeldet werden – ein Eintrag ist leider nicht möglich.',
|
||||
});
|
||||
|
||||
export const HIGHSCORES = Object.freeze({
|
||||
title: 'EHRENTAFEL DER VERTEIDIGER',
|
||||
loading: 'Lade Ehrentafel …',
|
||||
empty: 'Noch keine Einträge. Die Geschichte wartet auf Sie.',
|
||||
error: 'Die Ehrentafel ist gerade nicht erreichbar.',
|
||||
columns: Object.freeze({ rank: 'Rang', name: 'Name', score: 'Punkte', wave: 'Welle' }),
|
||||
back: 'Zurück (Esc)',
|
||||
});
|
||||
|
||||
export const PAUSE = Object.freeze({
|
||||
title: 'EINSATZ PAUSIERT',
|
||||
subtitle: 'Der Feind wartet höflich.',
|
||||
resume: 'Weiter (Esc)',
|
||||
quit: 'Mission abbrechen',
|
||||
volumes: Object.freeze({ master: 'Gesamtlautstärke', music: 'Musik', sfx: 'Effekte' }),
|
||||
confirmQuit: 'Mission wirklich abbrechen? Der Punktestand verfällt.',
|
||||
});
|
||||
|
||||
export const CONTROLS = Object.freeze({
|
||||
title: 'STEUERUNG',
|
||||
rows: Object.freeze([
|
||||
['W A S D', 'Bewegen (8 Richtungen)'],
|
||||
['Maus', 'Zielen'],
|
||||
['Linksklick', 'Schießen (gedrückt halten beim Karabiner)'],
|
||||
['R', 'Nachladen'],
|
||||
['1 · 2 · 3 / Mausrad', 'Waffe wechseln'],
|
||||
['Leertaste', 'Ausweichrolle (kurz unverwundbar)'],
|
||||
['Q halten + Pfeile/WASD', 'Orbital-Befehl eingeben (Ziel: Mauszeiger)'],
|
||||
['Esc', 'Pause und Lautstärke'],
|
||||
]),
|
||||
orbitalTitle: 'ORBITAL-BEFEHLE',
|
||||
back: 'Zurück (Esc)',
|
||||
});
|
||||
|
||||
export const CREDITS = Object.freeze({
|
||||
title: 'CREDITS',
|
||||
sections: Object.freeze([
|
||||
Object.freeze({
|
||||
heading: 'Spiel',
|
||||
lines: Object.freeze(['Glory Defense – ein satirischer 2D-Shooter', 'Idee, Code und Figuren: frei erfunden für dieses Projekt']),
|
||||
}),
|
||||
Object.freeze({
|
||||
heading: 'Technik',
|
||||
lines: Object.freeze(['Phaser 3 – © Phaser Studio Inc., MIT-Lizenz', 'Flask – © Pallets, BSD-3-Clause-Lizenz']),
|
||||
}),
|
||||
Object.freeze({
|
||||
heading: 'Grafik und Ton',
|
||||
lines: Object.freeze([
|
||||
'Figuren, Emblem, Geschütz und Kapsel: eigene Vektorgrafiken (CC0)',
|
||||
'Bodenkacheln aus „Top-down Shooter“ und Effekte aus „Particle Pack“ – Kenney (kenney.nl), CC0',
|
||||
'Schriften: Systemschriften des Betriebssystems (nicht mitgeliefert)',
|
||||
'Klänge aus „Sci-Fi Sounds“, „Impact Sounds“, „Interface Sounds“, „Music Jingles“ – Kenney (kenney.nl), CC0',
|
||||
'Marschmusik: eigene Komposition, im Browser synthetisch erzeugt (CC0)',
|
||||
]),
|
||||
}),
|
||||
Object.freeze({
|
||||
heading: 'Hinweis',
|
||||
lines: Object.freeze([
|
||||
'Satire und reine Fiktion. Alle Figuren und Organisationen sind frei erfunden.',
|
||||
'Keine Verbindung zu realen Personen, Parteien, Ländern oder anderen Spielen.',
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
back: 'Zurück (Esc)',
|
||||
});
|
||||
|
||||
export const ERRORS = Object.freeze({
|
||||
phaserMissing: 'Die Spiel-Engine konnte nicht geladen werden. Bitte Seite neu laden.',
|
||||
generic: 'Ein unerwarteter Fehler ist aufgetreten. Bitte Seite neu laden.',
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Abwurfobjekte der Orbital-Befehle: Verteidigungsgeschütz und Nachschubkapsel.
|
||||
* @module entities/Deployables
|
||||
*/
|
||||
|
||||
import { GAME } from '../config.js';
|
||||
import { angleTo, distance } from '../logic/math.js';
|
||||
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
|
||||
|
||||
/** Letzte Sekunden, in denen ein auslaufendes Objekt blinkt. */
|
||||
const EXPIRY_WARNING_MS = 3000;
|
||||
|
||||
/**
|
||||
* Geschütz »Wachsamer Nachbar«: zielt automatisch auf den nächsten Gegner in Reichweite.
|
||||
*/
|
||||
export class Turret extends Phaser.GameObjects.Sprite {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {{durationMs: number, fireIntervalMs: number, range: number, bulletDamage: number, bulletSpeed: number}} cfg
|
||||
* @param {number} now
|
||||
*/
|
||||
constructor(scene, x, y, cfg, now) {
|
||||
super(scene, x, y, TEXTURE_KEYS.turret);
|
||||
scene.add.existing(this);
|
||||
this.setDepth(GAME.depth.pickups);
|
||||
this.cfg = cfg;
|
||||
this.expiresAt = now + cfg.durationMs;
|
||||
this.nextShotAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @param {Iterable<import('./Enemy.js').Enemy>} enemies
|
||||
* @param {(x: number, y: number, angle: number) => void} fire
|
||||
* @returns {boolean} false, wenn die Einsatzzeit abgelaufen ist.
|
||||
*/
|
||||
step(now, enemies, fire) {
|
||||
if (now >= this.expiresAt) return false;
|
||||
let target = null;
|
||||
let best = this.cfg.range;
|
||||
for (const enemy of enemies) {
|
||||
if (!enemy.alive) continue;
|
||||
const d = distance(this, enemy);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
target = enemy;
|
||||
}
|
||||
}
|
||||
if (target) {
|
||||
const angle = angleTo(this, target);
|
||||
this.setRotation(angle);
|
||||
if (now >= this.nextShotAt) {
|
||||
this.nextShotAt = now + this.cfg.fireIntervalMs;
|
||||
fire(this.x + Math.cos(angle) * 24, this.y + Math.sin(angle) * 24, angle);
|
||||
}
|
||||
}
|
||||
// Kurz vor Ablauf blinken, damit der Spieler nicht überrascht wird.
|
||||
const left = this.expiresAt - now;
|
||||
this.setAlpha(left < EXPIRY_WARNING_MS && Math.floor(now / 150) % 2 === 0 ? 0.4 : 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachschubkapsel »Fürsorgepaket«: Beim Berühren gibt es Leben und Munition.
|
||||
*/
|
||||
export class SupplyPod extends Phaser.Physics.Arcade.Sprite {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {{lifetimeMs: number, pickupRadius: number, heal: number}} cfg
|
||||
* @param {number} now
|
||||
*/
|
||||
constructor(scene, x, y, cfg, now) {
|
||||
super(scene, x, y, TEXTURE_KEYS.supplyPod);
|
||||
scene.add.existing(this);
|
||||
scene.physics.add.existing(this);
|
||||
this.setDepth(GAME.depth.pickups);
|
||||
this.cfg = cfg;
|
||||
this.expiresAt = now + cfg.lifetimeMs;
|
||||
this.collected = false;
|
||||
}
|
||||
|
||||
/** Nach dem Einfügen in die Gruppe: runden Aufnahmebereich setzen. */
|
||||
initBody() {
|
||||
const r = this.cfg.pickupRadius;
|
||||
this.body.setCircle(r, this.width / 2 - r, this.height / 2 - r);
|
||||
// Liegt fest am Boden: kein Wegschieben durch Physik.
|
||||
this.body.setImmovable(true);
|
||||
this.body.moves = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @returns {boolean} false, wenn die Kapsel abgelaufen ist.
|
||||
*/
|
||||
tick(now) {
|
||||
const left = this.expiresAt - now;
|
||||
if (left <= 0) return false;
|
||||
this.setAlpha(left < EXPIRY_WARNING_MS && Math.floor(now / 150) % 2 === 0 ? 0.4 : 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Gegner beider Fraktionen. Das Verhalten kommt aus `config.ENEMY_TYPES[type].behavior`,
|
||||
* die Entscheidungen trifft die Phaser-freie Logik in `logic/enemyAi.js`. Diese
|
||||
* Klasse setzt sie nur in Physik, Drehung und Grafik um.
|
||||
* @module entities/Enemy
|
||||
*/
|
||||
|
||||
import { AI, ENEMY_TYPES, GAME, PLAYER } from '../config.js';
|
||||
import {
|
||||
attackStep,
|
||||
broodStep,
|
||||
createBrain,
|
||||
flankVelocity,
|
||||
kiteVelocity,
|
||||
leaperStep,
|
||||
swarmVelocity,
|
||||
updateStrafe,
|
||||
volleyAngles,
|
||||
} from '../logic/enemyAi.js';
|
||||
import { angleTo, degToRad, distance, scale } from '../logic/math.js';
|
||||
import { combine, seek, separation, steerTowards } from '../logic/steering.js';
|
||||
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
|
||||
|
||||
/** Frisch gespawnte Gegner schießen nicht sofort (Fairness). */
|
||||
const FIRST_ATTACK_DELAY_MS = 1100;
|
||||
/** Unter dieser Geschwindigkeit bleibt die Blickrichtung stehen (verhindert Zittern). */
|
||||
const MIN_TURN_SPEED = 12;
|
||||
/** Farbe der Vorwarnung vor dem Sprungangriff. */
|
||||
const TELEGRAPH_TINT = 0xff5040;
|
||||
/** Verhalten, die im Nahkampf angreifen. */
|
||||
const MELEE_BEHAVIORS = new Set(['swarm', 'leaper', 'brood']);
|
||||
/** Stärke der Leerlauf-Animationen (Anteil der Größe). */
|
||||
const ANIMATION = Object.freeze({ periodMs: 90, swarmWobble: 0.09, broodPulse: 0.04, hoverBob: 0.04 });
|
||||
|
||||
/**
|
||||
* @typedef {Object} EnemyContext
|
||||
* @property {{x: number, y: number}} player
|
||||
* @property {Phaser.GameObjects.GameObject[]} neighbors
|
||||
* @property {() => number} rng
|
||||
* @property {(x: number, y: number, angle: number, speed: number, damage: number) => void} fireBullet
|
||||
* @property {(enemy: Enemy, count: number) => void} spawnChildren
|
||||
* @property {(x: number, y: number, mortar: object) => void} scheduleMortar
|
||||
*/
|
||||
|
||||
let nextEnemyId = 1;
|
||||
|
||||
export class Enemy extends Phaser.Physics.Arcade.Sprite {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {string} typeId Schlüssel aus ENEMY_TYPES.
|
||||
* @param {{now: number, rng: () => number, hpMultiplier?: number, speedMultiplier?: number, parent?: Enemy|null}} options
|
||||
*/
|
||||
constructor(scene, x, y, typeId, options) {
|
||||
super(scene, x, y, TEXTURE_KEYS.enemy(typeId));
|
||||
this.id = nextEnemyId;
|
||||
nextEnemyId += 1;
|
||||
this.type = ENEMY_TYPES[typeId];
|
||||
/** Wellen-Multiplikatoren; ausgebrütete Krabbler erben sie von der Brutmutter. */
|
||||
this.multipliers = { hp: options.hpMultiplier ?? 1, speed: options.speedMultiplier ?? 1 };
|
||||
this.maxHp = Math.round(this.type.hp * this.multipliers.hp);
|
||||
this.hp = this.maxHp;
|
||||
this.speed = this.type.speed * this.multipliers.speed;
|
||||
this.brain = createBrain(options.rng, options.now, { firstAttackDelayMs: FIRST_ATTACK_DELAY_MS });
|
||||
this.alive = true;
|
||||
this.lastContactAt = -Infinity;
|
||||
this.leaping = false;
|
||||
this.telegraphing = false;
|
||||
/** @type {Enemy|null} Brutmutter, von der dieser Krabbler stammt. */
|
||||
this.parentEnemy = options.parent ?? null;
|
||||
this.childCount = 0;
|
||||
|
||||
scene.add.existing(this);
|
||||
scene.physics.add.existing(this);
|
||||
this.setDepth(GAME.depth.enemies);
|
||||
}
|
||||
|
||||
/** Physik-Körper einrichten – erst nach dem Einfügen in die Physik-Gruppe aufrufen. */
|
||||
initBody() {
|
||||
const r = this.type.radius;
|
||||
this.body.setCircle(r, this.width / 2 - r, this.height / 2 - r);
|
||||
this.setCollideWorldBounds(true);
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
get isElite() {
|
||||
return this.type.tier === 'elite';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein KI-Schritt: Wunschgeschwindigkeit bestimmen, Angriffe auslösen.
|
||||
* @param {number} now Spielzeit in ms.
|
||||
* @param {EnemyContext} ctx
|
||||
*/
|
||||
step(now, ctx) {
|
||||
if (!this.alive) return;
|
||||
const { player } = ctx;
|
||||
const type = this.type;
|
||||
let desired;
|
||||
let responsiveness = AI.steeringResponsiveness;
|
||||
let faceTarget = false;
|
||||
|
||||
switch (type.behavior) {
|
||||
case 'swarm':
|
||||
desired = swarmVelocity(this.brain, this, player, this.speed, type);
|
||||
break;
|
||||
case 'leaper': {
|
||||
const result = leaperStep(this.brain, this, player, now, this.speed, type.leap);
|
||||
desired = result.velocity;
|
||||
this.leaping = result.leaping;
|
||||
if (result.leaping || result.telegraph) responsiveness = 1;
|
||||
this.setTelegraph(result.telegraph);
|
||||
break;
|
||||
}
|
||||
case 'brood': {
|
||||
desired = seek(this, player, this.speed);
|
||||
const hatch = broodStep(this.brain, now, this.childCount, type.brood);
|
||||
if (hatch > 0) ctx.spawnChildren(this, hatch);
|
||||
break;
|
||||
}
|
||||
case 'kite':
|
||||
updateStrafe(this.brain, now, AI.strafeSwitchMs, ctx.rng);
|
||||
desired = kiteVelocity(this.brain, this, player, this.speed, type.range);
|
||||
faceTarget = true;
|
||||
this.tryAttack(now, ctx);
|
||||
break;
|
||||
case 'flank':
|
||||
desired = flankVelocity(this.brain, this, player, this.speed, type);
|
||||
faceTarget = true;
|
||||
this.tryAttack(now, ctx);
|
||||
break;
|
||||
case 'siege':
|
||||
updateStrafe(this.brain, now, AI.strafeSwitchMs, ctx.rng);
|
||||
desired = kiteVelocity(this.brain, this, player, this.speed, type.range);
|
||||
faceTarget = true;
|
||||
this.tryAttack(now, ctx);
|
||||
this.tryMortar(now, ctx);
|
||||
break;
|
||||
default:
|
||||
desired = seek(this, player, this.speed);
|
||||
}
|
||||
|
||||
// Nahkämpfer bleiben am Präsidenten „kleben“, statt sich auf seinen Mittelpunkt zu
|
||||
// drängen – sonst stapeln sie sich unlesbar übereinander.
|
||||
if (MELEE_BEHAVIORS.has(type.behavior) && !this.leaping) {
|
||||
if (distance(this, player) < PLAYER.radius + type.radius) desired = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
// Abstand zu Nachbarn – außer im Sprung, der soll geradlinig bleiben.
|
||||
if (!this.leaping) {
|
||||
const push = separation(this, ctx.neighbors, AI.separationRadius + type.radius);
|
||||
desired = combine(
|
||||
[
|
||||
{ vector: desired, weight: 1 },
|
||||
{ vector: scale(push, this.speed), weight: AI.separationWeight },
|
||||
],
|
||||
this.speed,
|
||||
);
|
||||
}
|
||||
|
||||
const velocity = steerTowards(this.body.velocity, desired, responsiveness);
|
||||
this.setVelocity(velocity.x, velocity.y);
|
||||
|
||||
if (faceTarget) {
|
||||
this.setRotation(angleTo(this, player));
|
||||
} else if (Math.hypot(velocity.x, velocity.y) > MIN_TURN_SPEED) {
|
||||
this.setRotation(Math.atan2(velocity.y, velocity.x));
|
||||
}
|
||||
this.animate(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kleine Lebenszeichen ohne Spritesheet: Insekten wackeln beim Laufen, die Brutmutter
|
||||
* pulsiert, Drohnen schweben. Die ID verschiebt die Phase, damit nicht alle im Takt wippen.
|
||||
* @param {number} now
|
||||
*/
|
||||
animate(now) {
|
||||
if (this.telegraphing) return;
|
||||
const phase = now / ANIMATION.periodMs + this.id;
|
||||
if (this.type.behavior === 'brood') {
|
||||
this.setScale(1 + ANIMATION.broodPulse * Math.sin(phase * 0.35));
|
||||
} else if (this.type.faction === 'swarm') {
|
||||
const moving = Math.hypot(this.body.velocity.x, this.body.velocity.y) > MIN_TURN_SPEED;
|
||||
this.setScale(1, moving ? 1 + ANIMATION.swarmWobble * Math.sin(phase * 2.2) : 1);
|
||||
} else if (this.type.behavior === 'kite') {
|
||||
this.setScale(1 + ANIMATION.hoverBob * Math.sin(phase));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schießt gemäß Takt des Gegnertyps.
|
||||
* @param {number} now
|
||||
* @param {EnemyContext} ctx
|
||||
*/
|
||||
tryAttack(now, ctx) {
|
||||
const attack = this.type.attack;
|
||||
if (!attackStep(this.brain.attack, now, distance(this, ctx.player), attack)) return;
|
||||
let base = angleTo(this, ctx.player);
|
||||
// Einzelschüsse streuen leicht, Fächersalven sind bereits breit.
|
||||
if (attack.count === 1 && attack.spreadDeg > 0) {
|
||||
base += (ctx.rng() - 0.5) * degToRad(attack.spreadDeg);
|
||||
}
|
||||
const reach = this.type.radius + 6;
|
||||
for (const angle of volleyAngles(base, attack.count, attack.spreadDeg)) {
|
||||
ctx.fireBullet(this.x + Math.cos(angle) * reach, this.y + Math.sin(angle) * reach, angle, attack.speed, attack.damage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walzmeister: Mörser auf die aktuelle Spielerposition (mit Bodenmarkierung).
|
||||
* @param {number} now
|
||||
* @param {EnemyContext} ctx
|
||||
*/
|
||||
tryMortar(now, ctx) {
|
||||
const mortar = this.type.mortar;
|
||||
if (!mortar) return;
|
||||
if (attackStep(this.brain.mortar, now, distance(this, ctx.player), mortar)) {
|
||||
ctx.scheduleMortar(ctx.player.x, ctx.player.y, mortar);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {boolean} active */
|
||||
setTelegraph(active) {
|
||||
if (active === this.telegraphing) return;
|
||||
this.telegraphing = active;
|
||||
if (active) {
|
||||
this.setTint(TELEGRAPH_TINT);
|
||||
this.setScale(1.15);
|
||||
} else {
|
||||
this.clearTint();
|
||||
this.setScale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} amount
|
||||
* @returns {boolean} true, wenn der Gegner dadurch stirbt.
|
||||
*/
|
||||
takeDamage(amount) {
|
||||
if (!this.alive) return false;
|
||||
this.hp -= amount;
|
||||
if (this.hp <= 0) {
|
||||
this.hp = 0;
|
||||
this.alive = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Präsident Maximilian Valor – die Spielfigur.
|
||||
*
|
||||
* Die Figur ist frei erfunden: Raumrüstung mit geschlossenem Helm (kein Gesicht,
|
||||
* keine Frisur) und Umhang in den Farben der fiktiven Vereinigten Erde.
|
||||
* @module entities/Player
|
||||
*/
|
||||
|
||||
import { GAME, PLAYER, WEAPONS } from '../config.js';
|
||||
import { fromAngle, scale } from '../logic/math.js';
|
||||
import { isNearlyZero } from '../logic/steering.js';
|
||||
import { WeaponState } from '../logic/weapon.js';
|
||||
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
|
||||
|
||||
export class Player extends Phaser.Physics.Arcade.Sprite {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
constructor(scene, x, y) {
|
||||
super(scene, x, y, TEXTURE_KEYS.player);
|
||||
scene.add.existing(this);
|
||||
scene.physics.add.existing(this);
|
||||
this.setDepth(GAME.depth.player);
|
||||
this.setCollideWorldBounds(true);
|
||||
const r = PLAYER.radius;
|
||||
this.body.setCircle(r, this.width / 2 - r, this.height / 2 - r);
|
||||
|
||||
this.maxHp = PLAYER.maxHp;
|
||||
this.hp = PLAYER.maxHp;
|
||||
this.alive = true;
|
||||
this.weapons = PLAYER.weaponSlots.map((id) => new WeaponState(WEAPONS[id]));
|
||||
this.weaponIndex = 0;
|
||||
this.aimAngle = 0;
|
||||
this.dodgeUntil = 0;
|
||||
this.dodgeReadyAt = 0;
|
||||
this.dodgeDirection = { x: 1, y: 0 };
|
||||
this.hitInvulnerableUntil = 0;
|
||||
}
|
||||
|
||||
/** @returns {WeaponState} */
|
||||
get weapon() {
|
||||
return this.weapons[this.weaponIndex];
|
||||
}
|
||||
|
||||
/** @param {number} now @returns {boolean} */
|
||||
isDodging(now) {
|
||||
return now < this.dodgeUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* Während der Rolle und kurz nach einem Treffer nimmt der Präsident keinen Schaden.
|
||||
* @param {number} now
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isInvulnerable(now) {
|
||||
return !this.alive || this.isDodging(now) || now < this.hitInvulnerableUntil;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @returns {number} Bereitschaft der Rolle, 0..1 (1 = bereit).
|
||||
*/
|
||||
dodgeReadiness(now) {
|
||||
if (now >= this.dodgeReadyAt) return 1;
|
||||
return 1 - (this.dodgeReadyAt - now) / PLAYER.dodge.cooldownMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet eine Ausweichrolle in Bewegungsrichtung (ohne Bewegung: in Blickrichtung).
|
||||
* @param {number} now
|
||||
* @param {{x: number, y: number}} moveDirection Normalisiert.
|
||||
* @returns {boolean} true, wenn die Rolle ausgelöst wurde.
|
||||
*/
|
||||
tryDodge(now, moveDirection) {
|
||||
if (!this.alive || now < this.dodgeReadyAt) return false;
|
||||
this.dodgeDirection = isNearlyZero(moveDirection) ? fromAngle(this.aimAngle) : moveDirection;
|
||||
this.dodgeUntil = now + PLAYER.dodge.durationMs;
|
||||
this.dodgeReadyAt = now + PLAYER.dodge.cooldownMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewegung für diesen Frame.
|
||||
* @param {number} now
|
||||
* @param {{x: number, y: number}} moveDirection Normalisiert.
|
||||
* @param {boolean} blocked true, solange ein Orbital-Befehl eingegeben wird.
|
||||
*/
|
||||
move(now, moveDirection, blocked) {
|
||||
if (!this.alive) {
|
||||
this.setVelocity(0, 0);
|
||||
return;
|
||||
}
|
||||
if (this.isDodging(now)) {
|
||||
const v = scale(this.dodgeDirection, PLAYER.dodge.speed);
|
||||
this.setVelocity(v.x, v.y);
|
||||
this.setAlpha(0.55);
|
||||
return;
|
||||
}
|
||||
this.setAlpha(1);
|
||||
const v = blocked ? { x: 0, y: 0 } : scale(moveDirection, PLAYER.speed);
|
||||
this.setVelocity(v.x, v.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Richtet die Figur auf einen Punkt in Weltkoordinaten aus.
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
*/
|
||||
aimAt(x, y) {
|
||||
this.aimAngle = Math.atan2(y - this.y, x - this.x);
|
||||
this.setRotation(this.aimAngle);
|
||||
}
|
||||
|
||||
/** @returns {{x: number, y: number}} Position der Waffenmündung. */
|
||||
muzzlePosition() {
|
||||
const reach = PLAYER.radius + 8;
|
||||
return {
|
||||
x: this.x + Math.cos(this.aimAngle) * reach,
|
||||
y: this.y + Math.sin(this.aimAngle) * reach,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @returns {boolean} true, wenn gewechselt wurde.
|
||||
*/
|
||||
switchWeapon(index) {
|
||||
if (index === this.weaponIndex || !this.weapons[index]) return false;
|
||||
// Nachladen bricht beim Wechsel ab – sonst könnte man durch Wechseln „heimlich“ laden.
|
||||
this.weapon.cancelReload();
|
||||
this.weaponIndex = index;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {1|-1} step
|
||||
* @returns {boolean}
|
||||
*/
|
||||
cycleWeapon(step) {
|
||||
const count = this.weapons.length;
|
||||
return this.switchWeapon((this.weaponIndex + step + count) % count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt Schaden zu, sofern der Präsident nicht gerade unverwundbar ist.
|
||||
* @param {number} amount
|
||||
* @param {number} now
|
||||
* @returns {number} Tatsächlich erlittener Schaden.
|
||||
*/
|
||||
takeDamage(amount, now) {
|
||||
if (this.isInvulnerable(now) || amount <= 0) return 0;
|
||||
const dealt = Math.min(this.hp, amount);
|
||||
this.hp -= dealt;
|
||||
this.hitInvulnerableUntil = now + PLAYER.hitInvulnerabilityMs;
|
||||
if (this.hp <= 0) {
|
||||
this.alive = false;
|
||||
this.setVelocity(0, 0);
|
||||
}
|
||||
return dealt;
|
||||
}
|
||||
|
||||
/** @param {number} amount @returns {number} Tatsächlich geheilte Punkte. */
|
||||
heal(amount) {
|
||||
const before = this.hp;
|
||||
this.hp = Math.min(this.maxHp, this.hp + amount);
|
||||
return this.hp - before;
|
||||
}
|
||||
|
||||
/** Füllt die Reserve aller Waffen auf (Nachschubkapsel). */
|
||||
refillAmmo() {
|
||||
for (const weapon of this.weapons) weapon.refill();
|
||||
}
|
||||
|
||||
/** @param {number} now @returns {boolean} true, wenn die aktive Waffe fertig nachgeladen hat. */
|
||||
updateWeapons(now) {
|
||||
return this.weapon.update(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Wiederverwendbare Projektile (Object Pooling).
|
||||
*
|
||||
* Bei Dauerfeuer entstehen Hunderte Projektile pro Minute. Statt sie jedes Mal neu
|
||||
* zu erzeugen und zu zerstören (Garbage-Collector-Ruckler), werden deaktivierte
|
||||
* Projektile wiederverwendet.
|
||||
* @module entities/ProjectilePool
|
||||
*/
|
||||
|
||||
export class ProjectilePool {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {string} textureKey
|
||||
* @param {number} maxSize Obergrenze gleichzeitig fliegender Projektile.
|
||||
* @param {number} depth
|
||||
*/
|
||||
constructor(scene, textureKey, maxSize, depth) {
|
||||
this.scene = scene;
|
||||
this.textureKey = textureKey;
|
||||
this.depth = depth;
|
||||
this.group = scene.physics.add.group({
|
||||
classType: Phaser.Physics.Arcade.Image,
|
||||
maxSize,
|
||||
allowGravity: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Feuert ein Projektil ab.
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} angle Bogenmaß.
|
||||
* @param {number} speed px/s
|
||||
* @param {number} damage
|
||||
* @param {number} range Maximale Flugstrecke in px.
|
||||
* @param {string} [textureKey] Abweichende Textur (z. B. Geschütz).
|
||||
* @returns {Phaser.Physics.Arcade.Image|null} null, wenn der Pool erschöpft ist.
|
||||
*/
|
||||
fire(x, y, angle, speed, damage, range, textureKey) {
|
||||
const projectile = this.group.get(x, y, textureKey ?? this.textureKey);
|
||||
if (!projectile) return null;
|
||||
projectile.enableBody(true, x, y, true, true);
|
||||
projectile.setTexture(textureKey ?? this.textureKey);
|
||||
projectile.setDepth(this.depth);
|
||||
projectile.setRotation(angle);
|
||||
this.scene.physics.velocityFromRotation(angle, speed, projectile.body.velocity);
|
||||
projectile.damage = damage;
|
||||
projectile.spawnX = x;
|
||||
projectile.spawnY = y;
|
||||
projectile.maxTravelSq = range * range;
|
||||
return projectile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deaktiviert Projektile, die ihre Reichweite überschritten oder die Welt verlassen haben.
|
||||
* @param {Phaser.Geom.Rectangle} bounds
|
||||
*/
|
||||
update(bounds) {
|
||||
for (const projectile of this.group.getChildren()) {
|
||||
if (!projectile.active) continue;
|
||||
const dx = projectile.x - projectile.spawnX;
|
||||
const dy = projectile.y - projectile.spawnY;
|
||||
if (dx * dx + dy * dy > projectile.maxTravelSq || !bounds.contains(projectile.x, projectile.y)) {
|
||||
this.kill(projectile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Phaser.Physics.Arcade.Image} projectile */
|
||||
kill(projectile) {
|
||||
projectile.disableBody(true, true);
|
||||
}
|
||||
|
||||
/** @returns {number} Aktive Projektile. */
|
||||
get activeCount() {
|
||||
return this.group.countActive(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2024 Richard Davey, Phaser Studio Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Eingabe der Orbital-Befehle als Richtungsfolge (ohne Phaser).
|
||||
*
|
||||
* Solange die Halte-Taste gedrückt ist, sammelt `ComboInput` Richtungen. Nach jeder
|
||||
* Eingabe wird geprüft, ob der Puffer noch Anfang mindestens eines Befehls ist:
|
||||
* - passt nichts mehr → „fail“, Puffer wird geleert (man kann direkt neu ansetzen)
|
||||
* - Folge vollständig → „match“ mit Befehls-ID
|
||||
* - sonst → „progress“
|
||||
* Ob ein Befehl gerade abklingt, entscheidet der Aufrufer – hier geht es nur um Folgen.
|
||||
* @module logic/comboInput
|
||||
*/
|
||||
|
||||
/** @typedef {'up'|'down'|'left'|'right'} Direction */
|
||||
/** @typedef {{id: string, sequence: readonly Direction[]}} ComboCommand */
|
||||
|
||||
/** @type {readonly Direction[]} */
|
||||
export const DIRECTIONS = Object.freeze(['up', 'down', 'left', 'right']);
|
||||
|
||||
/**
|
||||
* Physische Tastencodes (KeyboardEvent.code) → Richtung. `code` statt `key`, damit
|
||||
* WASD auch auf AZERTY- oder QWERTZ-Tastaturen an derselben Stelle liegt.
|
||||
*/
|
||||
const CODE_TO_DIRECTION = Object.freeze({
|
||||
ArrowUp: 'up',
|
||||
KeyW: 'up',
|
||||
ArrowDown: 'down',
|
||||
KeyS: 'down',
|
||||
ArrowLeft: 'left',
|
||||
KeyA: 'left',
|
||||
ArrowRight: 'right',
|
||||
KeyD: 'right',
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} code KeyboardEvent.code
|
||||
* @returns {Direction|null}
|
||||
*/
|
||||
export function codeToDirection(code) {
|
||||
return CODE_TO_DIRECTION[code] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {readonly string[]} prefix
|
||||
* @param {readonly string[]} sequence
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPrefix(prefix, sequence) {
|
||||
if (prefix.length > sequence.length) return false;
|
||||
return prefix.every((dir, i) => sequence[i] === dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Befehlsliste: gültige Richtungen, eindeutige IDs und keine Folge, die
|
||||
* Anfang einer anderen ist (sonst wäre die längere Folge nie erreichbar).
|
||||
* @param {readonly ComboCommand[]} commands
|
||||
* @throws {Error} bei ungültiger Konfiguration.
|
||||
*/
|
||||
export function validateCommands(commands) {
|
||||
const ids = new Set();
|
||||
for (const command of commands) {
|
||||
if (ids.has(command.id)) throw new Error(`Doppelte Befehls-ID: ${command.id}`);
|
||||
ids.add(command.id);
|
||||
if (command.sequence.length === 0) throw new Error(`Leere Folge bei ${command.id}`);
|
||||
for (const dir of command.sequence) {
|
||||
if (!DIRECTIONS.includes(dir)) throw new Error(`Ungültige Richtung „${dir}“ bei ${command.id}`);
|
||||
}
|
||||
}
|
||||
for (const a of commands) {
|
||||
for (const b of commands) {
|
||||
if (a !== b && isPrefix(a.sequence, b.sequence)) {
|
||||
throw new Error(`Folge von ${a.id} ist Anfang der Folge von ${b.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ComboInput {
|
||||
/** @param {readonly ComboCommand[]} commands */
|
||||
constructor(commands) {
|
||||
validateCommands(commands);
|
||||
this.commands = commands;
|
||||
/** @type {Direction[]} */
|
||||
this.buffer = [];
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
/** Halte-Taste gedrückt: Eingabe beginnt mit leerem Puffer. */
|
||||
begin() {
|
||||
this.active = true;
|
||||
this.buffer = [];
|
||||
}
|
||||
|
||||
/** Halte-Taste losgelassen: halbe Eingaben verfallen. */
|
||||
end() {
|
||||
this.active = false;
|
||||
this.buffer = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine Richtung.
|
||||
* @param {string} direction
|
||||
* @returns {{status: 'inactive'|'ignored'|'fail'|'progress'|'match', commandId?: string, candidates?: string[]}}
|
||||
*/
|
||||
push(direction) {
|
||||
if (!this.active) return { status: 'inactive' };
|
||||
if (!DIRECTIONS.includes(/** @type {Direction} */ (direction))) return { status: 'ignored' };
|
||||
|
||||
this.buffer.push(/** @type {Direction} */ (direction));
|
||||
const candidates = this.commands.filter((c) => isPrefix(this.buffer, c.sequence));
|
||||
if (candidates.length === 0) {
|
||||
this.buffer = [];
|
||||
return { status: 'fail' };
|
||||
}
|
||||
const exact = candidates.find((c) => c.sequence.length === this.buffer.length);
|
||||
if (exact) {
|
||||
this.buffer = [];
|
||||
return { status: 'match', commandId: exact.id };
|
||||
}
|
||||
return { status: 'progress', candidates: candidates.map((c) => c.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Schritte eines Befehls bereits korrekt eingegeben sind (für die HUD-Anzeige).
|
||||
* @param {string} commandId
|
||||
* @returns {number}
|
||||
*/
|
||||
progressFor(commandId) {
|
||||
const command = this.commands.find((c) => c.id === commandId);
|
||||
if (!command || !isPrefix(this.buffer, command.sequence)) return 0;
|
||||
return this.buffer.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Abklingzeiten für Fähigkeiten (Ausweichrolle, Orbital-Befehle) ohne Phaser.
|
||||
* Arbeitet mit der Spielzeit des GameScene, die während der Pause stillsteht –
|
||||
* Abklingzeiten laufen in der Pause also nicht heimlich weiter.
|
||||
* @module logic/cooldowns
|
||||
*/
|
||||
|
||||
import { clamp } from './math.js';
|
||||
|
||||
export class CooldownTracker {
|
||||
constructor() {
|
||||
/** @type {Map<string, number>} */
|
||||
this.readyAt = new Map();
|
||||
/** @type {Map<string, number>} */
|
||||
this.durations = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet die Abklingzeit von `id`.
|
||||
* @param {string} id
|
||||
* @param {number} durationMs
|
||||
* @param {number} now Spielzeit in ms.
|
||||
*/
|
||||
trigger(id, durationMs, now) {
|
||||
this.readyAt.set(id, now + durationMs);
|
||||
this.durations.set(id, durationMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {number} now
|
||||
* @returns {boolean} true, wenn die Fähigkeit benutzt werden kann (auch wenn nie ausgelöst).
|
||||
*/
|
||||
isReady(id, now) {
|
||||
return (this.readyAt.get(id) ?? -Infinity) <= now;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {number} now
|
||||
* @returns {number} Restzeit in ms (0 wenn bereit).
|
||||
*/
|
||||
remaining(id, now) {
|
||||
return Math.max(0, (this.readyAt.get(id) ?? now) - now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fortschritt für Ladebalken im HUD.
|
||||
* @param {string} id
|
||||
* @param {number} now
|
||||
* @returns {number} 0 = gerade ausgelöst, 1 = bereit.
|
||||
*/
|
||||
progress(id, now) {
|
||||
const duration = this.durations.get(id);
|
||||
if (!duration) return 1;
|
||||
return clamp(1 - this.remaining(id, now) / duration, 0, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt eine oder alle Abklingzeiten zurück.
|
||||
* @param {string} [id]
|
||||
*/
|
||||
reset(id) {
|
||||
if (id === undefined) {
|
||||
this.readyAt.clear();
|
||||
this.durations.clear();
|
||||
} else {
|
||||
this.readyAt.delete(id);
|
||||
this.durations.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Entscheidungslogik der Gegner (ohne Phaser): Bewegung pro Verhalten,
|
||||
* Sprungangriff, Schuss-Salven, Brut. Die Entities rufen diese Funktionen jeden
|
||||
* Frame auf und setzen das Ergebnis in Physik und Grafik um.
|
||||
*
|
||||
* Zustände liegen in kleinen, veränderlichen „Brain“-Objekten pro Gegner. Das ist
|
||||
* bewusst kein unveränderlicher Stil: Bei 60 Gegnern und 60 FPS sollen pro Frame
|
||||
* keine Objektberge für den Garbage Collector entstehen.
|
||||
* @module logic/enemyAi
|
||||
*/
|
||||
|
||||
import { degToRad, distance, normalize, scale, sub } from './math.js';
|
||||
import { encirclePoint, flankPoint, keepDistance, seek } from './steering.js';
|
||||
|
||||
/** @typedef {import('./math.js').Vec2} Vec2 */
|
||||
|
||||
/**
|
||||
* @typedef {Object} Brain
|
||||
* @property {number} slotAngle Anflugwinkel im Schwarm (Bogenmaß).
|
||||
* @property {1|-1} strafeDirection Richtung beim seitlichen Ausweichen.
|
||||
* @property {number} nextStrafeSwitchAt
|
||||
* @property {1|-1} flankSide Seite, von der flankiert wird.
|
||||
* @property {'chase'|'windup'|'leap'} leapState
|
||||
* @property {number} leapStateUntil
|
||||
* @property {number} nextLeapAt
|
||||
* @property {Vec2} leapDirection
|
||||
* @property {AttackState} attack
|
||||
* @property {AttackState} mortar
|
||||
* @property {number} nextBroodAt
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AttackState
|
||||
* @property {number} nextAttackAt
|
||||
* @property {number} burstRemaining
|
||||
* @property {number} nextShotAt
|
||||
*/
|
||||
|
||||
/**
|
||||
* Neuer Angriffszustand. `firstAttackAt` verzögert den ersten Schuss, damit frisch
|
||||
* gespawnte Gegner nicht sofort und alle gleichzeitig feuern.
|
||||
* @param {number} firstAttackAt
|
||||
* @returns {AttackState}
|
||||
*/
|
||||
export function createAttackState(firstAttackAt = 0) {
|
||||
return { nextAttackAt: firstAttackAt, burstRemaining: 0, nextShotAt: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Neues Brain mit zufälligen, aber reproduzierbaren Eigenheiten.
|
||||
* @param {import('./rng.js').Rng} rng
|
||||
* @param {number} now
|
||||
* @param {{firstAttackDelayMs?: number}} [options]
|
||||
* @returns {Brain}
|
||||
*/
|
||||
export function createBrain(rng, now, options = {}) {
|
||||
const firstAttackAt = now + (options.firstAttackDelayMs ?? 0) * (0.5 + rng());
|
||||
return {
|
||||
slotAngle: rng() * Math.PI * 2,
|
||||
strafeDirection: rng() < 0.5 ? 1 : -1,
|
||||
nextStrafeSwitchAt: now,
|
||||
flankSide: rng() < 0.5 ? 1 : -1,
|
||||
leapState: 'chase',
|
||||
leapStateUntil: 0,
|
||||
nextLeapAt: now,
|
||||
leapDirection: { x: 0, y: 0 },
|
||||
attack: createAttackState(firstAttackAt),
|
||||
mortar: createAttackState(firstAttackAt + (options.firstAttackDelayMs ?? 0)),
|
||||
nextBroodAt: now + (options.firstAttackDelayMs ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wechselt gelegentlich die Ausweichrichtung, damit Fernkämpfer nicht vorhersehbar kreisen.
|
||||
* @param {Brain} brain
|
||||
* @param {number} now
|
||||
* @param {{min: number, max: number}} switchMs
|
||||
* @param {import('./rng.js').Rng} rng
|
||||
*/
|
||||
export function updateStrafe(brain, now, switchMs, rng) {
|
||||
if (now >= brain.nextStrafeSwitchAt) {
|
||||
brain.strafeDirection = brain.strafeDirection === 1 ? -1 : 1;
|
||||
brain.nextStrafeSwitchAt = now + switchMs.min + rng() * (switchMs.max - switchMs.min);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schwarm: aus der Distanz den eigenen Platz im Umzingelungskreis ansteuern,
|
||||
* in Reichweite direkt angreifen. So kommt ein Rudel von mehreren Seiten.
|
||||
* @param {Brain} brain
|
||||
* @param {Vec2} self
|
||||
* @param {Vec2} player
|
||||
* @param {number} speed
|
||||
* @param {{engageRange: number, encircleRadius: number}} type
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function swarmVelocity(brain, self, player, speed, type) {
|
||||
if (distance(self, player) <= type.engageRange) {
|
||||
return seek(self, player, speed);
|
||||
}
|
||||
return seek(self, encirclePoint(player, brain.slotAngle, type.encircleRadius), speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprungkralle: verfolgen → ansetzen (stillstehen, Vorwarnung) → springen → verfolgen.
|
||||
* Die Sprungrichtung wird beim Ansetzen festgelegt: Wer die Vorwarnung sieht, kann
|
||||
* ausweichen. Das ist die „Fairness“ des Angriffs.
|
||||
* @param {Brain} brain
|
||||
* @param {Vec2} self
|
||||
* @param {Vec2} player
|
||||
* @param {number} now
|
||||
* @param {number} speed
|
||||
* @param {{triggerRange: number, windupMs: number, speed: number, durationMs: number, cooldownMs: number}} leap
|
||||
* @returns {{velocity: Vec2, telegraph: boolean, leaping: boolean}}
|
||||
*/
|
||||
export function leaperStep(brain, self, player, now, speed, leap) {
|
||||
if (brain.leapState === 'windup') {
|
||||
if (now < brain.leapStateUntil) {
|
||||
return { velocity: { x: 0, y: 0 }, telegraph: true, leaping: false };
|
||||
}
|
||||
brain.leapState = 'leap';
|
||||
brain.leapStateUntil = now + leap.durationMs;
|
||||
}
|
||||
if (brain.leapState === 'leap') {
|
||||
if (now < brain.leapStateUntil) {
|
||||
return { velocity: scale(brain.leapDirection, leap.speed), telegraph: false, leaping: true };
|
||||
}
|
||||
brain.leapState = 'chase';
|
||||
brain.nextLeapAt = now + leap.cooldownMs;
|
||||
}
|
||||
// chase
|
||||
if (distance(self, player) <= leap.triggerRange && now >= brain.nextLeapAt) {
|
||||
brain.leapState = 'windup';
|
||||
brain.leapStateUntil = now + leap.windupMs;
|
||||
brain.leapDirection = normalize(sub(player, self));
|
||||
return { velocity: { x: 0, y: 0 }, telegraph: true, leaping: false };
|
||||
}
|
||||
return { velocity: seek(self, player, speed), telegraph: false, leaping: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fernkämpfer auf Abstand (Nieter-Drohne, Walzmeister).
|
||||
* @param {Brain} brain
|
||||
* @param {Vec2} self
|
||||
* @param {Vec2} player
|
||||
* @param {number} speed
|
||||
* @param {{min: number, max: number}} range
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function kiteVelocity(brain, self, player, speed, range) {
|
||||
return keepDistance(self, player, range, speed, brain.strafeDirection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flankierer (Blechsoldat): steuert eine Position seitlich des Spielers an und
|
||||
* hält dort Abstand. Nah an der Flankenposition geht er ins Abstandhalten über.
|
||||
* @param {Brain} brain
|
||||
* @param {Vec2} self
|
||||
* @param {Vec2} player
|
||||
* @param {number} speed
|
||||
* @param {{flankAngleDeg: number, flankDistance: number, range: {min: number, max: number}}} type
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function flankVelocity(brain, self, player, speed, type) {
|
||||
const target = flankPoint(player, self, degToRad(type.flankAngleDeg) * brain.flankSide, type.flankDistance);
|
||||
// Ist die Flanke (fast) erreicht, wie ein Fernkämpfer weiterkämpfen.
|
||||
if (distance(self, target) < type.range.min * 0.5) {
|
||||
return keepDistance(self, player, type.range, speed, brain.flankSide);
|
||||
}
|
||||
return seek(self, target, speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Taktgeber für Schüsse und Salven.
|
||||
* @param {AttackState} state
|
||||
* @param {number} now
|
||||
* @param {number} distanceToPlayer
|
||||
* @param {{cooldownMs: number, burst?: number, burstIntervalMs?: number, maxDistance: number}} attack
|
||||
* @returns {boolean} true, wenn in diesem Frame geschossen werden soll.
|
||||
*/
|
||||
export function attackStep(state, now, distanceToPlayer, attack) {
|
||||
if (state.burstRemaining > 0) {
|
||||
if (now >= state.nextShotAt) {
|
||||
state.burstRemaining -= 1;
|
||||
state.nextShotAt = now + (attack.burstIntervalMs ?? 0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (now >= state.nextAttackAt && distanceToPlayer <= attack.maxDistance) {
|
||||
state.burstRemaining = (attack.burst ?? 1) - 1;
|
||||
state.nextShotAt = now + (attack.burstIntervalMs ?? 0);
|
||||
state.nextAttackAt = now + attack.cooldownMs;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Winkel aller Projektile einer Salve, gleichmäßig über `spreadDeg` verteilt.
|
||||
* @param {number} baseAngle Bogenmaß (Richtung zum Ziel).
|
||||
* @param {number} count
|
||||
* @param {number} spreadDeg Gesamtbreite des Fächers in Grad.
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function volleyAngles(baseAngle, count, spreadDeg) {
|
||||
if (count <= 1) return [baseAngle];
|
||||
const spread = degToRad(spreadDeg);
|
||||
const step = spread / (count - 1);
|
||||
return Array.from({ length: count }, (_, i) => baseAngle - spread / 2 + i * step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Brutmutter: Wie viele Krabbler jetzt schlüpfen sollen.
|
||||
* @param {Brain} brain
|
||||
* @param {number} now
|
||||
* @param {number} aliveChildren Aktuell lebende Nachkommen dieser Brutmutter.
|
||||
* @param {{intervalMs: number, spawnCount: number, maxChildren: number}} brood
|
||||
* @returns {number}
|
||||
*/
|
||||
export function broodStep(brain, now, aliveChildren, brood) {
|
||||
if (now < brain.nextBroodAt) return 0;
|
||||
brain.nextBroodAt = now + brood.intervalMs;
|
||||
return Math.max(0, Math.min(brood.spawnCount, brood.maxChildren - aliveChildren));
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Formatierung und Eingabeprüfung für Anzeige und Highscore-Formular (ohne Phaser).
|
||||
* @module logic/format
|
||||
*/
|
||||
|
||||
import { DIRECTION_SYMBOLS, HIGHSCORE } from '../config.js';
|
||||
|
||||
const scoreFormatter = new Intl.NumberFormat('de-DE');
|
||||
|
||||
/**
|
||||
* Punktestand mit Tausendertrennzeichen („12.345“).
|
||||
* @param {number} score
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatScore(score) {
|
||||
return scoreFormatter.format(Math.max(0, Math.floor(score)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dauer als „m:ss“.
|
||||
* @param {number} ms
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatDuration(ms) {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = String(totalSeconds % 60).padStart(2, '0');
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplikator mit deutschem Dezimalkomma („×1,5“).
|
||||
* @param {number} multiplier
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatMultiplier(multiplier) {
|
||||
return `×${String(Math.round(multiplier * 10) / 10).replace('.', ',')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abklingzeit in Sekunden für das HUD („12“, „0,8“).
|
||||
* @param {number} ms
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCooldown(ms) {
|
||||
if (ms <= 0) return '';
|
||||
const seconds = ms / 1000;
|
||||
return seconds >= 1 ? String(Math.ceil(seconds)) : seconds.toFixed(1).replace('.', ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Richtungsfolge als Pfeile („↑ → ↓ ↓“).
|
||||
* @param {readonly string[]} sequence
|
||||
* @param {string} [separator]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSequence(sequence, separator = ' ') {
|
||||
return sequence.map((dir) => DIRECTION_SYMBOLS[dir] ?? '?').join(separator);
|
||||
}
|
||||
|
||||
/** Wie auf dem Server (app/validation.py): alles außer Buchstaben, Ziffern und wenigen Zeichen. */
|
||||
const DISALLOWED_NAME_CHARS = /[^\p{L}\p{N}_ .\-'!?]/gu;
|
||||
/** Steuer-, Format-, Surrogat- und private Zeichen (Unicode-Kategorie C). */
|
||||
const CONTROL_CHARS = /\p{C}/gu;
|
||||
|
||||
/**
|
||||
* Bereinigt einen Namen genauso wie das Backend, damit der Spieler vor dem Absenden
|
||||
* sieht, was gespeichert wird. Der Server prüft trotzdem selbst.
|
||||
* @param {string} raw
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sanitizeName(raw) {
|
||||
return String(raw)
|
||||
.normalize('NFKC')
|
||||
.replace(CONTROL_CHARS, '')
|
||||
.replace(DISALLOWED_NAME_CHARS, '')
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft einen Namen für das Highscore-Formular.
|
||||
* @param {string} raw
|
||||
* @returns {{ok: true, name: string} | {ok: false, error: string}}
|
||||
*/
|
||||
export function validateName(raw) {
|
||||
const name = sanitizeName(raw);
|
||||
if (!name) return { ok: false, error: 'Bitte einen Namen mit Buchstaben oder Ziffern eingeben.' };
|
||||
if ([...name].length > HIGHSCORE.nameMaxLength) {
|
||||
return { ok: false, error: `Höchstens ${HIGHSCORE.nameMaxLength} Zeichen.` };
|
||||
}
|
||||
return { ok: true, name };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Notenmuster der Hintergrundmusik: ein pompöser Marsch (eigene Komposition, CC0),
|
||||
* der die Propaganda-Stimmung überzeichnet. Ohne Web-Audio-Abhängigkeit, damit sich
|
||||
* Rhythmus und Harmonie testen lassen; gespielt wird er von `systems/Music.js`.
|
||||
*
|
||||
* Raster: 16 Schritte (Sechzehntel) pro Takt, 8 Takte, Viervierteltakt.
|
||||
* @module logic/marchPattern
|
||||
*/
|
||||
|
||||
export const TEMPO_BPM = 112;
|
||||
export const STEPS_PER_BAR = 16;
|
||||
export const BARS = 8;
|
||||
export const TOTAL_STEPS = STEPS_PER_BAR * BARS;
|
||||
|
||||
/** Dauer eines Sechzehntels in Sekunden. */
|
||||
export const STEP_SECONDS = 60 / TEMPO_BPM / 4;
|
||||
|
||||
/** Akkordgrundtöne (MIDI) je Takt: I I IV V I IV V I in C-Dur. */
|
||||
export const CHORD_ROOTS = Object.freeze([48, 48, 53, 55, 48, 53, 55, 48]);
|
||||
|
||||
/** Dur-Dreiklang über dem Grundton. */
|
||||
const MAJOR_TRIAD = Object.freeze([0, 4, 7]);
|
||||
|
||||
/**
|
||||
* Fanfarenmelodie: [Takt, Schritt, MIDI-Ton, Länge in Schritten].
|
||||
* @type {readonly (readonly [number, number, number, number])[]}
|
||||
*/
|
||||
const MELODY = Object.freeze([
|
||||
[0, 0, 67, 3], [0, 3, 67, 1], [0, 4, 72, 4], [0, 8, 76, 6], [0, 14, 72, 2],
|
||||
[1, 0, 74, 4], [1, 4, 72, 4], [1, 8, 71, 4], [1, 12, 67, 4],
|
||||
[2, 0, 69, 3], [2, 3, 69, 1], [2, 4, 72, 4], [2, 8, 77, 6], [2, 14, 76, 2],
|
||||
[3, 0, 74, 4], [3, 4, 71, 4], [3, 8, 67, 8],
|
||||
[4, 0, 67, 3], [4, 3, 67, 1], [4, 4, 72, 4], [4, 8, 76, 4], [4, 12, 79, 4],
|
||||
[5, 0, 81, 6], [5, 6, 77, 2], [5, 8, 76, 4], [5, 12, 72, 4],
|
||||
[6, 0, 74, 3], [6, 3, 76, 1], [6, 4, 74, 4], [6, 8, 71, 4], [6, 12, 67, 4],
|
||||
[7, 0, 72, 8],
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {Object} MusicEvent
|
||||
* @property {'kick'|'snare'|'bass'|'stab'|'lead'} instrument
|
||||
* @property {number[]} notes MIDI-Töne (leer bei Schlagzeug).
|
||||
* @property {number} lengthSteps
|
||||
* @property {number} velocity 0..1
|
||||
*/
|
||||
|
||||
/**
|
||||
* Alle Ereignisse eines Schritts.
|
||||
* @param {number} step 0..TOTAL_STEPS-1 (größere Werte werden umgebrochen).
|
||||
* @returns {MusicEvent[]}
|
||||
*/
|
||||
export function eventsAtStep(step) {
|
||||
const s = ((step % TOTAL_STEPS) + TOTAL_STEPS) % TOTAL_STEPS;
|
||||
const bar = Math.floor(s / STEPS_PER_BAR);
|
||||
const pos = s % STEPS_PER_BAR;
|
||||
const root = CHORD_ROOTS[bar];
|
||||
const events = [];
|
||||
|
||||
// „Umm“ auf 1 und 3 (Bass + große Trommel), „ta“ auf 2 und 4 (Akkord + kleine Trommel).
|
||||
if (pos === 0 || pos === 8) {
|
||||
events.push({ instrument: 'kick', notes: [], lengthSteps: 2, velocity: 1 });
|
||||
events.push({ instrument: 'bass', notes: [pos === 0 ? root - 12 : root - 5], lengthSteps: 4, velocity: 0.9 });
|
||||
}
|
||||
if (pos === 4 || pos === 12) {
|
||||
events.push({ instrument: 'snare', notes: [], lengthSteps: 1, velocity: 0.8 });
|
||||
events.push({ instrument: 'stab', notes: MAJOR_TRIAD.map((i) => root + 12 + i), lengthSteps: 2, velocity: 0.8 });
|
||||
}
|
||||
// Trommelwirbel in der zweiten Hälfte des letzten Takts leitet die Wiederholung ein.
|
||||
if (bar === BARS - 1 && pos >= 8 && pos !== 12) {
|
||||
events.push({ instrument: 'snare', notes: [], lengthSteps: 1, velocity: 0.35 + (pos - 8) * 0.07 });
|
||||
}
|
||||
for (const [mBar, mStep, note, length] of MELODY) {
|
||||
if (mBar === bar && mStep === pos) {
|
||||
events.push({ instrument: 'lead', notes: [note], lengthSteps: length, velocity: 0.8 });
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequenz eines MIDI-Tons (A4 = 69 = 440 Hz).
|
||||
* @param {number} midi
|
||||
* @returns {number}
|
||||
*/
|
||||
export function midiToFrequency(midi) {
|
||||
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Kleine Vektor- und Zahlenhelfer ohne Phaser-Abhängigkeit.
|
||||
* Vektoren sind einfache Objekte `{x, y}`; die Funktionen verändern ihre Eingaben nie.
|
||||
* @module logic/math
|
||||
*/
|
||||
|
||||
/** @typedef {{x: number, y: number}} Vec2 */
|
||||
|
||||
/**
|
||||
* Begrenzt `value` auf das Intervall [min, max].
|
||||
* @param {number} value
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lineare Interpolation zwischen `a` und `b`.
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @param {number} t 0..1
|
||||
* @returns {number}
|
||||
*/
|
||||
export function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
/** @param {number} deg @returns {number} */
|
||||
export function degToRad(deg) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** @param {number} x @param {number} y @returns {Vec2} */
|
||||
export function vec(x = 0, y = 0) {
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** @param {Vec2} a @param {Vec2} b @returns {Vec2} */
|
||||
export function add(a, b) {
|
||||
return { x: a.x + b.x, y: a.y + b.y };
|
||||
}
|
||||
|
||||
/** @param {Vec2} a @param {Vec2} b @returns {Vec2} */
|
||||
export function sub(a, b) {
|
||||
return { x: a.x - b.x, y: a.y - b.y };
|
||||
}
|
||||
|
||||
/** @param {Vec2} v @param {number} s @returns {Vec2} */
|
||||
export function scale(v, s) {
|
||||
return { x: v.x * s, y: v.y * s };
|
||||
}
|
||||
|
||||
/** @param {Vec2} v @returns {number} */
|
||||
export function length(v) {
|
||||
return Math.hypot(v.x, v.y);
|
||||
}
|
||||
|
||||
/** @param {Vec2} a @param {Vec2} b @returns {number} */
|
||||
export function distance(a, b) {
|
||||
return Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Einheitsvektor in Richtung `v`. Der Nullvektor bleibt der Nullvektor –
|
||||
* so muss kein Aufrufer Division durch null abfangen.
|
||||
* @param {Vec2} v
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function normalize(v) {
|
||||
const len = length(v);
|
||||
return len > 0 ? { x: v.x / len, y: v.y / len } : { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kürzt `v` auf höchstens `max` Länge, die Richtung bleibt erhalten.
|
||||
* @param {Vec2} v
|
||||
* @param {number} max
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function clampLength(v, max) {
|
||||
const len = length(v);
|
||||
return len > max && len > 0 ? scale(v, max / len) : { x: v.x, y: v.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dreht `v` um `angle` (Bogenmaß, im Uhrzeigersinn bei y nach unten wie im Canvas).
|
||||
* @param {Vec2} v
|
||||
* @param {number} angle
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function rotate(v, angle) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
return { x: v.x * cos - v.y * sin, y: v.x * sin + v.y * cos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Winkel des Vektors von `from` nach `to` (Bogenmaß, 0 = nach rechts).
|
||||
* @param {Vec2} from
|
||||
* @param {Vec2} to
|
||||
* @returns {number}
|
||||
*/
|
||||
export function angleTo(from, to) {
|
||||
return Math.atan2(to.y - from.y, to.x - from.x);
|
||||
}
|
||||
|
||||
/** @param {number} angle @returns {Vec2} Einheitsvektor für den Winkel. */
|
||||
export function fromAngle(angle) {
|
||||
return { x: Math.cos(angle), y: Math.sin(angle) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Punkt in einem Kreis liegt (inkl. Radius eines Objekts).
|
||||
* @param {Vec2} point
|
||||
* @param {Vec2} center
|
||||
* @param {number} radius
|
||||
* @param {number} [objectRadius=0] Ausdehnung des Objekts, damit große Gegner am Rand zählen.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function inCircle(point, center, radius, objectRadius = 0) {
|
||||
return distance(point, center) <= radius + objectRadius;
|
||||
}
|
||||
|
||||
/**
|
||||
* Richtungsvektor aus gedrückten Tasten; diagonal wird normalisiert, damit man
|
||||
* schräg nicht schneller läuft (klassischer 8-Wege-Fehler).
|
||||
* @param {{up: boolean, down: boolean, left: boolean, right: boolean}} keys
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function directionFromKeys(keys) {
|
||||
const x = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
|
||||
const y = (keys.down ? 1 : 0) - (keys.up ? 1 : 0);
|
||||
return normalize({ x, y });
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Deterministischer Zufallszahlengenerator.
|
||||
*
|
||||
* Warum nicht Math.random: Mit einem Seed lässt sich eine Welle reproduzieren –
|
||||
* das macht die Wellengenerierung testbar und Balancing-Fehler nachstellbar.
|
||||
* @module logic/rng
|
||||
*/
|
||||
|
||||
/** @typedef {() => number} Rng Liefert Zahlen im Intervall [0, 1). */
|
||||
|
||||
/**
|
||||
* Mulberry32: klein, schnell und für Spielzwecke gut genug verteilt.
|
||||
* @param {number} seed Beliebige Ganzzahl.
|
||||
* @returns {Rng}
|
||||
*/
|
||||
export function createRng(seed) {
|
||||
let state = seed >>> 0;
|
||||
return function next() {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ganzzahl im geschlossenen Intervall [min, max].
|
||||
* @param {Rng} rng
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
*/
|
||||
export function randInt(rng, min, max) {
|
||||
return min + Math.floor(rng() * (max - min + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gleitkommazahl im Intervall [min, max).
|
||||
* @param {Rng} rng
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number}
|
||||
*/
|
||||
export function randFloat(rng, min, max) {
|
||||
return min + rng() * (max - min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zufälliges Element einer nicht-leeren Liste.
|
||||
* @template T
|
||||
* @param {Rng} rng
|
||||
* @param {readonly T[]} items
|
||||
* @returns {T}
|
||||
*/
|
||||
export function pick(rng, items) {
|
||||
if (items.length === 0) {
|
||||
throw new Error('pick() braucht mindestens ein Element');
|
||||
}
|
||||
return items[Math.floor(rng() * items.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed aus der aktuellen Zeit, für echte Spielrunden.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function timeSeed() {
|
||||
return (Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Punkte, Serien und Multiplikator (ohne Phaser).
|
||||
*
|
||||
* Regeln: Jeder Abschuss innerhalb von `streakWindowMs` nach dem letzten setzt die
|
||||
* Serie fort. Alle `stepKills` Abschüsse steigt der Multiplikator um `stepValue`
|
||||
* bis `maxMultiplier`. Ein Treffer am Präsidenten beendet die Serie – das belohnt
|
||||
* sauberes Spielen statt blindem Draufhalten.
|
||||
* @module logic/scoring
|
||||
*/
|
||||
|
||||
import { SCORING } from '../config.js';
|
||||
|
||||
/**
|
||||
* Multiplikator für eine Serienlänge.
|
||||
* @param {number} streak Anzahl Abschüsse in Folge (inkl. des aktuellen).
|
||||
* @param {typeof SCORING} [cfg]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function multiplierForStreak(streak, cfg = SCORING) {
|
||||
if (streak <= 0) return 1;
|
||||
return Math.min(cfg.maxMultiplier, 1 + Math.floor(streak / cfg.stepKills) * cfg.stepValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bonus für eine überstandene Welle (ohne Multiplikator).
|
||||
* @param {number} waveNumber
|
||||
* @param {typeof SCORING} [cfg]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function waveBonus(waveNumber, cfg = SCORING) {
|
||||
return waveNumber * cfg.waveBonusPerWave;
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt Punktestand, Serie und Statistik einer Spielrunde.
|
||||
*/
|
||||
export class ScoreKeeper {
|
||||
/** @param {typeof SCORING} [cfg] */
|
||||
constructor(cfg = SCORING) {
|
||||
this.cfg = cfg;
|
||||
this.score = 0;
|
||||
this.kills = 0;
|
||||
this.streak = 0;
|
||||
this.bestStreak = 0;
|
||||
this.lastKillAt = -Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft die Serie zum Zeitpunkt `now` noch?
|
||||
* @param {number} now Spielzeit in ms.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isStreakActive(now) {
|
||||
return this.streak > 0 && now - this.lastKillAt <= this.cfg.streakWindowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Multiplikator (1, wenn die Serie abgelaufen ist).
|
||||
* @param {number} now
|
||||
* @returns {number}
|
||||
*/
|
||||
multiplierAt(now) {
|
||||
return this.isStreakActive(now) ? multiplierForStreak(this.streak, this.cfg) : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbleibender Anteil des Serienfensters (1 = gerade getroffen, 0 = abgelaufen).
|
||||
* @param {number} now
|
||||
* @returns {number}
|
||||
*/
|
||||
streakTimeLeft(now) {
|
||||
if (!this.isStreakActive(now)) return 0;
|
||||
return 1 - (now - this.lastKillAt) / this.cfg.streakWindowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbucht einen Abschuss.
|
||||
* @param {number} basePoints Punkte des Gegnertyps.
|
||||
* @param {number} now Spielzeit in ms.
|
||||
* @returns {{awarded: number, multiplier: number, streak: number}}
|
||||
*/
|
||||
registerKill(basePoints, now) {
|
||||
if (!this.isStreakActive(now)) {
|
||||
this.streak = 0;
|
||||
}
|
||||
this.streak += 1;
|
||||
this.kills += 1;
|
||||
this.bestStreak = Math.max(this.bestStreak, this.streak);
|
||||
this.lastKillAt = now;
|
||||
const multiplier = multiplierForStreak(this.streak, this.cfg);
|
||||
const awarded = Math.round(basePoints * multiplier);
|
||||
this.score += awarded;
|
||||
return { awarded, multiplier, streak: this.streak };
|
||||
}
|
||||
|
||||
/** Beendet die laufende Serie (z. B. wenn der Präsident getroffen wird). */
|
||||
breakStreak() {
|
||||
this.streak = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt Bonuspunkte ohne Multiplikator hinzu.
|
||||
* @param {number} points
|
||||
* @returns {number} Tatsächlich gutgeschriebene Punkte.
|
||||
*/
|
||||
addBonus(points) {
|
||||
const awarded = Math.max(0, Math.round(points));
|
||||
this.score += awarded;
|
||||
return awarded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Momentaufnahme für HUD und Game-Over-Bildschirm.
|
||||
* @param {number} now
|
||||
*/
|
||||
snapshot(now) {
|
||||
const active = this.isStreakActive(now);
|
||||
return {
|
||||
score: this.score,
|
||||
kills: this.kills,
|
||||
streak: active ? this.streak : 0,
|
||||
multiplier: this.multiplierAt(now),
|
||||
streakTimeLeft: this.streakTimeLeft(now),
|
||||
bestStreak: this.bestStreak,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Steuerungsverhalten („Steering Behaviors“) für die Gegner-KI, ohne Phaser.
|
||||
*
|
||||
* Jede Funktion liefert eine Wunschgeschwindigkeit. Die Entities mischen mehrere
|
||||
* davon (z. B. Verfolgen + Abstand zu Nachbarn) und begrenzen das Ergebnis auf ihr
|
||||
* Tempo. Die drei Grundmuster aus dem Design:
|
||||
* - Verfolgen → seek
|
||||
* - Abstand → keepDistance (mit seitlichem Ausweichen im Wohlfühlbereich)
|
||||
* - Flankieren → flankPoint / encirclePoint als Ziel für seek
|
||||
* @module logic/steering
|
||||
*/
|
||||
|
||||
import { add, angleTo, clampLength, distance, fromAngle, length, normalize, scale, sub } from './math.js';
|
||||
|
||||
/** @typedef {import('./math.js').Vec2} Vec2 */
|
||||
|
||||
/** Anteil der Grundgeschwindigkeit beim seitlichen Ausweichen. */
|
||||
export const DEFAULT_STRAFE_FACTOR = 0.6;
|
||||
|
||||
/**
|
||||
* Direkt auf ein Ziel zu.
|
||||
* @param {Vec2} position
|
||||
* @param {Vec2} target
|
||||
* @param {number} speed
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function seek(position, target, speed) {
|
||||
return scale(normalize(sub(target, position)), speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direkt von einer Bedrohung weg.
|
||||
* @param {Vec2} position
|
||||
* @param {Vec2} threat
|
||||
* @param {number} speed
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function flee(position, threat, speed) {
|
||||
return scale(seek(position, threat, speed), -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstoßung von Nachbarn, damit Gruppen nicht zu einem Klumpen verschmelzen.
|
||||
* Nähere Nachbarn stoßen stärker ab (linear bis 0 am Rand des Radius).
|
||||
* @param {Vec2} position
|
||||
* @param {Iterable<Vec2>} neighbors Andere Gegner (der eigene Eintrag darf enthalten sein).
|
||||
* @param {number} radius
|
||||
* @returns {Vec2} Unskalierter Richtungsvektor (Länge ≈ Anzahl/Stärke der Überlappungen).
|
||||
*/
|
||||
export function separation(position, neighbors, radius) {
|
||||
let push = { x: 0, y: 0 };
|
||||
for (const other of neighbors) {
|
||||
if (other === position) continue;
|
||||
const d = distance(position, other);
|
||||
if (d >= radius) continue;
|
||||
// Exakt übereinander: feste Richtung statt NaN, damit sich die beiden trennen.
|
||||
const away = d > 0 ? normalize(sub(position, other)) : { x: 1, y: 0 };
|
||||
push = add(push, scale(away, 1 - d / radius));
|
||||
}
|
||||
return push;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hält einen Abstand zwischen `band.min` und `band.max` zum Ziel ein.
|
||||
* Zu weit weg → annähern, zu nah → zurückweichen, dazwischen → seitlich ausweichen,
|
||||
* damit Fernkämpfer kein stehendes Ziel sind.
|
||||
* @param {Vec2} position
|
||||
* @param {Vec2} target
|
||||
* @param {{min: number, max: number}} band
|
||||
* @param {number} speed
|
||||
* @param {1|-1} strafeDirection 1 = im Uhrzeigersinn, -1 = dagegen.
|
||||
* @param {number} [strafeFactor]
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function keepDistance(position, target, band, speed, strafeDirection, strafeFactor = DEFAULT_STRAFE_FACTOR) {
|
||||
const d = distance(position, target);
|
||||
const toTarget = normalize(sub(target, position));
|
||||
if (d > band.max) return scale(toTarget, speed);
|
||||
if (d < band.min) return scale(toTarget, -speed);
|
||||
const tangent = { x: -toTarget.y * strafeDirection, y: toTarget.x * strafeDirection };
|
||||
return scale(tangent, speed * strafeFactor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flankenposition: Punkt auf einem Kreis um das Ziel, gegenüber der aktuellen
|
||||
* Blickachse Ziel→Gegner um `angleOffset` gedreht. Der Gegner läuft also nicht
|
||||
* frontal an, sondern schwenkt seitlich ein.
|
||||
* @param {Vec2} target
|
||||
* @param {Vec2} position
|
||||
* @param {number} angleOffset Bogenmaß; Vorzeichen bestimmt die Seite.
|
||||
* @param {number} radius
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function flankPoint(target, position, angleOffset, radius) {
|
||||
const baseAngle = angleTo(target, position);
|
||||
return add(target, scale(fromAngle(baseAngle + angleOffset), radius));
|
||||
}
|
||||
|
||||
/**
|
||||
* Umzingelungspunkt: fester Winkel („Platz im Kreis“) um das Ziel. Verteilt man
|
||||
* die Winkel auf einen Schwarm, greift er von allen Seiten an.
|
||||
* @param {Vec2} target
|
||||
* @param {number} slotAngle Bogenmaß.
|
||||
* @param {number} radius
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function encirclePoint(target, slotAngle, radius) {
|
||||
return add(target, scale(fromAngle(slotAngle), radius));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gewichtete Summe mehrerer Steuerungsvektoren, begrenzt auf `maxSpeed`.
|
||||
* @param {{vector: Vec2, weight: number}[]} parts
|
||||
* @param {number} maxSpeed
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function combine(parts, maxSpeed) {
|
||||
const sum = parts.reduce((acc, p) => add(acc, scale(p.vector, p.weight)), { x: 0, y: 0 });
|
||||
return clampLength(sum, maxSpeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nähert die aktuelle Geschwindigkeit der Wunschgeschwindigkeit an. Ohne diese
|
||||
* Glättung würden Gegner bei jedem Richtungswechsel ruckartig umspringen.
|
||||
* @param {Vec2} current
|
||||
* @param {Vec2} desired
|
||||
* @param {number} responsiveness 0..1 (1 = sofort).
|
||||
* @returns {Vec2}
|
||||
*/
|
||||
export function steerTowards(current, desired, responsiveness) {
|
||||
return {
|
||||
x: current.x + (desired.x - current.x) * responsiveness,
|
||||
y: current.y + (desired.y - current.y) * responsiveness,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist ein Vektor (fast) null? Nützlich für Animationen („steht still“).
|
||||
* @param {Vec2} v
|
||||
* @param {number} [epsilon]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isNearlyZero(v, epsilon = 1e-3) {
|
||||
return length(v) < epsilon;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Ablaufsteuerung der Wellen (ohne Phaser): Pause → Welle → Spawns → Welle geschafft.
|
||||
*
|
||||
* Der Director kennt keine Sprites. Er ruft `spawnGroup` auf, wenn eine Gruppe
|
||||
* erscheinen soll, und fragt `aliveCount` ab, um zu erkennen, wann eine Welle
|
||||
* geschafft ist. So lässt sich der gesamte Ablauf ohne Browser testen.
|
||||
* @module logic/waveDirector
|
||||
*/
|
||||
|
||||
import { WAVES } from '../config.js';
|
||||
import { generateWave } from './waves.js';
|
||||
|
||||
/** @typedef {import('./waves.js').WavePlan} WavePlan */
|
||||
/** @typedef {import('./waves.js').SpawnGroup} SpawnGroup */
|
||||
|
||||
/**
|
||||
* @typedef {Object} WaveDirectorOptions
|
||||
* @property {import('./rng.js').Rng} rng
|
||||
* @property {(group: SpawnGroup, plan: WavePlan) => void} spawnGroup
|
||||
* @property {() => number} aliveCount
|
||||
* @property {(plan: WavePlan) => void} [onWaveStart]
|
||||
* @property {(waveNumber: number) => void} [onWaveCleared]
|
||||
* @property {typeof WAVES} [cfg]
|
||||
*/
|
||||
|
||||
export class WaveDirector {
|
||||
/** @param {WaveDirectorOptions} options */
|
||||
constructor(options) {
|
||||
this.rng = options.rng;
|
||||
this.spawnGroup = options.spawnGroup;
|
||||
this.aliveCount = options.aliveCount;
|
||||
this.onWaveStart = options.onWaveStart ?? (() => {});
|
||||
this.onWaveCleared = options.onWaveCleared ?? (() => {});
|
||||
this.cfg = options.cfg ?? WAVES;
|
||||
|
||||
/** @type {'idle'|'intermission'|'active'} */
|
||||
this.state = 'idle';
|
||||
this.waveNumber = 0;
|
||||
/** @type {WavePlan|null} */
|
||||
this.plan = null;
|
||||
this.nextWaveAt = 0;
|
||||
this.nextGroupIndex = 0;
|
||||
/**
|
||||
* Uhr der laufenden Welle. Sie steht still, solange zu viele Gegner leben –
|
||||
* so stauen sich Spawns nicht auf, wenn der Spieler in Bedrängnis ist.
|
||||
*/
|
||||
this.waveClock = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Ablauf; die erste Welle folgt nach `firstWaveDelayMs`.
|
||||
* @param {number} now Spielzeit in ms.
|
||||
*/
|
||||
start(now) {
|
||||
this.state = 'intermission';
|
||||
this.waveNumber = 0;
|
||||
this.nextWaveAt = now + this.cfg.firstWaveDelayMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Muss jeden Frame aufgerufen werden.
|
||||
* @param {number} now Spielzeit in ms.
|
||||
* @param {number} deltaMs Seit dem letzten Aufruf vergangene Spielzeit.
|
||||
*/
|
||||
update(now, deltaMs) {
|
||||
if (this.state === 'intermission' && now >= this.nextWaveAt) {
|
||||
this.beginWave();
|
||||
return;
|
||||
}
|
||||
if (this.state !== 'active' || !this.plan) return;
|
||||
|
||||
if (this.aliveCount() < this.cfg.maxAliveEnemies) {
|
||||
this.waveClock += deltaMs;
|
||||
}
|
||||
const { groups } = this.plan;
|
||||
while (this.nextGroupIndex < groups.length && groups[this.nextGroupIndex].atMs <= this.waveClock) {
|
||||
this.spawnGroup(groups[this.nextGroupIndex], this.plan);
|
||||
this.nextGroupIndex += 1;
|
||||
}
|
||||
if (this.nextGroupIndex >= groups.length && this.aliveCount() === 0) {
|
||||
const cleared = this.waveNumber;
|
||||
this.state = 'intermission';
|
||||
this.nextWaveAt = now + this.cfg.intermissionMs;
|
||||
this.onWaveCleared(cleared);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
beginWave() {
|
||||
this.waveNumber += 1;
|
||||
this.plan = generateWave(this.waveNumber, this.rng, this.cfg);
|
||||
this.nextGroupIndex = 0;
|
||||
this.waveClock = 0;
|
||||
this.state = 'active';
|
||||
this.onWaveStart(this.plan);
|
||||
}
|
||||
|
||||
/** @returns {number} Gegner, die in dieser Welle noch erscheinen werden. */
|
||||
get pendingEnemies() {
|
||||
if (this.state !== 'active' || !this.plan) return 0;
|
||||
return this.plan.groups.slice(this.nextGroupIndex).reduce((sum, g) => sum + g.count, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @returns {number} Restzeit der Pause in ms (0 während einer Welle).
|
||||
*/
|
||||
intermissionRemaining(now) {
|
||||
return this.state === 'intermission' ? Math.max(0, this.nextWaveAt - now) : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Wellengenerierung (ohne Phaser).
|
||||
*
|
||||
* Idee: Jede Welle hat ein Punktebudget, das mit der Wellennummer wächst. Gegner
|
||||
* „kosten“ Budget (config ENEMY_TYPES.cost). Der Generator kauft zuerst Eliten
|
||||
* (in jeder n-ten Welle), dann mittlere Gegner (Anteil steigt mit der Welle) und
|
||||
* füllt den Rest mit kleinen Gegnern auf. So wird es gleichmäßig schwerer, ohne
|
||||
* dass man jede Welle von Hand bauen muss.
|
||||
* @module logic/waves
|
||||
*/
|
||||
|
||||
import { ENEMY_TYPES, WAVES } from '../config.js';
|
||||
import { pick, randFloat, randInt } from './rng.js';
|
||||
|
||||
/** @typedef {import('./rng.js').Rng} Rng */
|
||||
/** @typedef {'north'|'south'|'east'|'west'} Edge */
|
||||
|
||||
/**
|
||||
* @typedef {Object} SpawnGroup
|
||||
* @property {string} type Gegnertyp-ID aus ENEMY_TYPES.
|
||||
* @property {number} count Anzahl Gegner in dieser Gruppe.
|
||||
* @property {number} atMs Spawn-Zeitpunkt relativ zum Wellenstart.
|
||||
* @property {Edge} edge Arenarand, an dem die Gruppe erscheint.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} WavePlan
|
||||
* @property {number} number
|
||||
* @property {number} budget
|
||||
* @property {string[]} factions
|
||||
* @property {SpawnGroup[]} groups Nach atMs aufsteigend sortiert.
|
||||
* @property {number} totalEnemies Summe aller Gruppen (ohne ausgebrütete Krabbler).
|
||||
* @property {number} hpMultiplier
|
||||
* @property {number} speedMultiplier
|
||||
*/
|
||||
|
||||
/** @type {readonly Edge[]} */
|
||||
export const EDGES = Object.freeze(['north', 'south', 'east', 'west']);
|
||||
|
||||
/** Anteil des Budgets, der auch in Elite-Wellen für Fußvolk reserviert bleibt. */
|
||||
const MIN_FODDER_SHARE = 0.3;
|
||||
|
||||
/** Position der Eliten im Spawn-Ablauf (0 = zuerst, 1 = zuletzt): nicht sofort, nicht am Ende. */
|
||||
const ELITE_TIMELINE_POSITION = 0.4;
|
||||
|
||||
/**
|
||||
* Budget der Welle `n`.
|
||||
* @param {number} n Wellennummer ab 1.
|
||||
* @param {typeof WAVES} [cfg]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function waveBudget(n, cfg = WAVES) {
|
||||
return Math.round(cfg.firstBudget + cfg.budgetGrowth * Math.pow(n - 1, cfg.budgetExponent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Fraktionen in Welle `n` angreifen.
|
||||
* @param {number} n
|
||||
* @param {typeof WAVES} [cfg]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function waveFactions(n, cfg = WAVES) {
|
||||
if (n < cfg.legionFromWave) return ['swarm'];
|
||||
if (n < cfg.mixedFromWave) return ['legion'];
|
||||
return ['swarm', 'legion'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzahl der Elite-Gegner in Welle `n`.
|
||||
* @param {number} n
|
||||
* @param {typeof WAVES} [cfg]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function eliteCount(n, cfg = WAVES) {
|
||||
if (n % cfg.eliteEvery !== 0) return 0;
|
||||
return Math.min(cfg.maxElitesPerWave, n / cfg.eliteEvery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lebenspunkte- und Tempo-Multiplikatoren für späte Wellen.
|
||||
* @param {number} n
|
||||
* @param {typeof WAVES} [cfg]
|
||||
* @returns {{hp: number, speed: number}}
|
||||
*/
|
||||
export function waveMultipliers(n, cfg = WAVES) {
|
||||
const steps = Math.max(0, n - cfg.scalingFromWave + 1);
|
||||
return {
|
||||
hp: 1 + steps * cfg.hpScalePerWave,
|
||||
speed: Math.min(cfg.speedScaleMax, 1 + steps * cfg.speedScalePerWave),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Typ-IDs einer Fraktion und Stufe.
|
||||
* @param {string} faction
|
||||
* @param {'small'|'medium'|'elite'} tier
|
||||
* @param {typeof ENEMY_TYPES} [types]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function typesOf(faction, tier, types = ENEMY_TYPES) {
|
||||
return Object.values(types)
|
||||
.filter((t) => t.faction === faction && t.tier === tier)
|
||||
.map((t) => t.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kauft Gruppen einer Stufe, bis das Budget nicht mehr reicht.
|
||||
* @returns {{groups: {type: string, count: number}[], leftover: number}}
|
||||
*/
|
||||
function buyGroups(rng, factions, tier, budget, types) {
|
||||
const groups = [];
|
||||
let remaining = budget;
|
||||
for (;;) {
|
||||
const affordable = factions
|
||||
.flatMap((f) => typesOf(f, tier, types))
|
||||
.filter((id) => types[id].cost <= remaining);
|
||||
if (affordable.length === 0) break;
|
||||
const type = types[pick(rng, affordable)];
|
||||
const maxAffordable = Math.floor(remaining / type.cost);
|
||||
const count = Math.min(randInt(rng, type.groupSize.min, type.groupSize.max), maxAffordable);
|
||||
groups.push({ type: type.id, count });
|
||||
remaining -= count * type.cost;
|
||||
}
|
||||
return { groups, leftover: remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mischt eine Liste (Fisher-Yates) mit dem übergebenen Zufallsgenerator.
|
||||
* @template T
|
||||
* @param {Rng} rng
|
||||
* @param {T[]} items
|
||||
* @returns {T[]}
|
||||
*/
|
||||
function shuffled(rng, items) {
|
||||
const copy = items.slice();
|
||||
for (let i = copy.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt den Plan für Welle `n`.
|
||||
* @param {number} n Wellennummer ab 1.
|
||||
* @param {Rng} rng Zufallsgenerator (für Tests mit festem Seed).
|
||||
* @param {typeof WAVES} [cfg]
|
||||
* @param {typeof ENEMY_TYPES} [types]
|
||||
* @returns {WavePlan}
|
||||
*/
|
||||
export function generateWave(n, rng, cfg = WAVES, types = ENEMY_TYPES) {
|
||||
if (!Number.isInteger(n) || n < 1) {
|
||||
throw new RangeError(`Ungültige Wellennummer: ${n}`);
|
||||
}
|
||||
const budget = waveBudget(n, cfg);
|
||||
const factions = waveFactions(n, cfg);
|
||||
|
||||
// Eliten zuerst – sie sind der Höhepunkt der Welle. In gemischten Wellen wechselt
|
||||
// die Fraktion der Eliten von Elite-Welle zu Elite-Welle (erste Elite-Welle:
|
||||
// Schwärmer, weil die Wellen davor der Blechlegion gehörten).
|
||||
const elites = [];
|
||||
let remaining = budget;
|
||||
const eliteRound = n / cfg.eliteEvery - 1;
|
||||
for (let i = 0; i < eliteCount(n, cfg); i += 1) {
|
||||
const faction = factions[(eliteRound + i) % factions.length];
|
||||
const eliteType = typesOf(faction, 'elite', types)[0];
|
||||
elites.push({ type: eliteType, count: 1 });
|
||||
remaining -= types[eliteType].cost;
|
||||
}
|
||||
// Auch neben Eliten braucht es Fußvolk, sonst wirkt die Welle leer.
|
||||
remaining = Math.max(remaining, Math.ceil(budget * MIN_FODDER_SHARE));
|
||||
|
||||
const mediumShare = Math.min(
|
||||
cfg.mediumShareMax,
|
||||
cfg.mediumShareBase + cfg.mediumSharePerWave * (n - 1),
|
||||
);
|
||||
const medium = buyGroups(rng, factions, 'medium', Math.floor(remaining * mediumShare), types);
|
||||
const smallBudget = remaining - Math.floor(remaining * mediumShare) + medium.leftover;
|
||||
const small = buyGroups(rng, factions, 'small', smallBudget, types);
|
||||
|
||||
// Reihenfolge: Fußvolk gemischt, Eliten etwa bei 40 % des Ablaufs.
|
||||
const ordered = shuffled(rng, [...small.groups, ...medium.groups]);
|
||||
const eliteIndex = Math.floor(ordered.length * ELITE_TIMELINE_POSITION);
|
||||
ordered.splice(eliteIndex, 0, ...elites);
|
||||
|
||||
// Spätere Wellen kommen dichter hintereinander.
|
||||
const delayFactor = Math.max(cfg.groupDelayMinFactor, 1 - cfg.groupDelayFactorPerWave * (n - 1));
|
||||
let atMs = 0;
|
||||
const groups = ordered.map((group, index) => {
|
||||
if (index > 0) {
|
||||
atMs += Math.round(randFloat(rng, cfg.groupDelayMs.min, cfg.groupDelayMs.max) * delayFactor);
|
||||
}
|
||||
return { ...group, atMs, edge: pick(rng, EDGES) };
|
||||
});
|
||||
|
||||
const multipliers = waveMultipliers(n, cfg);
|
||||
return {
|
||||
number: n,
|
||||
budget,
|
||||
factions,
|
||||
groups,
|
||||
totalEnemies: groups.reduce((sum, g) => sum + g.count, 0),
|
||||
hpMultiplier: multipliers.hp,
|
||||
speedMultiplier: multipliers.speed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Zufälliger Spawnpunkt am gegebenen Arenarand, knapp innerhalb der Welt.
|
||||
* @param {Edge} edge
|
||||
* @param {{width: number, height: number}} world
|
||||
* @param {number} margin Abstand zum Rand.
|
||||
* @param {Rng} rng
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
export function spawnPointOnEdge(edge, world, margin, rng) {
|
||||
const along = (size) => randFloat(rng, margin, size - margin);
|
||||
switch (edge) {
|
||||
case 'north':
|
||||
return { x: along(world.width), y: margin };
|
||||
case 'south':
|
||||
return { x: along(world.width), y: world.height - margin };
|
||||
case 'west':
|
||||
return { x: margin, y: along(world.height) };
|
||||
case 'east':
|
||||
return { x: world.width - margin, y: along(world.height) };
|
||||
default:
|
||||
throw new RangeError(`Unbekannter Rand: ${edge}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Waffenzustand: Magazin, Reserve, Feuerrate, Nachladen (ohne Phaser).
|
||||
* @module logic/weapon
|
||||
*/
|
||||
|
||||
import { degToRad } from './math.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} WeaponDef
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
* @property {number} magazineSize
|
||||
* @property {number|null} reserveStart null = unbegrenzt
|
||||
* @property {number|null} reserveMax null = unbegrenzt
|
||||
* @property {number} fireIntervalMs
|
||||
* @property {number} reloadMs
|
||||
* @property {number} damage
|
||||
* @property {number} pellets
|
||||
* @property {number} spreadDeg
|
||||
* @property {number} bulletSpeed
|
||||
* @property {number} rangePx
|
||||
* @property {boolean} automatic
|
||||
*/
|
||||
|
||||
export class WeaponState {
|
||||
/** @param {WeaponDef} def */
|
||||
constructor(def) {
|
||||
this.def = def;
|
||||
this.magazine = def.magazineSize;
|
||||
/** @type {number} Bei unbegrenzter Reserve Infinity. */
|
||||
this.reserve = def.reserveStart === null ? Infinity : def.reserveStart;
|
||||
/** Spielzeit, zu der das Nachladen fertig ist; 0 = lädt nicht nach. */
|
||||
this.reloadingUntil = 0;
|
||||
this.reloadStartedAt = 0;
|
||||
this.lastShotAt = -Infinity;
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
get hasInfiniteReserve() {
|
||||
return this.def.reserveMax === null;
|
||||
}
|
||||
|
||||
/** @returns {boolean} Weder Magazin noch Reserve – Waffe ist leer. */
|
||||
get isEmpty() {
|
||||
return this.magazine === 0 && this.reserve === 0;
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
get isReloading() {
|
||||
return this.reloadingUntil > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schließt ein fälliges Nachladen ab. Muss jeden Frame aufgerufen werden.
|
||||
* @param {number} now
|
||||
* @returns {boolean} true, wenn in diesem Aufruf fertig nachgeladen wurde.
|
||||
*/
|
||||
update(now) {
|
||||
if (this.isReloading && now >= this.reloadingUntil) {
|
||||
const needed = this.def.magazineSize - this.magazine;
|
||||
const taken = Math.min(needed, this.reserve);
|
||||
this.magazine += taken;
|
||||
this.reserve -= taken;
|
||||
this.reloadingUntil = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canFire(now) {
|
||||
return !this.isReloading && this.magazine > 0 && now - this.lastShotAt >= this.def.fireIntervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feuert einen Schuss, falls möglich.
|
||||
* @param {number} now
|
||||
* @param {() => number} rng Zufall für die Streuung.
|
||||
* @returns {number[]|null} Winkelabweichungen der Projektile (Bogenmaß) oder null.
|
||||
*/
|
||||
fire(now, rng) {
|
||||
if (!this.canFire(now)) return null;
|
||||
this.magazine -= 1;
|
||||
this.lastShotAt = now;
|
||||
const offsets = pelletOffsets(this.def.pellets, this.def.spreadDeg, rng);
|
||||
// Leeres Magazin lädt automatisch nach – niemand will im Gefecht R suchen.
|
||||
if (this.magazine === 0) this.startReload(now);
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beginnt das Nachladen.
|
||||
* @param {number} now
|
||||
* @returns {boolean} false, wenn es nichts nachzuladen gibt.
|
||||
*/
|
||||
startReload(now) {
|
||||
if (this.isReloading || this.magazine >= this.def.magazineSize || this.reserve <= 0) {
|
||||
return false;
|
||||
}
|
||||
this.reloadStartedAt = now;
|
||||
this.reloadingUntil = now + this.def.reloadMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Bricht das Nachladen ab (z. B. beim Waffenwechsel); nichts wird verbraucht. */
|
||||
cancelReload() {
|
||||
this.reloadingUntil = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} now
|
||||
* @returns {number} 0..1, 1 wenn nicht nachgeladen wird.
|
||||
*/
|
||||
reloadProgress(now) {
|
||||
if (!this.isReloading) return 1;
|
||||
return Math.min(1, (now - this.reloadStartedAt) / this.def.reloadMs);
|
||||
}
|
||||
|
||||
/** Füllt die Reserve bis zum Maximum auf (Nachschubkapsel). */
|
||||
refill() {
|
||||
if (!this.hasInfiniteReserve) {
|
||||
this.reserve = /** @type {number} */ (this.def.reserveMax);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streuung der Projektile eines Schusses. Mehrere Projektile (Schrot) werden
|
||||
* gleichmäßig über den Fächer verteilt und leicht verwackelt, damit sich das Muster
|
||||
* nicht wiederholt; ein Einzelprojektil streut zufällig innerhalb des Fächers.
|
||||
* @param {number} pellets
|
||||
* @param {number} spreadDeg
|
||||
* @param {() => number} rng
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function pelletOffsets(pellets, spreadDeg, rng) {
|
||||
const spread = degToRad(spreadDeg);
|
||||
if (pellets <= 1) {
|
||||
return [(rng() - 0.5) * spread];
|
||||
}
|
||||
const step = spread / (pellets - 1);
|
||||
return Array.from({ length: pellets }, (_, i) => -spread / 2 + i * step + (rng() - 0.5) * step * 0.5);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Einstiegspunkt des Frontends: liest die Server-URLs aus dem HTML, baut die
|
||||
* gemeinsamen Dienste (Einstellungen, Audio, API) und startet Phaser.
|
||||
* @module main
|
||||
*/
|
||||
|
||||
import { GAME } from './config.js';
|
||||
import { ERRORS } from './content/texts.js';
|
||||
import { BootScene } from './scenes/BootScene.js';
|
||||
import { BriefingScene } from './scenes/BriefingScene.js';
|
||||
import { DisclaimerScene } from './scenes/DisclaimerScene.js';
|
||||
import { GameOverScene } from './scenes/GameOverScene.js';
|
||||
import { GameScene } from './scenes/GameScene.js';
|
||||
import { HudScene } from './scenes/HudScene.js';
|
||||
import { ControlsScene, CreditsScene, HighscoresScene } from './scenes/InfoScenes.js';
|
||||
import { MenuScene } from './scenes/MenuScene.js';
|
||||
import { PauseScene } from './scenes/PauseScene.js';
|
||||
import { ApiClient } from './systems/ApiClient.js';
|
||||
import { AudioManager } from './systems/AudioManager.js';
|
||||
import { SafeStorage, Settings } from './systems/Settings.js';
|
||||
|
||||
|
||||
/** @param {string} message */
|
||||
function showBootError(message) {
|
||||
const target = document.getElementById('boot-message');
|
||||
if (target) {
|
||||
target.textContent = message;
|
||||
target.classList.add('boot-message--error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die vom Server gerenderten URLs (funktioniert auch unter einer Unter-URL).
|
||||
* @param {HTMLElement} shell
|
||||
*/
|
||||
function readContext(shell) {
|
||||
return {
|
||||
staticBase: shell.dataset.staticBase ?? '/static/',
|
||||
api: {
|
||||
highscores: shell.dataset.apiHighscores ?? '/api/highscores',
|
||||
runs: shell.dataset.apiRuns ?? '/api/runs',
|
||||
},
|
||||
disclaimerUrl: shell.dataset.disclaimerUrl ?? '/disclaimer',
|
||||
};
|
||||
}
|
||||
|
||||
function start() {
|
||||
const shell = document.getElementById('game-shell');
|
||||
if (!shell) return;
|
||||
if (typeof window.Phaser === 'undefined') {
|
||||
showBootError(ERRORS.phaserMissing);
|
||||
return;
|
||||
}
|
||||
|
||||
const context = readContext(shell);
|
||||
const settings = new Settings(SafeStorage.forBrowser());
|
||||
const services = {
|
||||
settings,
|
||||
audio: new AudioManager(settings, { staticBase: context.staticBase }),
|
||||
api: new ApiClient(context.api),
|
||||
};
|
||||
// Klangdateien schon jetzt als Bytes laden; dekodiert wird nach der ersten Interaktion.
|
||||
services.audio.preload();
|
||||
|
||||
const game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
parent: 'game',
|
||||
width: GAME.width,
|
||||
height: GAME.height,
|
||||
backgroundColor: GAME.backgroundColor,
|
||||
banner: false,
|
||||
// Rechtsklick soll kein Browser-Kontextmenü über dem Spiel öffnen.
|
||||
disableContextMenu: true,
|
||||
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
|
||||
physics: { default: 'arcade', arcade: { debug: false } },
|
||||
// Ton übernimmt der eigene AudioManager (Kanäle, Synthesizer, Musik). Ohne diese
|
||||
// Einstellung würde Phaser zusätzlich einen eigenen AudioContext anlegen.
|
||||
audio: { noAudio: true },
|
||||
// Reihenfolge = Zeichenreihenfolge: HUD und Pause liegen über dem Spiel.
|
||||
scene: [
|
||||
BootScene,
|
||||
MenuScene,
|
||||
DisclaimerScene,
|
||||
BriefingScene,
|
||||
GameScene,
|
||||
HudScene,
|
||||
PauseScene,
|
||||
GameOverScene,
|
||||
HighscoresScene,
|
||||
ControlsScene,
|
||||
CreditsScene,
|
||||
],
|
||||
callbacks: {
|
||||
preBoot: (g) => {
|
||||
g.registry.set('context', context);
|
||||
g.registry.set('services', services);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Für End-to-End-Tests und Fehlersuche in der Browser-Konsole.
|
||||
window.gloryDefense = game;
|
||||
}
|
||||
|
||||
try {
|
||||
start();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showBootError(ERRORS.generic);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Startszene: lädt Grafiken mit Fortschrittsbalken und erzeugt für alles, was nicht
|
||||
* geladen werden konnte, Platzhalter. Danach geht es ins Hauptmenü.
|
||||
* @module scenes/BootScene
|
||||
*/
|
||||
|
||||
import { COLORS } from '../config.js';
|
||||
import { queueAssets } from '../systems/Assets.js';
|
||||
import { createPlaceholderTextures } from '../systems/Placeholders.js';
|
||||
|
||||
const BAR = Object.freeze({ width: 420, height: 14 });
|
||||
|
||||
export class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Boot');
|
||||
}
|
||||
|
||||
preload() {
|
||||
const { width, height } = this.scale;
|
||||
const context = this.registry.get('context');
|
||||
/** @type {string[]} */
|
||||
this.failedAssets = [];
|
||||
|
||||
const frame = this.add.graphics();
|
||||
frame.lineStyle(2, COLORS.gold, 1);
|
||||
frame.strokeRect(width / 2 - BAR.width / 2 - 4, height / 2 - BAR.height / 2 - 4, BAR.width + 8, BAR.height + 8);
|
||||
const bar = this.add.graphics();
|
||||
this.load.on('progress', (value) => {
|
||||
bar.clear();
|
||||
bar.fillStyle(COLORS.crimson, 1);
|
||||
bar.fillRect(width / 2 - BAR.width / 2, height / 2 - BAR.height / 2, BAR.width * value, BAR.height);
|
||||
});
|
||||
// Fehlende Dateien sind kein Abbruchgrund – dafür gibt es Platzhalter.
|
||||
this.load.on('loaderror', (file) => this.failedAssets.push(file.key));
|
||||
|
||||
queueAssets(this, context.staticBase);
|
||||
}
|
||||
|
||||
create() {
|
||||
if (this.failedAssets.length > 0) {
|
||||
console.warn(`Glory Defense: ${this.failedAssets.length} Grafik(en) nicht geladen, verwende Platzhalter:`, this.failedAssets);
|
||||
}
|
||||
createPlaceholderTextures(this);
|
||||
document.getElementById('boot-message')?.remove();
|
||||
this.scene.start('Menu');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Missionsbriefing im Schreibmaschinenstil. Leertaste/Klick zeigt zuerst den ganzen
|
||||
* Text, ein zweiter Druck startet den Einsatz.
|
||||
* @module scenes/BriefingScene
|
||||
*/
|
||||
|
||||
import { BRIEFING_CONTINUE, BRIEFINGS, PRESIDENT } from '../content/texts.js';
|
||||
import { oncePerEvent } from '../systems/keyboard.js';
|
||||
import { textStyle, UI } from '../ui/theme.js';
|
||||
|
||||
/** Tippgeschwindigkeit der Schreibmaschine in Zeichen pro Sekunde. */
|
||||
const CHARS_PER_SECOND = 70;
|
||||
|
||||
export class BriefingScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Briefing');
|
||||
}
|
||||
|
||||
create() {
|
||||
const { width, height } = this.scale;
|
||||
this.input.setDefaultCursor('default');
|
||||
const briefing = BRIEFINGS[Math.floor(Math.random() * BRIEFINGS.length)];
|
||||
|
||||
const bg = this.add.graphics();
|
||||
bg.fillStyle(0x0d1321, 1);
|
||||
bg.fillRect(0, 0, width, height);
|
||||
bg.lineStyle(3, 0xf2c14e, 1);
|
||||
bg.strokeRect(60, 50, width - 120, height - 100);
|
||||
bg.fillStyle(0xc1121f, 1);
|
||||
bg.fillRect(60, 50, width - 120, 64);
|
||||
|
||||
this.add.text(width / 2, 82, briefing.title, textStyle({ fontSize: '26px', strokeThickness: 0 })).setOrigin(0.5);
|
||||
this.add.text(96, 140, `AN: ${PRESIDENT.toUpperCase()}`, textStyle({ fontSize: '18px', color: UI.colors.gold }));
|
||||
|
||||
this.fullText = briefing.lines.join('\n\n');
|
||||
this.bodyText = this.add.text(96, 185, '', {
|
||||
...textStyle({ fontSize: '21px', strokeThickness: 0 }),
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
wordWrap: { width: width - 192 },
|
||||
lineSpacing: 6,
|
||||
});
|
||||
this.typed = 0;
|
||||
|
||||
this.add.text(width / 2, height - 80, BRIEFING_CONTINUE, textStyle({ fontSize: '17px', color: UI.colors.muted })).setOrigin(0.5);
|
||||
|
||||
const advance = oncePerEvent((event) => {
|
||||
// Gedrückt gehaltene Tasten sollen das Briefing nicht versehentlich überspringen.
|
||||
if (event?.repeat) return;
|
||||
if (this.typed < this.fullText.length) {
|
||||
this.typed = this.fullText.length;
|
||||
this.bodyText.setText(this.fullText);
|
||||
} else {
|
||||
this.scene.start('Game');
|
||||
}
|
||||
});
|
||||
this.input.on('pointerup', advance);
|
||||
this.input.keyboard.on('keydown-SPACE', advance);
|
||||
this.input.keyboard.on('keydown-ENTER', advance);
|
||||
this.input.keyboard.on('keydown-ESC', oncePerEvent(() => this.scene.start('Menu')));
|
||||
this.input.keyboard.addCapture('SPACE');
|
||||
}
|
||||
|
||||
update(_time, delta) {
|
||||
if (this.typed >= this.fullText.length) return;
|
||||
this.typed = Math.min(this.fullText.length, this.typed + (CHARS_PER_SECOND * delta) / 1000);
|
||||
this.bodyText.setText(this.fullText.slice(0, Math.floor(this.typed)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Disclaimer als HTML-Overlay. Der Text kommt aus dem Template
|
||||
* `_disclaimer_content.html` (einzige Quelle, auch für die Seite /disclaimer).
|
||||
* Die Bestätigung wird in localStorage gespeichert.
|
||||
* @module scenes/DisclaimerScene
|
||||
*/
|
||||
|
||||
import { DISCLAIMER } from '../content/texts.js';
|
||||
import { el, Overlay } from '../systems/Overlay.js';
|
||||
|
||||
export class DisclaimerScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Disclaimer');
|
||||
}
|
||||
|
||||
/** @param {{mandatory?: boolean}} data */
|
||||
create(data) {
|
||||
const services = this.registry.get('services');
|
||||
const context = this.registry.get('context');
|
||||
const mandatory = Boolean(data?.mandatory);
|
||||
|
||||
const content = [];
|
||||
const template = document.getElementById('disclaimer-content');
|
||||
if (template instanceof HTMLTemplateElement) {
|
||||
content.push(template.content.cloneNode(true));
|
||||
} else {
|
||||
content.push(el('p', '', DISCLAIMER.missingTemplate));
|
||||
}
|
||||
const link = /** @type {HTMLAnchorElement} */ (el('a', '', DISCLAIMER.openPage));
|
||||
link.href = context.disclaimerUrl;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
const linkLine = el('p');
|
||||
linkLine.append(link);
|
||||
content.push(linkLine);
|
||||
|
||||
const close = () => {
|
||||
// Der Klick ist eine Nutzeraktion – guter Moment, um den Ton freizuschalten.
|
||||
services.audio.unlock();
|
||||
services.audio.play('ui-confirm');
|
||||
services.settings.acceptDisclaimer();
|
||||
this.overlay.close();
|
||||
this.events.emit('disclaimer-closed');
|
||||
this.scene.stop();
|
||||
};
|
||||
|
||||
this.overlay = new Overlay({
|
||||
id: 'disclaimer-overlay',
|
||||
content,
|
||||
buttons: [{ label: mandatory ? DISCLAIMER.accept : DISCLAIMER.close, onClick: close, testId: 'disclaimer-accept' }],
|
||||
// Beim ersten Start muss aktiv bestätigt werden; Esc schließt nur den erneuten Aufruf.
|
||||
onEscape: mandatory ? undefined : close,
|
||||
});
|
||||
this.events.once('shutdown', () => this.overlay?.element.isConnected && this.overlay.close());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Game Over: Statistik, Namenseingabe für die Ehrentafel und Top 10.
|
||||
* Formular als HTML-Overlay (Texteingabe im Canvas wäre unzugänglich).
|
||||
* @module scenes/GameOverScene
|
||||
*/
|
||||
|
||||
import { HIGHSCORE } from '../config.js';
|
||||
import { GAME_OVER, HIGHSCORES } from '../content/texts.js';
|
||||
import { formatDuration, formatScore, validateName } from '../logic/format.js';
|
||||
import { el, Overlay } from '../systems/Overlay.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} RunStats
|
||||
* @property {number} score
|
||||
* @property {number} wave
|
||||
* @property {number} kills
|
||||
* @property {number} bestStreak
|
||||
* @property {number} durationMs
|
||||
* @property {string|null} runToken
|
||||
*/
|
||||
|
||||
export class GameOverScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('GameOver');
|
||||
}
|
||||
|
||||
/** @param {RunStats} stats */
|
||||
create(stats) {
|
||||
this.stats = stats;
|
||||
this.services = this.registry.get('services');
|
||||
const { width, height } = this.scale;
|
||||
this.input.setDefaultCursor('default');
|
||||
// Sonst würde Phaser Tasten wie Leertaste oder WASD im Namensfeld abfangen.
|
||||
this.input.keyboard.clearCaptures();
|
||||
this.input.keyboard.disableGlobalCapture();
|
||||
this.events.once('shutdown', () => {
|
||||
this.overlay?.close();
|
||||
this.input.keyboard?.enableGlobalCapture();
|
||||
});
|
||||
|
||||
this.services.audio.stopMusic();
|
||||
this.services.audio.play('game-over');
|
||||
|
||||
const bg = this.add.graphics();
|
||||
bg.fillGradientStyle(0x2a0a10, 0x2a0a10, 0x0d1321, 0x0d1321, 1);
|
||||
bg.fillRect(0, 0, width, height);
|
||||
|
||||
this.showForm();
|
||||
}
|
||||
|
||||
/** @returns {HTMLElement[]} Untertitel und Statistik. */
|
||||
summary() {
|
||||
const s = this.stats;
|
||||
const subtitle = GAME_OVER.subtitles[Math.floor(Math.random() * GAME_OVER.subtitles.length)];
|
||||
const list = el('dl', 'stats');
|
||||
const rows = [
|
||||
[GAME_OVER.stats.score, formatScore(s.score)],
|
||||
[GAME_OVER.stats.wave, String(s.wave)],
|
||||
[GAME_OVER.stats.kills, String(s.kills)],
|
||||
[GAME_OVER.stats.bestStreak, String(s.bestStreak)],
|
||||
[GAME_OVER.stats.duration, formatDuration(s.durationMs)],
|
||||
];
|
||||
for (const [label, value] of rows) {
|
||||
const row = el('div', 'stats__row');
|
||||
row.append(el('dt', '', label), el('dd', '', value));
|
||||
list.append(row);
|
||||
}
|
||||
this.subtitle = this.subtitle ?? subtitle;
|
||||
return [el('p', 'disclaimer__lead', this.subtitle), list];
|
||||
}
|
||||
|
||||
endButtons() {
|
||||
return [
|
||||
{ label: GAME_OVER.retry, onClick: () => this.scene.start('Game'), testId: 'gameover-retry' },
|
||||
{ label: GAME_OVER.menu, onClick: () => this.scene.start('Menu'), secondary: true, testId: 'gameover-menu' },
|
||||
];
|
||||
}
|
||||
|
||||
showForm() {
|
||||
const content = this.summary();
|
||||
if (!this.stats.runToken || this.stats.score <= 0) {
|
||||
if (!this.stats.runToken) content.push(el('p', 'field__error', GAME_OVER.noToken));
|
||||
this.openOverlay(content, this.endButtons());
|
||||
return;
|
||||
}
|
||||
|
||||
const form = /** @type {HTMLFormElement} */ (el('form', 'highscore-form'));
|
||||
const field = el('label', 'field');
|
||||
const input = /** @type {HTMLInputElement} */ (el('input', 'field__input'));
|
||||
input.type = 'text';
|
||||
input.name = 'name';
|
||||
input.maxLength = HIGHSCORE.nameMaxLength;
|
||||
input.autocomplete = 'nickname';
|
||||
input.spellcheck = false;
|
||||
input.required = true;
|
||||
input.value = this.services.settings.getLastName();
|
||||
input.dataset.testid = 'highscore-name';
|
||||
const error = el('p', 'field__error');
|
||||
error.setAttribute('role', 'alert');
|
||||
field.append(el('span', 'field__label', GAME_OVER.nameLabel), input, el('span', 'field__hint', GAME_OVER.nameHint));
|
||||
form.append(field, error);
|
||||
content.push(form);
|
||||
|
||||
const submit = async () => {
|
||||
const check = validateName(input.value);
|
||||
if (!check.ok) {
|
||||
error.textContent = check.error;
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
submitButton.disabled = true;
|
||||
error.textContent = GAME_OVER.submitting;
|
||||
const result = await this.services.api.submitHighscore({
|
||||
name: check.name,
|
||||
score: this.stats.score,
|
||||
wave: this.stats.wave,
|
||||
durationS: Math.floor(this.stats.durationMs / 1000),
|
||||
runToken: /** @type {string} */ (this.stats.runToken),
|
||||
});
|
||||
if (!this.sys.isActive()) return;
|
||||
if (result.ok) {
|
||||
this.services.settings.setLastName(check.name);
|
||||
this.showResults(GAME_OVER.saved(result.rank), result.highscores ?? [], result.rank);
|
||||
return;
|
||||
}
|
||||
submitButton.disabled = false;
|
||||
if (result.error === 'network') error.textContent = GAME_OVER.offline;
|
||||
else error.textContent = result.fields?.name ?? result.message ?? GAME_OVER.offline;
|
||||
};
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
});
|
||||
|
||||
this.openOverlay(content, [
|
||||
{ label: GAME_OVER.submit, onClick: () => submit(), testId: 'highscore-submit' },
|
||||
{ label: GAME_OVER.skip, onClick: () => this.showResults('', null, null), secondary: true, testId: 'highscore-skip' },
|
||||
]);
|
||||
const submitButton = /** @type {HTMLButtonElement} */ (this.overlay.actions.firstElementChild);
|
||||
input.select();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {import('../systems/ApiClient.js').HighscoreEntry[]|null} highscores null = neu laden
|
||||
* @param {number|null} ownRank
|
||||
*/
|
||||
async showResults(message, highscores, ownRank) {
|
||||
const content = this.summary();
|
||||
if (message) content.push(el('p', 'disclaimer__lead', message));
|
||||
const table = el('ol', 'highscore-list');
|
||||
content.push(table);
|
||||
this.openOverlay(content, this.endButtons());
|
||||
|
||||
let entries = highscores;
|
||||
if (!entries) {
|
||||
table.append(el('li', '', HIGHSCORES.loading));
|
||||
const result = await this.services.api.fetchHighscores();
|
||||
if (!this.sys.isActive()) return;
|
||||
entries = result.ok ? result.highscores : [];
|
||||
table.textContent = '';
|
||||
if (!result.ok) table.append(el('li', '', HIGHSCORES.error));
|
||||
}
|
||||
if (entries.length === 0 && table.childElementCount === 0) table.append(el('li', '', HIGHSCORES.empty));
|
||||
for (const entry of entries) {
|
||||
const item = el('li', entry.rank === ownRank ? 'highscore-list__own' : '');
|
||||
item.append(
|
||||
el('span', 'highscore-list__rank', `${entry.rank}.`),
|
||||
el('span', 'highscore-list__name', entry.name),
|
||||
el('span', 'highscore-list__score', formatScore(entry.score)),
|
||||
el('span', 'highscore-list__wave', `W${entry.wave}`),
|
||||
);
|
||||
table.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement[]} content
|
||||
* @param {import('../systems/Overlay.js').OverlayButton[]} buttons
|
||||
*/
|
||||
openOverlay(content, buttons) {
|
||||
this.overlay?.close();
|
||||
this.overlay = new Overlay({ id: 'gameover-overlay', title: GAME_OVER.title, content, buttons });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* Die eigentliche Spielszene: Arena, Präsident, Gegner, Projektile, Wellen und
|
||||
* Orbital-Befehle. Das HUD läuft als eigene Szene darüber (HudScene) und liest
|
||||
* seinen Zustand über `hudState()`.
|
||||
*
|
||||
* Zeitmessung: Alle Spielmechaniken verwenden `this.gameTime` (eigene Uhr, die nur
|
||||
* läuft, während die Szene aktiv ist). So laufen Abklingzeiten in der Pause nicht
|
||||
* weiter, und die gemeldete Spieldauer enthält keine Pausen.
|
||||
* @module scenes/GameScene
|
||||
*/
|
||||
|
||||
import { COLORS, ENEMY_TYPES, FX, GAME, PLAYER, SCORING } from '../config.js';
|
||||
import { ORBITAL_TEXT, PLAYER_TEXT, PROPAGANDA, WAVE } from '../content/texts.js';
|
||||
import { SupplyPod, Turret } from '../entities/Deployables.js';
|
||||
import { Enemy } from '../entities/Enemy.js';
|
||||
import { Player } from '../entities/Player.js';
|
||||
import { ProjectilePool } from '../entities/ProjectilePool.js';
|
||||
import { clamp, directionFromKeys, distance, inCircle } from '../logic/math.js';
|
||||
import { createRng, randFloat, timeSeed } from '../logic/rng.js';
|
||||
import { ScoreKeeper, waveBonus } from '../logic/scoring.js';
|
||||
import { WaveDirector } from '../logic/waveDirector.js';
|
||||
import { spawnPointOnEdge } from '../logic/waves.js';
|
||||
import { formatMultiplier, formatSequence } from '../logic/format.js';
|
||||
import { buildArena } from '../systems/Arena.js';
|
||||
import { Effects } from '../systems/Effects.js';
|
||||
import { GroundMarker } from '../systems/GroundMarker.js';
|
||||
import { OrbitalSystem } from '../systems/OrbitalSystem.js';
|
||||
import { oncePerEvent } from '../systems/keyboard.js';
|
||||
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
|
||||
|
||||
/** Längere Frames (z. B. nach Tab-Wechsel) zählen nicht voll als Spielzeit. */
|
||||
const MAX_FRAME_MS = 100;
|
||||
/** Streuung der Mitglieder einer Spawn-Gruppe um ihren Spawnpunkt. */
|
||||
const SPAWN_SPREAD = 70;
|
||||
/** Kein Spawn näher als das am Präsidenten – sonst gäbe es unfaire Treffer aus dem Nichts. */
|
||||
const MIN_SPAWN_DISTANCE = 420;
|
||||
/** Ein Klick kurz vor Ende der Feuerpause wird nachgeholt (fühlt sich reaktionsschneller an). */
|
||||
const CLICK_BUFFER_MS = 180;
|
||||
const PLAYER_BULLET_POOL = 240;
|
||||
const ENEMY_BULLET_POOL = 280;
|
||||
const ENEMY_BULLET_RANGE = 950;
|
||||
/** Reichweitenzuschlag für Geschützprojektile über den Zielradius hinaus. */
|
||||
const TURRET_BULLET_EXTRA_RANGE = 80;
|
||||
const OPPOSITE_EDGE = Object.freeze({ north: 'south', south: 'north', east: 'west', west: 'east' });
|
||||
const FACTION_COLORS = Object.freeze({ swarm: COLORS.swarm, legion: COLORS.legion });
|
||||
const ORBITAL_COLORS = Object.freeze({ strike: COLORS.strike, supply: COLORS.supply, turret: COLORS.turret });
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Game');
|
||||
}
|
||||
|
||||
create() {
|
||||
this.services = this.registry.get('services');
|
||||
this.rng = createRng(timeSeed());
|
||||
this.gameTime = 0;
|
||||
this.isOver = false;
|
||||
this.runToken = null;
|
||||
this.lowAmmoHinted = false;
|
||||
this.propagandaDeck = [];
|
||||
this.firePressedAt = -Infinity;
|
||||
this.services.api.startRun().then((token) => {
|
||||
this.runToken = token;
|
||||
});
|
||||
|
||||
this.createArena();
|
||||
this.player = new Player(this, GAME.world.width / 2, GAME.world.height / 2);
|
||||
this.cameras.main.setBounds(0, 0, GAME.world.width, GAME.world.height);
|
||||
this.cameras.main.startFollow(this.player, true, GAME.cameraLerp, GAME.cameraLerp);
|
||||
|
||||
this.enemies = this.physics.add.group();
|
||||
this.pods = this.physics.add.group();
|
||||
/** @type {Turret[]} */
|
||||
this.turrets = [];
|
||||
/** @type {{marker: GroundMarker, cfg: object}[]} */
|
||||
this.mortars = [];
|
||||
this.playerBullets = new ProjectilePool(this, TEXTURE_KEYS.bulletPlayer, PLAYER_BULLET_POOL, GAME.depth.projectiles);
|
||||
this.enemyBullets = new ProjectilePool(this, TEXTURE_KEYS.bulletEnemy, ENEMY_BULLET_POOL, GAME.depth.projectiles);
|
||||
this.effects = new Effects(this);
|
||||
this.score = new ScoreKeeper();
|
||||
this.healthBars = this.add.graphics().setDepth(GAME.depth.effects);
|
||||
|
||||
this.enemyContext = {
|
||||
player: this.player,
|
||||
neighbors: [],
|
||||
rng: this.rng,
|
||||
fireBullet: (x, y, angle, speed, damage) => {
|
||||
this.enemyBullets.fire(x, y, angle, speed, damage, ENEMY_BULLET_RANGE);
|
||||
this.services.audio.play('enemy-shot');
|
||||
},
|
||||
spawnChildren: (mother, count) => this.spawnBrood(mother, count),
|
||||
scheduleMortar: (x, y, cfg) => this.scheduleMortar(x, y, cfg),
|
||||
};
|
||||
|
||||
this.setupInput();
|
||||
this.orbital = new OrbitalSystem(this, {
|
||||
getPlayer: () => this.player,
|
||||
getAimPoint: () => this.aimPoint(),
|
||||
onImpact: (command, x, y) => this.onOrbitalImpact(command, x, y),
|
||||
onFeedback: (kind, command, remainingMs) => this.onOrbitalFeedback(kind, command, remainingMs),
|
||||
});
|
||||
this.waves = new WaveDirector({
|
||||
rng: this.rng,
|
||||
spawnGroup: (group, plan) => this.spawnGroup(group, plan),
|
||||
aliveCount: () => this.enemies.countActive(true),
|
||||
onWaveStart: (plan) => this.onWaveStart(plan),
|
||||
onWaveCleared: (n) => this.onWaveCleared(n),
|
||||
});
|
||||
this.setupCollisions();
|
||||
this.waves.start(this.gameTime);
|
||||
|
||||
this.scene.launch('Hud');
|
||||
this.input.setDefaultCursor('none');
|
||||
this.game.events.on('blur', this.autoPause, this);
|
||||
this.events.once('shutdown', this.cleanup, this);
|
||||
this.services.audio.startMusic();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Aufbau
|
||||
|
||||
createArena() {
|
||||
const { width, height } = GAME.world;
|
||||
this.physics.world.setBounds(0, 0, width, height);
|
||||
this.worldRect = new Phaser.Geom.Rectangle(0, 0, width, height);
|
||||
buildArena(this, this.rng);
|
||||
}
|
||||
|
||||
setupInput() {
|
||||
// Bewegung wird pro Frame abgefragt (gehaltene Tasten). Einmalige Aktionen laufen
|
||||
// dagegen über keydown-Ereignisse: Phasers JustDown verliert Tastendrücke, die
|
||||
// innerhalb eines Frames gedrückt UND losgelassen werden (schnelles Antippen).
|
||||
this.keys = this.input.keyboard.addKeys('W,A,S,D,R,SPACE,ONE,TWO,THREE,ESC');
|
||||
/** @type {Set<string>} Seit dem letzten Frame angeforderte Aktionen. */
|
||||
this.pendingActions = new Set();
|
||||
const bindings = { ESC: 'pause', SPACE: 'dodge', R: 'reload', ONE: 'slot-0', TWO: 'slot-1', THREE: 'slot-2' };
|
||||
for (const [key, action] of Object.entries(bindings)) {
|
||||
this.input.keyboard.on(
|
||||
`keydown-${key}`,
|
||||
oncePerEvent((event) => {
|
||||
if (!event.repeat) this.pendingActions.add(action);
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.input.on('pointerdown', (pointer) => {
|
||||
this.services.audio.unlock();
|
||||
if (pointer.leftButtonDown()) this.firePressedAt = this.gameTime;
|
||||
});
|
||||
this.input.on('wheel', (_pointer, _over, _dx, dy) => {
|
||||
if (dy !== 0 && this.player.cycleWeapon(dy > 0 ? 1 : -1)) this.services.audio.play('switch');
|
||||
});
|
||||
}
|
||||
|
||||
setupCollisions() {
|
||||
this.physics.add.overlap(this.playerBullets.group, this.enemies, (a, b) => {
|
||||
const [bullet, enemy] = a instanceof Enemy ? [b, a] : [a, b];
|
||||
this.onBulletHitEnemy(bullet, enemy);
|
||||
});
|
||||
this.physics.add.overlap(
|
||||
this.player,
|
||||
this.enemyBullets.group,
|
||||
(a, b) => this.onEnemyBulletHitPlayer(a === this.player ? b : a),
|
||||
// Während der Rolle fliegen Projektile hindurch, statt verschluckt zu werden.
|
||||
() => !this.player.isDodging(this.gameTime),
|
||||
);
|
||||
this.physics.add.overlap(this.player, this.enemies, (a, b) => this.onEnemyContact(a === this.player ? b : a));
|
||||
this.physics.add.overlap(this.player, this.pods, (a, b) => this.onPickup(a === this.player ? b : a));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Hauptschleife
|
||||
|
||||
update(_time, delta) {
|
||||
const dt = Math.min(delta, MAX_FRAME_MS);
|
||||
this.gameTime += dt;
|
||||
const now = this.gameTime;
|
||||
|
||||
if (this.isOver) {
|
||||
this.player.move(now, { x: 0, y: 0 }, true);
|
||||
this.updateEnemies(now);
|
||||
this.updateProjectiles();
|
||||
return;
|
||||
}
|
||||
if (this.pendingActions.has('pause')) {
|
||||
this.pendingActions.clear();
|
||||
this.openPause();
|
||||
return;
|
||||
}
|
||||
|
||||
this.orbital.update(now);
|
||||
this.updatePlayer(now);
|
||||
this.updateEnemies(now);
|
||||
this.updateDeployables(now);
|
||||
this.updateMortars(now);
|
||||
this.updateProjectiles();
|
||||
this.waves.update(now, dt);
|
||||
this.drawHealthBars();
|
||||
}
|
||||
|
||||
updatePlayer(now) {
|
||||
const { keys, player } = this;
|
||||
const comboActive = this.orbital.isInputActive;
|
||||
// Während der Befehlseingabe dienen WASD als Richtungstasten, nicht zum Laufen.
|
||||
const move = comboActive
|
||||
? { x: 0, y: 0 }
|
||||
: directionFromKeys({ up: keys.W.isDown, down: keys.S.isDown, left: keys.A.isDown, right: keys.D.isDown });
|
||||
|
||||
const actions = this.pendingActions;
|
||||
if (!comboActive && actions.has('dodge') && player.tryDodge(now, move)) {
|
||||
this.services.audio.play('dodge');
|
||||
this.effects.afterimage(player);
|
||||
this.effects.burst(player.x, player.y, COLORS.muted, 8);
|
||||
}
|
||||
player.move(now, move, comboActive);
|
||||
const aim = this.aimPoint();
|
||||
player.aimAt(aim.x, aim.y);
|
||||
|
||||
player.weapons.forEach((_weapon, index) => {
|
||||
if (actions.has(`slot-${index}`) && player.switchWeapon(index)) this.services.audio.play('switch');
|
||||
});
|
||||
if (actions.has('reload') && player.weapon.startReload(now)) {
|
||||
this.services.audio.play('reload');
|
||||
}
|
||||
actions.clear();
|
||||
if (player.updateWeapons(now)) this.services.audio.play('reload-done');
|
||||
this.handleFiring(now);
|
||||
}
|
||||
|
||||
handleFiring(now) {
|
||||
const { player } = this;
|
||||
const weapon = player.weapon;
|
||||
const def = weapon.def;
|
||||
const held = this.input.activePointer.leftButtonDown();
|
||||
const clicked = now - this.firePressedAt <= CLICK_BUFFER_MS;
|
||||
if (!(def.automatic ? held || clicked : clicked)) return;
|
||||
|
||||
if (weapon.isEmpty) {
|
||||
// Waffe leer: automatisch zur Dienstpistole mit unbegrenzter Munition wechseln.
|
||||
const fallback = player.weapons.findIndex((w) => w.hasInfiniteReserve);
|
||||
if (fallback >= 0 && player.switchWeapon(fallback)) this.feedback(PLAYER_TEXT.outOfAmmo, 'warn');
|
||||
this.firePressedAt = -Infinity;
|
||||
return;
|
||||
}
|
||||
if (weapon.magazine === 0 && weapon.startReload(now)) this.services.audio.play('reload');
|
||||
|
||||
const offsets = weapon.fire(now, this.rng);
|
||||
if (!offsets) return;
|
||||
this.firePressedAt = -Infinity;
|
||||
const muzzle = player.muzzlePosition();
|
||||
for (const offset of offsets) {
|
||||
this.playerBullets.fire(muzzle.x, muzzle.y, player.aimAngle + offset, def.bulletSpeed, def.damage, def.rangePx);
|
||||
}
|
||||
this.effects.muzzle(muzzle.x, muzzle.y, player.aimAngle);
|
||||
this.services.audio.play(`shot-${def.id}`);
|
||||
if (weapon.isReloading && weapon.reserve > 0) this.services.audio.play('reload');
|
||||
|
||||
if (!weapon.hasInfiniteReserve && weapon.reserve === 0 && !this.lowAmmoHinted) {
|
||||
this.lowAmmoHinted = true;
|
||||
const supply = this.orbital.commands.find((c) => c.id === 'supply');
|
||||
this.feedback(PLAYER_TEXT.lowAmmo(supply ? formatSequence(supply.sequence) : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
updateEnemies(now) {
|
||||
const list = this.enemies.getChildren().slice();
|
||||
const ctx = this.enemyContext;
|
||||
ctx.neighbors = list;
|
||||
for (const enemy of list) {
|
||||
if (enemy.alive) enemy.step(now, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
updateDeployables(now) {
|
||||
const alive = this.enemies.getChildren().filter((e) => e.alive);
|
||||
for (let i = this.turrets.length - 1; i >= 0; i -= 1) {
|
||||
const turret = this.turrets[i];
|
||||
const running = turret.step(now, alive, (x, y, angle) => {
|
||||
this.playerBullets.fire(
|
||||
x,
|
||||
y,
|
||||
angle,
|
||||
turret.cfg.bulletSpeed,
|
||||
turret.cfg.bulletDamage,
|
||||
turret.cfg.range + TURRET_BULLET_EXTRA_RANGE,
|
||||
TEXTURE_KEYS.bulletTurret,
|
||||
);
|
||||
this.effects.muzzle(x, y, angle, COLORS.turretBullet);
|
||||
this.services.audio.play('turret-shot');
|
||||
});
|
||||
if (!running) {
|
||||
this.effects.burst(turret.x, turret.y, COLORS.turret, 14);
|
||||
turret.destroy();
|
||||
this.turrets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
for (const pod of this.pods.getChildren().slice()) {
|
||||
if (!pod.tick(now)) pod.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
updateMortars(now) {
|
||||
for (let i = this.mortars.length - 1; i >= 0; i -= 1) {
|
||||
const { marker, cfg } = this.mortars[i];
|
||||
if (!marker.isDue(now)) {
|
||||
marker.update(now);
|
||||
continue;
|
||||
}
|
||||
marker.destroy();
|
||||
this.mortars.splice(i, 1);
|
||||
this.effects.explosion(marker.x, marker.y, cfg.radius, COLORS.mortar);
|
||||
this.services.audio.play('explosion-small');
|
||||
if (inCircle(this.player, marker, cfg.radius, PLAYER.radius)) this.damagePlayer(cfg.damage);
|
||||
}
|
||||
}
|
||||
|
||||
updateProjectiles() {
|
||||
this.playerBullets.update(this.worldRect);
|
||||
this.enemyBullets.update(this.worldRect);
|
||||
}
|
||||
|
||||
drawHealthBars() {
|
||||
const g = this.healthBars;
|
||||
g.clear();
|
||||
for (const enemy of this.enemies.getChildren()) {
|
||||
if (!enemy.alive || (enemy.hp >= enemy.maxHp && !enemy.isElite)) continue;
|
||||
const width = enemy.isElite ? 72 : 30;
|
||||
const height = enemy.isElite ? 7 : 4;
|
||||
const x = enemy.x - width / 2;
|
||||
const y = enemy.y - enemy.type.radius - 12;
|
||||
g.fillStyle(0x000000, 0.6);
|
||||
g.fillRect(x - 1, y - 1, width + 2, height + 2);
|
||||
g.fillStyle(enemy.isElite ? COLORS.gold : COLORS.hpBarLow, 1);
|
||||
g.fillRect(x, y, (width * enemy.hp) / enemy.maxHp, height);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {{x: number, y: number}} Mausposition in Weltkoordinaten. */
|
||||
aimPoint() {
|
||||
const point = this.input.activePointer.positionToCamera(this.cameras.main);
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Gegner
|
||||
|
||||
spawnGroup(group, plan) {
|
||||
let point = spawnPointOnEdge(group.edge, GAME.world, GAME.spawnMargin, this.rng);
|
||||
if (distance(point, this.player) < MIN_SPAWN_DISTANCE) {
|
||||
point = spawnPointOnEdge(OPPOSITE_EDGE[group.edge], GAME.world, GAME.spawnMargin, this.rng);
|
||||
}
|
||||
const margin = GAME.spawnMargin;
|
||||
for (let i = 0; i < group.count; i += 1) {
|
||||
const x = clamp(point.x + randFloat(this.rng, -SPAWN_SPREAD, SPAWN_SPREAD), margin, GAME.world.width - margin);
|
||||
const y = clamp(point.y + randFloat(this.rng, -SPAWN_SPREAD, SPAWN_SPREAD), margin, GAME.world.height - margin);
|
||||
this.spawnEnemy(group.type, x, y, { hpMultiplier: plan.hpMultiplier, speedMultiplier: plan.speedMultiplier });
|
||||
}
|
||||
const type = ENEMY_TYPES[group.type];
|
||||
if (type.tier === 'elite') {
|
||||
this.announce(WAVE.elite[type.id] ?? type.name, '', 'elite');
|
||||
this.services.audio.play('alarm');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} typeId
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {{hpMultiplier?: number, speedMultiplier?: number, parent?: Enemy}} [options]
|
||||
* @returns {Enemy}
|
||||
*/
|
||||
spawnEnemy(typeId, x, y, options = {}) {
|
||||
const enemy = new Enemy(this, x, y, typeId, { now: this.gameTime, rng: this.rng, ...options });
|
||||
this.enemies.add(enemy);
|
||||
enemy.initBody();
|
||||
enemy.setAlpha(0);
|
||||
this.tweens.add({ targets: enemy, alpha: 1, duration: 250 });
|
||||
return enemy;
|
||||
}
|
||||
|
||||
spawnBrood(mother, count) {
|
||||
const childType = mother.type.brood.childType;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const angle = this.rng() * Math.PI * 2;
|
||||
const reach = mother.type.radius + ENEMY_TYPES[childType].radius;
|
||||
const child = this.spawnEnemy(childType, mother.x + Math.cos(angle) * reach, mother.y + Math.sin(angle) * reach, {
|
||||
hpMultiplier: mother.multipliers.hp,
|
||||
speedMultiplier: mother.multipliers.speed,
|
||||
parent: mother,
|
||||
});
|
||||
child.setAlpha(1);
|
||||
mother.childCount += 1;
|
||||
}
|
||||
this.effects.burst(mother.x, mother.y, COLORS.swarm, 10);
|
||||
this.services.audio.play('brood');
|
||||
}
|
||||
|
||||
scheduleMortar(x, y, cfg) {
|
||||
const now = this.gameTime;
|
||||
this.mortars.push({ marker: new GroundMarker(this, x, y, cfg.radius, COLORS.mortar, now, now + cfg.delayMs), cfg });
|
||||
this.services.audio.play('mortar');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Enemy} enemy
|
||||
* @param {number} amount
|
||||
* @param {number} hitX
|
||||
* @param {number} hitY
|
||||
*/
|
||||
damageEnemy(enemy, amount, hitX, hitY) {
|
||||
if (!enemy.alive) return;
|
||||
const killed = enemy.takeDamage(amount);
|
||||
this.effects.flash(enemy);
|
||||
this.effects.burst(hitX, hitY, FACTION_COLORS[enemy.type.faction], 4);
|
||||
this.services.audio.play(enemy.type.faction === 'legion' ? 'hit-metal' : 'hit-flesh');
|
||||
if (killed) this.killEnemy(enemy);
|
||||
}
|
||||
|
||||
/** @param {Enemy} enemy */
|
||||
killEnemy(enemy) {
|
||||
const now = this.gameTime;
|
||||
const previousMultiplier = this.score.multiplierAt(now);
|
||||
const result = this.score.registerKill(enemy.type.points, now);
|
||||
const label = result.multiplier > 1 ? `+${result.awarded} ${formatMultiplier(result.multiplier)}` : `+${result.awarded}`;
|
||||
this.effects.floatingText(enemy.x, enemy.y - enemy.type.radius, label);
|
||||
if (result.multiplier > previousMultiplier) {
|
||||
// Neue Multiplikatorstufe deutlich am Präsidenten melden – Belohnung soll man sehen.
|
||||
this.effects.floatingText(this.player.x, this.player.y - 48, `SERIE ${formatMultiplier(result.multiplier)}`, '#ffe28a', 26);
|
||||
}
|
||||
this.effects.burst(enemy.x, enemy.y, FACTION_COLORS[enemy.type.faction], enemy.isElite ? 40 : 12);
|
||||
if (enemy.isElite) this.effects.explosion(enemy.x, enemy.y, enemy.type.radius * 3, COLORS.gold);
|
||||
if (enemy.parentEnemy) enemy.parentEnemy.childCount = Math.max(0, enemy.parentEnemy.childCount - 1);
|
||||
this.services.audio.play(enemy.type.faction === 'legion' ? 'death-legion' : 'death-swarm');
|
||||
|
||||
// Körper sofort deaktivieren (zählt nicht mehr als lebend, keine Kollisionen),
|
||||
// die Grafik aber kurz ausblenden statt sie hart verschwinden zu lassen.
|
||||
enemy.disableBody(true, false);
|
||||
this.tweens.add({
|
||||
targets: enemy,
|
||||
alpha: 0,
|
||||
scale: 0.4,
|
||||
duration: 220,
|
||||
onComplete: () => enemy.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Kollisionen
|
||||
|
||||
onBulletHitEnemy(bullet, enemy) {
|
||||
if (!bullet.active || !enemy.alive) return;
|
||||
this.playerBullets.kill(bullet);
|
||||
this.damageEnemy(enemy, bullet.damage, bullet.x, bullet.y);
|
||||
}
|
||||
|
||||
onEnemyBulletHitPlayer(bullet) {
|
||||
if (!bullet.active || this.isOver) return;
|
||||
this.enemyBullets.kill(bullet);
|
||||
this.damagePlayer(bullet.damage);
|
||||
}
|
||||
|
||||
/** @param {Enemy} enemy */
|
||||
onEnemyContact(enemy) {
|
||||
const now = this.gameTime;
|
||||
if (!enemy.alive || this.isOver || this.player.isDodging(now)) return;
|
||||
if (now - enemy.lastContactAt < enemy.type.contactCooldownMs) return;
|
||||
enemy.lastContactAt = now;
|
||||
this.damagePlayer(enemy.type.contactDamage);
|
||||
}
|
||||
|
||||
/** @param {SupplyPod} pod */
|
||||
onPickup(pod) {
|
||||
if (pod.collected || this.isOver) return;
|
||||
pod.collected = true;
|
||||
this.player.heal(pod.cfg.heal);
|
||||
this.player.refillAmmo();
|
||||
this.lowAmmoHinted = false;
|
||||
this.feedback(ORBITAL_TEXT.supplyPickup(pod.cfg.heal), 'ok');
|
||||
this.effects.burst(pod.x, pod.y, COLORS.supply, 20);
|
||||
this.services.audio.play('pickup');
|
||||
pod.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} amount
|
||||
* @returns {number} Tatsächlich erlittener Schaden.
|
||||
*/
|
||||
damagePlayer(amount) {
|
||||
const dealt = this.player.takeDamage(amount, this.gameTime);
|
||||
if (dealt <= 0) return 0;
|
||||
this.effects.flash(this.player);
|
||||
this.effects.shake('hit');
|
||||
this.services.audio.play('player-hit');
|
||||
if (SCORING.damageBreaksStreak) this.score.breakStreak();
|
||||
this.events.emit('player-damaged', dealt);
|
||||
if (!this.player.alive) this.onPlayerDeath();
|
||||
return dealt;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Orbital-Befehle
|
||||
|
||||
onOrbitalFeedback(kind, command, remainingMs) {
|
||||
const audio = this.services.audio;
|
||||
if (kind === 'step') audio.play('combo-step');
|
||||
else if (kind === 'fail') {
|
||||
audio.play('combo-fail');
|
||||
this.feedback(ORBITAL_TEXT.invalid, 'warn');
|
||||
} else if (kind === 'notReady') {
|
||||
audio.play('combo-fail');
|
||||
this.feedback(ORBITAL_TEXT.notReady(command.shortName, Math.ceil(remainingMs / 1000)), 'warn');
|
||||
} else if (kind === 'deploy') {
|
||||
audio.play('orbital-call');
|
||||
this.feedback(ORBITAL_TEXT.requested(command.shortName), 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
onOrbitalImpact(command, x, y) {
|
||||
const center = { x, y };
|
||||
this.effects.explosion(x, y, command.radius, ORBITAL_COLORS[command.id] ?? COLORS.gold);
|
||||
this.services.audio.play(command.id === 'strike' ? 'explosion-big' : 'explosion-small');
|
||||
|
||||
for (const enemy of this.enemies.getChildren().slice()) {
|
||||
if (enemy.alive && inCircle(enemy, center, command.radius, enemy.type.radius)) {
|
||||
this.damageEnemy(enemy, command.damage, enemy.x, enemy.y);
|
||||
}
|
||||
}
|
||||
if (command.playerDamage > 0 && inCircle(this.player, center, command.radius, PLAYER.radius)) {
|
||||
if (this.damagePlayer(command.playerDamage) > 0 && command.id === 'strike') {
|
||||
this.feedback(ORBITAL_TEXT.selfHit, 'warn');
|
||||
}
|
||||
}
|
||||
if (command.id === 'supply' && !this.isOver) {
|
||||
const pod = new SupplyPod(this, x, y, command, this.gameTime);
|
||||
this.pods.add(pod);
|
||||
pod.initBody();
|
||||
}
|
||||
if (command.id === 'turret' && !this.isOver) {
|
||||
this.turrets.push(new Turret(this, x, y, command, this.gameTime));
|
||||
this.feedback(ORBITAL_TEXT.turretOnline, 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Wellen
|
||||
|
||||
onWaveStart(plan) {
|
||||
const factionKey = plan.factions.length > 1 ? 'mixed' : plan.factions[0];
|
||||
this.announce(WAVE.incoming(plan.number), WAVE.factionLine[factionKey], 'wave');
|
||||
this.services.audio.play('wave-start');
|
||||
}
|
||||
|
||||
onWaveCleared(waveNumber) {
|
||||
const bonus = this.score.addBonus(waveBonus(waveNumber));
|
||||
const healed = this.player.heal(PLAYER.waveClearHeal);
|
||||
const lines = [WAVE.bonus(bonus)];
|
||||
if (healed > 0) lines.push(WAVE.heal(healed));
|
||||
lines.push(`„${this.nextPropaganda()}“`);
|
||||
this.announce(WAVE.cleared(waveNumber), lines.join('\n'), 'cleared');
|
||||
this.services.audio.play('wave-cleared');
|
||||
}
|
||||
|
||||
/** Propaganda ohne direkte Wiederholung: Stapel mischen und nacheinander ziehen. */
|
||||
nextPropaganda() {
|
||||
if (this.propagandaDeck.length === 0) {
|
||||
this.propagandaDeck = PROPAGANDA.slice();
|
||||
for (let i = this.propagandaDeck.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(this.rng() * (i + 1));
|
||||
[this.propagandaDeck[i], this.propagandaDeck[j]] = [this.propagandaDeck[j], this.propagandaDeck[i]];
|
||||
}
|
||||
}
|
||||
return this.propagandaDeck.pop();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Meldungen
|
||||
|
||||
/**
|
||||
* Große Einblendung im HUD.
|
||||
* @param {string} title
|
||||
* @param {string} subtitle
|
||||
* @param {'wave'|'cleared'|'elite'} kind
|
||||
*/
|
||||
announce(title, subtitle, kind) {
|
||||
this.events.emit('announce', { title, subtitle, kind });
|
||||
}
|
||||
|
||||
/**
|
||||
* Kurze Statuszeile im HUD.
|
||||
* @param {string} text
|
||||
* @param {'ok'|'warn'} tone
|
||||
*/
|
||||
feedback(text, tone) {
|
||||
this.events.emit('feedback', { text, tone });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Pause und Ende
|
||||
|
||||
openPause() {
|
||||
if (this.isOver || !this.scene.isActive()) return;
|
||||
this.scene.pause();
|
||||
this.scene.launch('Pause');
|
||||
}
|
||||
|
||||
autoPause() {
|
||||
// Fenster verliert den Fokus: lieber pausieren, als den Präsidenten allein zu lassen.
|
||||
if (this.scene.isActive() && !this.isOver) this.openPause();
|
||||
}
|
||||
|
||||
onPlayerDeath() {
|
||||
if (this.isOver) return;
|
||||
this.isOver = true;
|
||||
this.services.audio.play('player-death');
|
||||
this.services.audio.stopMusic();
|
||||
this.effects.explosion(this.player.x, this.player.y, 120, COLORS.crimson);
|
||||
this.player.setTint(0x555555);
|
||||
this.tweens.add({ targets: this.player, alpha: 0.3, duration: FX.deathDelayMs });
|
||||
this.time.delayedCall(FX.deathDelayMs, () => this.finish());
|
||||
}
|
||||
|
||||
finish() {
|
||||
const snapshot = this.score.snapshot(this.gameTime);
|
||||
const stats = {
|
||||
score: snapshot.score,
|
||||
wave: Math.max(1, this.waves.waveNumber),
|
||||
kills: snapshot.kills,
|
||||
bestStreak: snapshot.bestStreak,
|
||||
durationMs: this.gameTime,
|
||||
runToken: this.runToken,
|
||||
};
|
||||
this.scene.stop('Hud');
|
||||
this.scene.start('GameOver', stats);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.orbital?.destroy();
|
||||
this.game.events.off('blur', this.autoPause, this);
|
||||
this.input.setDefaultCursor('default');
|
||||
this.services.audio.stopMusic();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ HUD-Daten
|
||||
|
||||
/** Zustand für das HUD (wird jeden Frame von HudScene gelesen). */
|
||||
hudState() {
|
||||
const now = this.gameTime;
|
||||
const { player } = this;
|
||||
const weapon = player.weapon;
|
||||
return {
|
||||
hp: player.hp,
|
||||
maxHp: player.maxHp,
|
||||
dodge: player.dodgeReadiness(now),
|
||||
weaponIndex: player.weaponIndex,
|
||||
weapons: player.weapons.map((w) => ({ name: w.def.shortName, empty: w.isEmpty })),
|
||||
weaponName: weapon.def.name,
|
||||
magazine: weapon.magazine,
|
||||
magazineSize: weapon.def.magazineSize,
|
||||
reserve: weapon.reserve,
|
||||
reloading: weapon.isReloading,
|
||||
reloadProgress: weapon.reloadProgress(now),
|
||||
score: this.score.snapshot(now),
|
||||
wave: {
|
||||
number: this.waves.waveNumber,
|
||||
state: this.waves.state,
|
||||
enemies: this.waves.pendingEnemies + this.enemies.countActive(true),
|
||||
nextInMs: this.waves.intermissionRemaining(now),
|
||||
},
|
||||
orbital: this.orbital.hudState(now),
|
||||
over: this.isOver,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* HUD über der Spielszene: Leben, Munition, Welle, Punkte, Serie, Rolle,
|
||||
* Orbital-Befehle mit Abklingzeiten, Einblendungen und Fadenkreuz.
|
||||
*
|
||||
* Läuft als eigene Szene, damit das HUD nicht mit der Kamera mitscrollt und nicht
|
||||
* vom Bildschirmwackeln betroffen ist. Texte werden nur bei Änderungen neu gesetzt
|
||||
* (setText rendert intern eine Textur neu und ist vergleichsweise teuer).
|
||||
* @module scenes/HudScene
|
||||
*/
|
||||
|
||||
import { COLORS, DIRECTION_SYMBOLS, FX, ORBITAL, PLAYER, WEAPONS } from '../config.js';
|
||||
import { ORBITAL_TEXT, PRESIDENT, WAVE } from '../content/texts.js';
|
||||
import { formatCooldown, formatMultiplier, formatScore } from '../logic/format.js';
|
||||
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
|
||||
import { textStyle, UI, uiStyle } from '../ui/theme.js';
|
||||
|
||||
const MARGIN = 24;
|
||||
const HP_BAR = Object.freeze({ width: 280, height: 18 });
|
||||
const DODGE_BAR = Object.freeze({ width: 120, height: 6 });
|
||||
const RELOAD_BAR = Object.freeze({ width: 220, height: 6 });
|
||||
const STREAK_BAR = Object.freeze({ width: 140, height: 5 });
|
||||
const ORBITAL_PANEL = Object.freeze({ width: 350, rowHeight: 34, arrowsX: 150 });
|
||||
const SLOT_GAP = 22;
|
||||
/** Ab diesem Anteil Leben wird der Balken rot und der Bildschirmrand pulsiert. */
|
||||
const LOW_HP_RATIO = 0.3;
|
||||
const LOW_HP_BORDER = 16;
|
||||
const LOW_HP_PULSE_MS = 140;
|
||||
/** Randpfeile für Gegner außerhalb des Bildes. */
|
||||
const INDICATOR = Object.freeze({ edge: 22, size: 9, eliteSize: 14, max: 30 });
|
||||
const FEEDBACK_MS = 2200;
|
||||
|
||||
export class HudScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Hud');
|
||||
}
|
||||
|
||||
create() {
|
||||
this.gameScene = this.scene.get('Game');
|
||||
const { width, height } = this.scale;
|
||||
/** Letzter gesetzter Text je Textobjekt (nicht `this.cache` – das ist Phasers Cache). */
|
||||
this.textCache = new Map();
|
||||
this.colorCache = new Map();
|
||||
this.g = this.add.graphics();
|
||||
|
||||
// Oben links: Leben und Rolle
|
||||
this.add.text(MARGIN, 14, PRESIDENT.toUpperCase(), uiStyle({ fontSize: '15px', color: UI.colors.gold }));
|
||||
this.hpText = this.add.text(MARGIN + HP_BAR.width + 12, 34, '', uiStyle({ fontSize: '17px' }));
|
||||
this.add.text(MARGIN, 64, 'ROLLE', uiStyle({ fontSize: '13px', color: UI.colors.muted }));
|
||||
|
||||
// Oben Mitte: Welle
|
||||
this.waveTitle = this.add.text(width / 2, 12, '', textStyle({ fontSize: '28px', color: UI.colors.gold })).setOrigin(0.5, 0);
|
||||
this.waveInfo = this.add.text(width / 2, 48, '', uiStyle({ fontSize: '18px' })).setOrigin(0.5, 0);
|
||||
|
||||
// Oben rechts: Punkte und Multiplikator
|
||||
this.scoreText = this.add.text(width - MARGIN, 10, '', textStyle({ fontSize: '32px' })).setOrigin(1, 0);
|
||||
this.multText = this.add.text(width - MARGIN, 50, '', uiStyle({ fontSize: '20px', color: UI.colors.gold })).setOrigin(1, 0);
|
||||
|
||||
// Unten links: Waffe
|
||||
// Waffennamen stehen fest – einmal setzen und nach tatsächlicher Breite anordnen.
|
||||
let slotX = MARGIN;
|
||||
this.slotTexts = PLAYER.weaponSlots.map((id, i) => {
|
||||
const slot = this.add.text(slotX, height - 132, `${i + 1} ${WEAPONS[id].shortName}`, uiStyle({ fontSize: '15px' }));
|
||||
slotX += slot.width + SLOT_GAP;
|
||||
return slot;
|
||||
});
|
||||
this.weaponName = this.add.text(MARGIN, height - 106, '', uiStyle({ fontSize: '18px', color: UI.colors.gold }));
|
||||
this.ammoText = this.add.text(MARGIN, height - 80, '', uiStyle({ fontSize: '34px' }));
|
||||
|
||||
// Unten rechts: Orbital-Befehle
|
||||
const panelX = width - MARGIN - ORBITAL_PANEL.width;
|
||||
const panelTop = height - MARGIN - ORBITAL.commands.length * ORBITAL_PANEL.rowHeight - 34;
|
||||
this.orbitalPanel = { x: panelX, y: panelTop };
|
||||
this.orbitalTitle = this.add.text(panelX + 10, panelTop + 7, ORBITAL_TEXT.panelTitle, uiStyle({ fontSize: '15px', color: UI.colors.gold }));
|
||||
this.orbitalRows = ORBITAL.commands.map((command, i) => {
|
||||
const y = panelTop + 34 + i * ORBITAL_PANEL.rowHeight;
|
||||
return {
|
||||
name: this.add.text(panelX + 10, y + 1, command.shortName, uiStyle({ fontSize: '15px' })),
|
||||
arrows: command.sequence.map((dir, j) =>
|
||||
// Pfeile aus einer Symbolschrift, damit sie überall gleich breit und gut erkennbar sind.
|
||||
this.add.text(panelX + ORBITAL_PANEL.arrowsX + j * 24, y - 3, DIRECTION_SYMBOLS[dir], textStyle({ fontSize: '19px', fontFamily: UI.symbolFont })),
|
||||
),
|
||||
cooldown: this.add.text(panelX + ORBITAL_PANEL.width - 10, y, '', uiStyle({ fontSize: '16px', color: UI.colors.muted })).setOrigin(1, 0),
|
||||
y,
|
||||
};
|
||||
});
|
||||
|
||||
// Einblendungen
|
||||
this.bannerTitle = this.add.text(width / 2, 150, '', textStyle({ fontSize: '46px', color: UI.colors.gold, strokeThickness: 8 })).setOrigin(0.5).setAlpha(0);
|
||||
this.bannerSubtitle = this.add
|
||||
.text(width / 2, 186, '', { ...uiStyle({ fontSize: '22px', align: 'center' }), wordWrap: { width: 900 } })
|
||||
.setOrigin(0.5, 0)
|
||||
.setAlpha(0);
|
||||
this.feedbackText = this.add.text(width / 2, height - 170, '', uiStyle({ fontSize: '20px' })).setOrigin(0.5).setAlpha(0);
|
||||
|
||||
this.vignette = this.add.rectangle(0, 0, width, height, COLORS.crimson, 0).setOrigin(0);
|
||||
this.crosshair = this.add.image(0, 0, TEXTURE_KEYS.crosshair);
|
||||
|
||||
const events = this.gameScene.events;
|
||||
events.on('announce', this.showBanner, this);
|
||||
events.on('feedback', this.showFeedback, this);
|
||||
events.on('player-damaged', this.flashDamage, this);
|
||||
this.events.once('shutdown', () => {
|
||||
events.off('announce', this.showBanner, this);
|
||||
events.off('feedback', this.showFeedback, this);
|
||||
events.off('player-damaged', this.flashDamage, this);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Text nur, wenn er sich geändert hat.
|
||||
* @param {Phaser.GameObjects.Text} target
|
||||
* @param {string} value
|
||||
*/
|
||||
setText(target, value) {
|
||||
if (this.textCache.get(target) !== value) {
|
||||
this.textCache.set(target, value);
|
||||
target.setText(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Textfarbe nur bei Änderung (setColor rendert den Text sonst neu).
|
||||
* @param {Phaser.GameObjects.Text} target
|
||||
* @param {string} color
|
||||
*/
|
||||
setColor(target, color) {
|
||||
if (this.colorCache.get(target) !== color) {
|
||||
this.colorCache.set(target, color);
|
||||
target.setColor(color);
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
const gameScene = this.gameScene;
|
||||
if (!gameScene?.player || !(gameScene.sys.isActive() || gameScene.sys.isPaused())) return;
|
||||
const s = gameScene.hudState();
|
||||
const paused = gameScene.sys.isPaused();
|
||||
const g = this.g;
|
||||
g.clear();
|
||||
|
||||
// Leben
|
||||
const hpRatio = s.hp / s.maxHp;
|
||||
this.bar(g, MARGIN, 36, HP_BAR.width, HP_BAR.height, hpRatio, hpRatio <= LOW_HP_RATIO ? COLORS.hpBarLow : COLORS.hpBar);
|
||||
this.setText(this.hpText, `${Math.ceil(s.hp)} / ${s.maxHp}`);
|
||||
this.bar(g, MARGIN + 56, 70, DODGE_BAR.width, DODGE_BAR.height, s.dodge, s.dodge >= 1 ? COLORS.gold : COLORS.muted);
|
||||
|
||||
// Welle
|
||||
this.setText(this.waveTitle, s.wave.number > 0 ? WAVE.incoming(s.wave.number) : WAVE.preparing);
|
||||
const info = s.wave.state === 'intermission' ? WAVE.nextIn(Math.ceil(s.wave.nextInMs / 1000)) : WAVE.remaining(s.wave.enemies);
|
||||
this.setText(this.waveInfo, info);
|
||||
|
||||
// Punkte
|
||||
this.setText(this.scoreText, formatScore(s.score.score));
|
||||
let streakLabel = '';
|
||||
if (s.score.multiplier > 1) streakLabel = `${formatMultiplier(s.score.multiplier)} · Serie ${s.score.streak}`;
|
||||
else if (s.score.streak >= 2) streakLabel = `Serie ${s.score.streak}`;
|
||||
this.setText(this.multText, streakLabel);
|
||||
if (s.score.streakTimeLeft > 0) {
|
||||
this.bar(g, this.scale.width - MARGIN - STREAK_BAR.width, 80, STREAK_BAR.width, STREAK_BAR.height, s.score.streakTimeLeft, COLORS.gold);
|
||||
}
|
||||
|
||||
// Waffe
|
||||
s.weapons.forEach((weapon, i) => {
|
||||
const active = i === s.weaponIndex;
|
||||
this.setColor(this.slotTexts[i], active ? UI.colors.gold : weapon.empty ? UI.colors.danger : UI.colors.muted);
|
||||
});
|
||||
this.setText(this.weaponName, s.weaponName);
|
||||
const reserve = Number.isFinite(s.reserve) ? String(s.reserve) : '∞';
|
||||
this.setText(this.ammoText, s.reloading ? `NACHLADEN · ${reserve}` : `${s.magazine} / ${s.magazineSize} · ${reserve}`);
|
||||
this.setColor(this.ammoText, s.magazine === 0 && !s.reloading ? UI.colors.danger : UI.colors.cream);
|
||||
if (s.reloading) this.bar(g, MARGIN, this.scale.height - 36, RELOAD_BAR.width, RELOAD_BAR.height, s.reloadProgress, COLORS.gold);
|
||||
|
||||
this.drawOrbitalPanel(g, s.orbital);
|
||||
this.drawOffscreenIndicators(g, gameScene);
|
||||
if (hpRatio <= LOW_HP_RATIO && !s.over) {
|
||||
// Pulsierender Rand statt Dauerrot: auffällig, verdeckt aber nichts.
|
||||
const pulse = 0.35 + 0.35 * Math.sin(this.time.now / LOW_HP_PULSE_MS);
|
||||
g.lineStyle(LOW_HP_BORDER, COLORS.crimson, pulse);
|
||||
g.strokeRect(LOW_HP_BORDER / 2, LOW_HP_BORDER / 2, this.scale.width - LOW_HP_BORDER, this.scale.height - LOW_HP_BORDER);
|
||||
}
|
||||
|
||||
// Fadenkreuz folgt der Maus; in der Pause und nach dem Tod ausgeblendet.
|
||||
const pointer = this.input.activePointer;
|
||||
this.crosshair.setPosition(pointer.x, pointer.y);
|
||||
this.crosshair.setVisible(!paused && !s.over);
|
||||
}
|
||||
|
||||
drawOrbitalPanel(g, orbital) {
|
||||
const { x, y } = this.orbitalPanel;
|
||||
const height = 34 + orbital.commands.length * ORBITAL_PANEL.rowHeight;
|
||||
g.fillStyle(COLORS.navy, 0.72);
|
||||
g.fillRect(x, y, ORBITAL_PANEL.width, height);
|
||||
g.lineStyle(2, orbital.active ? COLORS.gold : COLORS.muted, orbital.active ? 1 : 0.5);
|
||||
g.strokeRect(x, y, ORBITAL_PANEL.width, height);
|
||||
|
||||
orbital.commands.forEach((command, i) => {
|
||||
const row = this.orbitalRows[i];
|
||||
// Dünner Ladebalken unter der Zeile
|
||||
if (!command.ready) {
|
||||
g.fillStyle(COLORS.muted, 0.35);
|
||||
g.fillRect(x + 10, row.y + 22, ORBITAL_PANEL.width - 20, 3);
|
||||
g.fillStyle(COLORS.gold, 0.9);
|
||||
g.fillRect(x + 10, row.y + 22, (ORBITAL_PANEL.width - 20) * command.cooldownProgress, 3);
|
||||
}
|
||||
this.setColor(row.name, command.ready ? UI.colors.cream : UI.colors.muted);
|
||||
row.arrows.forEach((arrow, j) => {
|
||||
const done = orbital.active && j < command.progress;
|
||||
this.setColor(arrow, done ? UI.colors.gold : command.ready ? UI.colors.cream : UI.colors.muted);
|
||||
arrow.setScale(done ? 1.2 : 1);
|
||||
});
|
||||
this.setText(row.cooldown, command.ready ? 'bereit' : `${formatCooldown(command.remainingMs)} s`);
|
||||
this.setColor(row.cooldown, command.ready ? UI.colors.ok : UI.colors.muted);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pfeile am Bildschirmrand für Gegner außerhalb des sichtbaren Bereichs. Die Arena ist
|
||||
* größer als der Bildschirm – ohne Hinweis kämen Angriffe scheinbar aus dem Nichts.
|
||||
* @param {Phaser.GameObjects.Graphics} g
|
||||
* @param {Phaser.Scene & {enemies: Phaser.Physics.Arcade.Group}} gameScene
|
||||
*/
|
||||
drawOffscreenIndicators(g, gameScene) {
|
||||
const view = gameScene.cameras.main.worldView;
|
||||
const cx = this.scale.width / 2;
|
||||
const cy = this.scale.height / 2;
|
||||
let shown = 0;
|
||||
for (const enemy of gameScene.enemies.getChildren()) {
|
||||
if (!enemy.alive || view.contains(enemy.x, enemy.y)) continue;
|
||||
const dx = enemy.x - view.x - cx;
|
||||
const dy = enemy.y - view.y - cy;
|
||||
// Schnittpunkt der Linie Bildmitte→Gegner mit dem (eingerückten) Bildschirmrand.
|
||||
const reach = Math.min((cx - INDICATOR.edge) / Math.abs(dx || 1e-6), (cy - INDICATOR.edge) / Math.abs(dy || 1e-6));
|
||||
const x = cx + dx * reach;
|
||||
const y = cy + dy * reach;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const size = enemy.isElite ? INDICATOR.eliteSize : INDICATOR.size;
|
||||
const color = enemy.isElite ? COLORS.gold : enemy.type.faction === 'swarm' ? COLORS.swarm : COLORS.legion;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
g.fillStyle(color, enemy.isElite ? 1 : 0.8);
|
||||
g.fillTriangle(
|
||||
x + cos * size,
|
||||
y + sin * size,
|
||||
x - cos * size * 0.6 - sin * size * 0.7,
|
||||
y - sin * size * 0.6 + cos * size * 0.7,
|
||||
x - cos * size * 0.6 + sin * size * 0.7,
|
||||
y - sin * size * 0.6 - cos * size * 0.7,
|
||||
);
|
||||
shown += 1;
|
||||
if (shown >= INDICATOR.max) break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Einfacher Balken mit dunklem Hintergrund. */
|
||||
bar(g, x, y, width, height, ratio, color) {
|
||||
g.fillStyle(0x000000, 0.55);
|
||||
g.fillRect(x - 2, y - 2, width + 4, height + 4);
|
||||
g.fillStyle(color, 1);
|
||||
g.fillRect(x, y, width * Math.max(0, Math.min(1, ratio)), height);
|
||||
}
|
||||
|
||||
/** @param {{title: string, subtitle: string, kind: string}} message */
|
||||
showBanner(message) {
|
||||
this.tweens.killTweensOf([this.bannerTitle, this.bannerSubtitle]);
|
||||
this.bannerTitle.setText(message.title).setColor(message.kind === 'elite' ? UI.colors.danger : UI.colors.gold);
|
||||
this.bannerSubtitle.setText(message.subtitle);
|
||||
this.bannerTitle.setAlpha(0).setScale(1.3);
|
||||
this.bannerSubtitle.setAlpha(0);
|
||||
this.tweens.add({ targets: this.bannerTitle, alpha: 1, scale: 1, duration: 220, ease: 'Back.easeOut' });
|
||||
this.tweens.add({ targets: this.bannerSubtitle, alpha: 1, duration: 300 });
|
||||
this.tweens.add({
|
||||
targets: [this.bannerTitle, this.bannerSubtitle],
|
||||
alpha: 0,
|
||||
delay: FX.announcementMs,
|
||||
duration: 500,
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {{text: string, tone: 'ok'|'warn'}} message */
|
||||
showFeedback(message) {
|
||||
this.tweens.killTweensOf(this.feedbackText);
|
||||
this.feedbackText.setText(message.text).setColor(message.tone === 'warn' ? UI.colors.danger : UI.colors.cyan);
|
||||
this.feedbackText.setAlpha(1);
|
||||
this.tweens.add({ targets: this.feedbackText, alpha: 0, delay: FEEDBACK_MS, duration: 400 });
|
||||
}
|
||||
|
||||
flashDamage() {
|
||||
this.tweens.killTweensOf(this.vignette);
|
||||
this.vignette.setFillStyle(COLORS.crimson, 0.28);
|
||||
this.tweens.add({ targets: this.vignette, fillAlpha: 0, duration: 320 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Einfache Informationsseiten im Canvas: Ehrentafel, Steuerung, Credits.
|
||||
* Alle kehren mit Esc, Enter oder Klick auf „Zurück“ ins Hauptmenü zurück.
|
||||
* @module scenes/InfoScenes
|
||||
*/
|
||||
|
||||
import { ORBITAL } from '../config.js';
|
||||
import { CONTROLS, CREDITS, HIGHSCORES } from '../content/texts.js';
|
||||
import { formatScore, formatSequence } from '../logic/format.js';
|
||||
import { oncePerEvent } from '../systems/keyboard.js';
|
||||
import { textStyle, UI, uiStyle } from '../ui/theme.js';
|
||||
|
||||
/**
|
||||
* Gemeinsamer Rahmen: Hintergrund, Titel und Zurück-Schaltfläche.
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {string} title
|
||||
* @param {string} backLabel
|
||||
*/
|
||||
function frame(scene, title, backLabel) {
|
||||
const { width, height } = scene.scale;
|
||||
scene.input.setDefaultCursor('default');
|
||||
const bg = scene.add.graphics();
|
||||
bg.fillGradientStyle(0x0d1321, 0x0d1321, 0x1b2438, 0x1b2438, 1);
|
||||
bg.fillRect(0, 0, width, height);
|
||||
bg.lineStyle(3, 0xf2c14e, 1);
|
||||
bg.strokeRect(40, 30, width - 80, height - 60);
|
||||
|
||||
scene.add.text(width / 2, 70, title, textStyle({ fontSize: '38px', color: UI.colors.gold })).setOrigin(0.5);
|
||||
const back = scene.add
|
||||
.text(width / 2, height - 70, backLabel, textStyle({ fontSize: '22px' }))
|
||||
.setOrigin(0.5)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
back.on('pointerover', () => back.setColor(UI.colors.gold));
|
||||
back.on('pointerout', () => back.setColor(UI.colors.cream));
|
||||
const leave = () => scene.scene.start('Menu');
|
||||
back.on('pointerup', leave);
|
||||
const leaveByKey = oncePerEvent((event) => {
|
||||
if (!event.repeat) leave();
|
||||
});
|
||||
scene.input.keyboard.on('keydown-ESC', leaveByKey);
|
||||
scene.input.keyboard.on('keydown-ENTER', leaveByKey);
|
||||
}
|
||||
|
||||
export class HighscoresScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Highscores');
|
||||
}
|
||||
|
||||
create() {
|
||||
frame(this, HIGHSCORES.title, HIGHSCORES.back);
|
||||
const { width } = this.scale;
|
||||
this.statusText = this.add.text(width / 2, 300, HIGHSCORES.loading, uiStyle({ fontSize: '22px', color: UI.colors.muted })).setOrigin(0.5);
|
||||
this.registry
|
||||
.get('services')
|
||||
.api.fetchHighscores()
|
||||
.then((result) => {
|
||||
if (!this.sys.isActive()) return;
|
||||
if (!result.ok) this.statusText.setText(HIGHSCORES.error);
|
||||
else if (result.highscores.length === 0) this.statusText.setText(HIGHSCORES.empty);
|
||||
else {
|
||||
this.statusText.destroy();
|
||||
this.renderTable(result.highscores);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {import('../systems/ApiClient.js').HighscoreEntry[]} entries */
|
||||
renderTable(entries) {
|
||||
const columns = { rank: 280, name: 360, score: 900, wave: 1000 };
|
||||
const top = 130;
|
||||
const rowHeight = 40;
|
||||
const header = uiStyle({ fontSize: '17px', color: UI.colors.muted });
|
||||
this.add.text(columns.rank, top, HIGHSCORES.columns.rank, header);
|
||||
this.add.text(columns.name, top, HIGHSCORES.columns.name, header);
|
||||
this.add.text(columns.score, top, HIGHSCORES.columns.score, header).setOrigin(1, 0);
|
||||
this.add.text(columns.wave, top, HIGHSCORES.columns.wave, header).setOrigin(1, 0);
|
||||
entries.forEach((entry, i) => {
|
||||
const y = top + 40 + i * rowHeight;
|
||||
const color = i === 0 ? UI.colors.gold : UI.colors.cream;
|
||||
const style = uiStyle({ fontSize: '24px', color });
|
||||
this.add.text(columns.rank, y, `${entry.rank}.`, style);
|
||||
// Canvas-Text interpretiert kein HTML – Namen werden hier sicher als Text gezeichnet.
|
||||
this.add.text(columns.name, y, entry.name, style);
|
||||
this.add.text(columns.score, y, formatScore(entry.score), style).setOrigin(1, 0);
|
||||
this.add.text(columns.wave, y, String(entry.wave), style).setOrigin(1, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ControlsScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Controls');
|
||||
}
|
||||
|
||||
create() {
|
||||
frame(this, CONTROLS.title, CONTROLS.back);
|
||||
const keyStyle = uiStyle({ fontSize: '20px', color: UI.colors.gold });
|
||||
const valueStyle = uiStyle({ fontSize: '20px' });
|
||||
CONTROLS.rows.forEach(([key, action], i) => {
|
||||
const y = 130 + i * 38;
|
||||
this.add.text(560, y, key, keyStyle).setOrigin(1, 0);
|
||||
this.add.text(590, y, action, valueStyle);
|
||||
});
|
||||
const top = 130 + CONTROLS.rows.length * 38 + 30;
|
||||
this.add.text(this.scale.width / 2, top, CONTROLS.orbitalTitle, textStyle({ fontSize: '22px', color: UI.colors.gold })).setOrigin(0.5);
|
||||
ORBITAL.commands.forEach((command, i) => {
|
||||
const y = top + 40 + i * 36;
|
||||
this.add.text(560, y, command.name, valueStyle).setOrigin(1, 0);
|
||||
this.add.text(590, y, formatSequence(command.sequence, ' '), { ...keyStyle, fontFamily: UI.symbolFont });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CreditsScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Credits');
|
||||
}
|
||||
|
||||
create() {
|
||||
frame(this, CREDITS.title, CREDITS.back);
|
||||
const { width } = this.scale;
|
||||
let y = 118;
|
||||
for (const section of CREDITS.sections) {
|
||||
const heading = this.add.text(width / 2, y, section.heading, textStyle({ fontSize: '21px', color: UI.colors.gold })).setOrigin(0.5, 0);
|
||||
y += heading.height + 4;
|
||||
for (const line of section.lines) {
|
||||
const text = this.add
|
||||
.text(width / 2, y, line, { ...uiStyle({ fontSize: '17px', align: 'center' }), wordWrap: { width: width - 200 } })
|
||||
.setOrigin(0.5, 0);
|
||||
// Tatsächliche Höhe verwenden: umgebrochene Zeilen sind höher als eine Zeile.
|
||||
y += text.height + 2;
|
||||
}
|
||||
y += 12;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Hauptmenü mit Propaganda-Laufband. Beim allerersten Start wird zuerst der
|
||||
* Disclaimer angezeigt; das Menü ist erst danach bedienbar.
|
||||
* @module scenes/MenuScene
|
||||
*/
|
||||
|
||||
import { GAME } from '../config.js';
|
||||
import { MENU, PROPAGANDA, TAGLINE, TITLE } from '../content/texts.js';
|
||||
import { TEXTURE_KEYS } from '../systems/Placeholders.js';
|
||||
import { MenuList } from '../ui/MenuList.js';
|
||||
import { textStyle, UI, uiStyle } from '../ui/theme.js';
|
||||
|
||||
/** Geschwindigkeit des Laufbands in px/s. */
|
||||
const TICKER_SPEED = 90;
|
||||
/** Anzahl Scheinwerferkegel im Hintergrund. */
|
||||
const SEARCHLIGHTS = 3;
|
||||
|
||||
export class MenuScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Menu');
|
||||
}
|
||||
|
||||
create() {
|
||||
const { width, height } = this.scale;
|
||||
const services = this.registry.get('services');
|
||||
this.input.setDefaultCursor('default');
|
||||
|
||||
this.drawBackground(width, height);
|
||||
|
||||
this.add.image(width / 2, 150, TEXTURE_KEYS.emblem).setDisplaySize(150, 150);
|
||||
this.add
|
||||
.text(width / 2, 262, TITLE, textStyle({ fontSize: '68px', color: UI.colors.gold, strokeThickness: 10, stroke: '#7a0a12' }))
|
||||
.setOrigin(0.5);
|
||||
this.add.text(width / 2, 318, TAGLINE, textStyle({ fontSize: '24px' })).setOrigin(0.5);
|
||||
|
||||
this.menu = new MenuList(this, width / 2, 372, [
|
||||
{ label: MENU.start, onSelect: () => this.startMission() },
|
||||
{ label: MENU.highscores, onSelect: () => this.scene.start('Highscores') },
|
||||
{ label: MENU.controls, onSelect: () => this.scene.start('Controls') },
|
||||
{ label: MENU.disclaimer, onSelect: () => this.openDisclaimer(false) },
|
||||
{ label: MENU.credits, onSelect: () => this.scene.start('Credits') },
|
||||
], { spacing: 54 });
|
||||
|
||||
this.add.text(width / 2, height - 62, MENU.hint, uiStyle({ fontSize: '16px', color: UI.colors.muted })).setOrigin(0.5);
|
||||
this.createTicker(width, height);
|
||||
|
||||
// Reine Touch-Geräte (Handy, Tablet) haben weder WASD noch Maus – lieber gleich sagen.
|
||||
if (globalThis.matchMedia?.('(pointer: coarse)').matches && !globalThis.matchMedia?.('(any-pointer: fine)').matches) {
|
||||
this.add.text(width / 2, height - 92, MENU.touchNotice, uiStyle({ fontSize: '18px', color: UI.colors.danger })).setOrigin(0.5);
|
||||
}
|
||||
|
||||
// Ton darf erst nach einer Nutzeraktion starten (Browser-Richtlinie); die Musik
|
||||
// wird vorgemerkt und beginnt mit dem ersten Klick oder Tastendruck.
|
||||
services.audio.startMusic();
|
||||
this.input.once('pointerdown', () => services.audio.unlock());
|
||||
this.input.keyboard.once('keydown', () => services.audio.unlock());
|
||||
|
||||
if (!services.settings.isDisclaimerAccepted()) {
|
||||
this.openDisclaimer(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} mandatory Beim ersten Start muss bestätigt werden.
|
||||
*/
|
||||
openDisclaimer(mandatory) {
|
||||
this.menu.setEnabled(false);
|
||||
this.scene.launch('Disclaimer', { mandatory });
|
||||
this.scene.get('Disclaimer').events.once('disclaimer-closed', () => {
|
||||
this.menu.setEnabled(true);
|
||||
});
|
||||
}
|
||||
|
||||
startMission() {
|
||||
this.registry.get('services').audio.unlock();
|
||||
this.scene.start('Briefing');
|
||||
}
|
||||
|
||||
drawBackground(width, height) {
|
||||
const bg = this.add.graphics();
|
||||
bg.fillGradientStyle(0x0d1321, 0x0d1321, 0x2a0a10, 0x2a0a10, 1);
|
||||
bg.fillRect(0, 0, width, height);
|
||||
// Diagonale Streifen wie auf einem Propagandaplakat.
|
||||
bg.fillStyle(0xc1121f, 0.08);
|
||||
for (let x = -height; x < width; x += 90) {
|
||||
bg.fillTriangle(x, height, x + 45, height, x + height + 45, 0);
|
||||
}
|
||||
|
||||
for (let i = 0; i < SEARCHLIGHTS; i += 1) {
|
||||
const beam = this.add.triangle(width * (0.2 + 0.3 * i), height + 20, 0, 0, -120, -height * 1.1, 120, -height * 1.1, 0xf2c14e, 0.06);
|
||||
beam.setOrigin(0, 0);
|
||||
this.tweens.add({
|
||||
targets: beam,
|
||||
angle: { from: -25, to: 25 },
|
||||
duration: 4200 + i * 900,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.easeInOut',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createTicker(width, height) {
|
||||
const bar = this.add.rectangle(0, height - 36, width, 36, 0xc1121f, 0.9).setOrigin(0, 0);
|
||||
bar.setDepth(GAME.depth.hud);
|
||||
const line = PROPAGANDA.join(' ★ ');
|
||||
this.ticker = this.add
|
||||
.text(width, height - 18, line, uiStyle({ fontSize: '19px', strokeThickness: 0 }))
|
||||
.setOrigin(0, 0.5)
|
||||
.setDepth(GAME.depth.hud + 1);
|
||||
}
|
||||
|
||||
update(_time, delta) {
|
||||
if (!this.ticker) return;
|
||||
this.ticker.x -= (TICKER_SPEED * delta) / 1000;
|
||||
if (this.ticker.x < -this.ticker.width) this.ticker.x = this.scale.width;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Pausenmenü (Esc) mit Lautstärkereglern. Als HTML-Overlay umgesetzt, damit die
|
||||
* Schieberegler per Maus, Tastatur und Screenreader bedienbar sind.
|
||||
* @module scenes/PauseScene
|
||||
*/
|
||||
|
||||
import { ORBITAL } from '../config.js';
|
||||
import { formatSequence } from '../logic/format.js';
|
||||
import { PAUSE } from '../content/texts.js';
|
||||
import { oncePerEvent } from '../systems/keyboard.js';
|
||||
import { el, Overlay } from '../systems/Overlay.js';
|
||||
import { VOLUME_CHANNELS } from '../systems/Settings.js';
|
||||
|
||||
export class PauseScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('Pause');
|
||||
}
|
||||
|
||||
create() {
|
||||
const { width, height } = this.scale;
|
||||
const services = this.registry.get('services');
|
||||
this.input.setDefaultCursor('default');
|
||||
// Solange die Pause offen ist, soll der Browser Tasten normal verarbeiten
|
||||
// (Pfeiltasten für Schieberegler, Tab für den Fokus).
|
||||
this.input.keyboard.disableGlobalCapture();
|
||||
services.audio.duck(true);
|
||||
this.add.rectangle(0, 0, width, height, 0x000000, 0.45).setOrigin(0);
|
||||
|
||||
const content = [el('p', 'disclaimer__lead', PAUSE.subtitle)];
|
||||
for (const channel of VOLUME_CHANNELS) {
|
||||
content.push(this.volumeField(channel, services.settings));
|
||||
}
|
||||
const codes = el('p', 'field__hint');
|
||||
codes.textContent = ORBITAL.commands
|
||||
.map((c) => `${c.shortName}: ${formatSequence(c.sequence)}`)
|
||||
.join(' · ');
|
||||
content.push(codes);
|
||||
|
||||
let quitArmed = false;
|
||||
this.overlay = new Overlay({
|
||||
id: 'pause-overlay',
|
||||
title: PAUSE.title,
|
||||
content,
|
||||
buttons: [
|
||||
{ label: PAUSE.resume, onClick: () => this.resume(), testId: 'pause-resume' },
|
||||
{
|
||||
label: PAUSE.quit,
|
||||
secondary: true,
|
||||
testId: 'pause-quit',
|
||||
onClick: () => {
|
||||
// Zweistufig statt Browser-Dialog: erster Klick fragt nach, zweiter bricht ab.
|
||||
if (!quitArmed) {
|
||||
quitArmed = true;
|
||||
quitButton.textContent = PAUSE.confirmQuit;
|
||||
return;
|
||||
}
|
||||
this.quit();
|
||||
},
|
||||
},
|
||||
],
|
||||
onEscape: () => this.resume(),
|
||||
});
|
||||
const quitButton = this.overlay.actions.lastElementChild;
|
||||
|
||||
this.input.keyboard.on(
|
||||
'keydown-ESC',
|
||||
oncePerEvent((event) => {
|
||||
if (!event.repeat) this.resume();
|
||||
}),
|
||||
);
|
||||
this.events.once('shutdown', () => {
|
||||
services.audio.duck(false);
|
||||
if (this.overlay.element.isConnected) this.overlay.close();
|
||||
this.input.keyboard?.enableGlobalCapture();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schieberegler für einen Lautstärkekanal.
|
||||
* @param {'master'|'music'|'sfx'} channel
|
||||
* @param {import('../systems/Settings.js').Settings} settings
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
volumeField(channel, settings) {
|
||||
const field = el('label', 'field');
|
||||
const label = el('span', 'field__label', PAUSE.volumes[channel]);
|
||||
const input = /** @type {HTMLInputElement} */ (el('input', 'field__input'));
|
||||
input.type = 'range';
|
||||
input.min = '0';
|
||||
input.max = '100';
|
||||
input.step = '5';
|
||||
input.value = String(Math.round(settings.getVolume(channel) * 100));
|
||||
input.dataset.testid = `volume-${channel}`;
|
||||
input.addEventListener('input', () => settings.setVolume(channel, Number(input.value) / 100));
|
||||
field.append(label, input);
|
||||
return field;
|
||||
}
|
||||
|
||||
resume() {
|
||||
this.scene.resume('Game');
|
||||
this.scene.get('Game').input.setDefaultCursor('none');
|
||||
this.scene.stop();
|
||||
}
|
||||
|
||||
quit() {
|
||||
this.scene.stop('Hud');
|
||||
this.scene.stop('Game');
|
||||
this.scene.start('Menu');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Zugriff auf die JSON-API des Flask-Backends (ohne Phaser).
|
||||
*
|
||||
* Fehler werden nie geworfen, sondern als Ergebnisobjekt zurückgegeben: Ein
|
||||
* ausgefallener Highscore-Server darf das Spiel nicht unterbrechen.
|
||||
* @module systems/ApiClient
|
||||
*/
|
||||
|
||||
import { HIGHSCORE } from '../config.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} HighscoreEntry
|
||||
* @property {number} rank
|
||||
* @property {string} name
|
||||
* @property {number} score
|
||||
* @property {number} wave
|
||||
* @property {string} created_at
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SubmitResult
|
||||
* @property {boolean} ok
|
||||
* @property {number} [rank]
|
||||
* @property {HighscoreEntry[]} [highscores]
|
||||
* @property {string} [error] Fehlercode der API oder „network“.
|
||||
* @property {string} [message] Lesbare Meldung.
|
||||
* @property {Record<string, string>} [fields] Feldfehler bei Validierung.
|
||||
*/
|
||||
|
||||
export class ApiClient {
|
||||
/**
|
||||
* @param {{highscores: string, runs: string}} endpoints
|
||||
* @param {typeof fetch} [fetchImpl] Für Tests austauschbar.
|
||||
* @param {number} [timeoutMs]
|
||||
*/
|
||||
constructor(endpoints, fetchImpl = globalThis.fetch?.bind(globalThis), timeoutMs = HIGHSCORE.requestTimeoutMs) {
|
||||
this.endpoints = endpoints;
|
||||
this.fetchImpl = fetchImpl;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {RequestInit} [init]
|
||||
* @returns {Promise<{status: number, body: any}>}
|
||||
*/
|
||||
async request(url, init = {}) {
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), this.timeoutMs) : null;
|
||||
try {
|
||||
const response = await this.fetchImpl(url, {
|
||||
...init,
|
||||
signal: controller?.signal,
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json', ...(init.headers ?? {}) },
|
||||
});
|
||||
let body = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
return { status: response.status, body };
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet eine neue Spielrunde an.
|
||||
* @returns {Promise<string|null>} Run-Token oder null, wenn der Server nicht erreichbar ist.
|
||||
*/
|
||||
async startRun() {
|
||||
try {
|
||||
const { status, body } = await this.request(this.endpoints.runs, { method: 'POST' });
|
||||
return status === 201 && typeof body?.run_token === 'string' ? body.run_token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ok: boolean, highscores: HighscoreEntry[]}>}
|
||||
*/
|
||||
async fetchHighscores() {
|
||||
try {
|
||||
const { status, body } = await this.request(this.endpoints.highscores);
|
||||
if (status === 200 && Array.isArray(body?.highscores)) {
|
||||
return { ok: true, highscores: body.highscores };
|
||||
}
|
||||
} catch {
|
||||
// Netzwerkfehler oder Timeout – unten als Fehlschlag gemeldet.
|
||||
}
|
||||
return { ok: false, highscores: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reicht einen Highscore ein.
|
||||
* @param {{name: string, score: number, wave: number, durationS: number, runToken: string}} entry
|
||||
* @returns {Promise<SubmitResult>}
|
||||
*/
|
||||
async submitHighscore(entry) {
|
||||
const payload = {
|
||||
name: entry.name,
|
||||
score: Math.max(0, Math.floor(entry.score)),
|
||||
wave: Math.max(1, Math.floor(entry.wave)),
|
||||
duration_s: Math.max(0, Math.floor(entry.durationS)),
|
||||
run_token: entry.runToken,
|
||||
};
|
||||
try {
|
||||
const { status, body } = await this.request(this.endpoints.highscores, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (status === 201) {
|
||||
return { ok: true, rank: body?.rank, highscores: body?.highscores ?? [] };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: body?.error ?? `http_${status}`,
|
||||
message: body?.message ?? 'Unbekannter Fehler.',
|
||||
fields: body?.fields,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, error: 'network', message: 'Keine Verbindung zum Server.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Aufbau der Arena: Boden, Dekoration und Begrenzung.
|
||||
*
|
||||
* Mit geladenen Kenney-Kacheln entsteht ein Erdboden mit Felsen, fremdartiger Flora
|
||||
* und Flecken; ohne sie ein einfaches Schachbrett-Platzhaltermuster. Dekoration hat
|
||||
* keine Physik – sie soll nur den Boden lebendig machen, nicht den Weg versperren.
|
||||
* @module systems/Arena
|
||||
*/
|
||||
|
||||
import { COLORS, GAME } from '../config.js';
|
||||
import { pick, randFloat, randInt } from '../logic/rng.js';
|
||||
import { GROUND_FRAMES, GROUND_SHEET } from './Assets.js';
|
||||
import { TEXTURE_KEYS } from './Placeholders.js';
|
||||
|
||||
/** Abdunkelung des Bodens, damit Figuren und Projektile sich klar abheben. */
|
||||
const GROUND_TINT = 0x6f6458;
|
||||
/** Anzahl Deko-Objekte je Sorte (für eine 2400×1600-Arena abgestimmt). */
|
||||
const DECOR = Object.freeze({ dirtPatches: 180, rocks: 45, bushes: 28, splats: 14 });
|
||||
/** Flora und Flecken in „außerirdischen“ Farben statt Erdgrün. */
|
||||
const ALIEN_TINTS = Object.freeze([0x6d5a8f, 0x3f7f78, 0x7d5f86]);
|
||||
const SPLAT_TINT = 0x2f4a1a;
|
||||
/** Freiraum um die Startposition, damit dort nichts die Sicht stört. */
|
||||
const CLEAR_RADIUS = 180;
|
||||
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {() => number} rng
|
||||
*/
|
||||
export function buildArena(scene, rng) {
|
||||
const { width, height } = GAME.world;
|
||||
const depth = GAME.depth.ground;
|
||||
|
||||
if (scene.textures.exists(GROUND_SHEET.key)) {
|
||||
const key = GROUND_SHEET.key;
|
||||
const tile = GROUND_SHEET.frame;
|
||||
scene.add.tileSprite(0, 0, width, height, key, GROUND_FRAMES.dirtA).setOrigin(0).setDepth(depth).setTint(GROUND_TINT);
|
||||
// Zweite Erdsorte im Kachelraster verstreut: bricht das sichtbare Wiederholungsmuster.
|
||||
for (let i = 0; i < DECOR.dirtPatches; i += 1) {
|
||||
const x = randInt(rng, 0, Math.floor(width / tile) - 1) * tile;
|
||||
const y = randInt(rng, 0, Math.floor(height / tile) - 1) * tile;
|
||||
scene.add.image(x, y, key, GROUND_FRAMES.dirtB).setOrigin(0).setDepth(depth).setTint(GROUND_TINT);
|
||||
}
|
||||
const place = (frame, count, decorate) => {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const point = randomPoint(rng, width, height);
|
||||
const image = scene.add.image(point.x, point.y, key, frame(rng)).setDepth(depth + 1);
|
||||
image.setAngle(randFloat(rng, 0, 360));
|
||||
decorate(image);
|
||||
}
|
||||
};
|
||||
place((r) => pick(r, GROUND_FRAMES.rocks), DECOR.rocks, (img) => img.setScale(randFloat(rng, 0.45, 0.95)).setTint(0x8d9196));
|
||||
place((r) => pick(r, GROUND_FRAMES.bushes), DECOR.bushes, (img) => img.setScale(randFloat(rng, 0.5, 1.1)).setTint(pick(rng, ALIEN_TINTS)));
|
||||
place(() => GROUND_FRAMES.splat, DECOR.splats, (img) => img.setScale(randFloat(rng, 0.8, 1.6)).setTint(SPLAT_TINT).setAlpha(0.7));
|
||||
} else {
|
||||
scene.add.tileSprite(0, 0, width, height, TEXTURE_KEYS.ground).setOrigin(0).setDepth(depth);
|
||||
}
|
||||
|
||||
// Emblem der Vereinigten Erde als Bodenmarkierung der Landezone.
|
||||
scene.add
|
||||
.image(width / 2, height / 2, TEXTURE_KEYS.emblem)
|
||||
.setDisplaySize(300, 300)
|
||||
.setAlpha(0.14)
|
||||
.setDepth(depth + 1);
|
||||
|
||||
const border = scene.add.graphics().setDepth(depth + 2);
|
||||
border.lineStyle(10, COLORS.crimson, 0.9);
|
||||
border.strokeRect(5, 5, width - 10, height - 10);
|
||||
border.lineStyle(3, COLORS.gold, 0.9);
|
||||
border.strokeRect(14, 14, width - 28, height - 28);
|
||||
}
|
||||
|
||||
/** Zufallspunkt in der Arena außerhalb der freien Startzone. */
|
||||
function randomPoint(rng, width, height) {
|
||||
for (;;) {
|
||||
const x = randFloat(rng, 30, width - 30);
|
||||
const y = randFloat(rng, 30, height - 30);
|
||||
if (Math.hypot(x - width / 2, y - height / 2) > CLEAR_RADIUS) return { x, y };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Liste aller Grafikdateien und ihr Laden mit Rückfallebene.
|
||||
*
|
||||
* Jede Datei ist optional: Schlägt das Laden fehl (Datei fehlt, Netzwerkfehler),
|
||||
* erzeugt `Placeholders.js` unter demselben Schlüssel eine Ersatzgrafik. Quellen und
|
||||
* Lizenzen aller Dateien stehen in docs/ASSETS.md.
|
||||
* @module systems/Assets
|
||||
*/
|
||||
|
||||
/** Eigene Vektorgrafiken (SVG, CC0). Größe = Rastergröße im Spiel in Pixeln. */
|
||||
export const SPRITES = Object.freeze([
|
||||
{ key: 'player', file: 'sprites/president.svg', size: 64 },
|
||||
{ key: 'enemy-krabbler', file: 'sprites/krabbler.svg', size: 36 },
|
||||
{ key: 'enemy-sprungkralle', file: 'sprites/sprungkralle.svg', size: 48 },
|
||||
{ key: 'enemy-brutmutter', file: 'sprites/brutmutter.svg', size: 84 },
|
||||
{ key: 'enemy-nieter', file: 'sprites/nieter.svg', size: 36 },
|
||||
{ key: 'enemy-blechsoldat', file: 'sprites/blechsoldat.svg', size: 48 },
|
||||
{ key: 'enemy-walzmeister', file: 'sprites/walzmeister.svg', size: 88 },
|
||||
{ key: 'turret', file: 'sprites/turret.svg', size: 52 },
|
||||
{ key: 'supply-pod', file: 'sprites/supply-pod.svg', size: 48 },
|
||||
{ key: 'emblem', file: 'sprites/emblem.svg', size: 200 },
|
||||
]);
|
||||
|
||||
/** Kenney Particle Pack (CC0), auf 128 px verkleinert. */
|
||||
export const PARTICLES = Object.freeze([
|
||||
{ key: 'fx-smoke', file: 'kenney/particles/smoke.png' },
|
||||
{ key: 'fx-fire', file: 'kenney/particles/fire.png' },
|
||||
{ key: 'fx-spark', file: 'kenney/particles/spark.png' },
|
||||
{ key: 'fx-scorch', file: 'kenney/particles/scorch.png' },
|
||||
{ key: 'fx-muzzle', file: 'kenney/particles/muzzle.png' },
|
||||
{ key: 'fx-glow', file: 'kenney/particles/glow.png' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* Kenney Top-down Shooter (CC0): Ausschnitt aus dem Tilesheet als Streifen mit
|
||||
* 64-px-Kacheln. Die Reihenfolge der Kacheln steht in GROUND_FRAMES.
|
||||
*/
|
||||
export const GROUND_SHEET = Object.freeze({ key: 'ground-tiles', file: 'kenney/tiles/ground_strip.png', frame: 64 });
|
||||
|
||||
/** Kachelindizes im Bodenstreifen. */
|
||||
export const GROUND_FRAMES = Object.freeze({
|
||||
dirtA: 0,
|
||||
dirtB: 1,
|
||||
rocks: Object.freeze([2, 3, 4]),
|
||||
bushes: Object.freeze([5, 6]),
|
||||
splat: 7,
|
||||
});
|
||||
|
||||
/**
|
||||
* Stellt alle Dateien in die Lade-Warteschlange.
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {string} staticBase URL des static-Ordners (mit abschließendem /).
|
||||
*/
|
||||
export function queueAssets(scene, staticBase) {
|
||||
scene.load.setPath(`${staticBase}assets/`);
|
||||
for (const sprite of SPRITES) {
|
||||
scene.load.svg(sprite.key, sprite.file, { width: sprite.size, height: sprite.size });
|
||||
}
|
||||
for (const particle of PARTICLES) {
|
||||
scene.load.image(particle.key, particle.file);
|
||||
}
|
||||
scene.load.spritesheet(GROUND_SHEET.key, GROUND_SHEET.file, {
|
||||
frameWidth: GROUND_SHEET.frame,
|
||||
frameHeight: GROUND_SHEET.frame,
|
||||
});
|
||||
scene.load.setPath('');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {string} key
|
||||
* @returns {boolean} true, wenn die echte Grafik (nicht der Platzhalter) geladen ist.
|
||||
*/
|
||||
export function hasTexture(scene, key) {
|
||||
return scene.textures.exists(key);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Klangdateien (Kenney, CC0) und Abspieloptionen je Effekt.
|
||||
*
|
||||
* Mehrere Dateien pro Effekt werden zufällig abgewechselt, damit Dauerfeuer nicht
|
||||
* monoton klingt. Fehlt eine Datei oder kann der Browser OGG nicht dekodieren,
|
||||
* springt der Synthesizer in `Synth.js` ein.
|
||||
* @module systems/AudioAssets
|
||||
*/
|
||||
|
||||
/** Pfad relativ zu `static/assets/`. */
|
||||
export const AUDIO_PATH = 'kenney/audio/';
|
||||
|
||||
/** @type {Readonly<Record<string, readonly string[]>>} */
|
||||
export const SOUND_FILES = Object.freeze({
|
||||
'shot-carbine': ['laserSmall_000.ogg', 'laserSmall_001.ogg', 'laserSmall_002.ogg'],
|
||||
'shot-shotgun': ['laserLarge_000.ogg', 'laserLarge_001.ogg'],
|
||||
'shot-pistol': ['laserRetro_000.ogg', 'laserRetro_001.ogg'],
|
||||
'turret-shot': ['laserRetro_002.ogg'],
|
||||
'enemy-shot': ['laserSmall_003.ogg', 'laserSmall_004.ogg'],
|
||||
'hit-flesh': ['impactSoft_medium_000.ogg', 'impactSoft_medium_001.ogg', 'impactSoft_medium_002.ogg'],
|
||||
'hit-metal': ['impactMetal_light_000.ogg', 'impactMetal_light_001.ogg', 'impactMetal_light_002.ogg'],
|
||||
'death-swarm': ['slime_000.ogg', 'slime_001.ogg'],
|
||||
'death-legion': ['explosionCrunch_000.ogg', 'explosionCrunch_001.ogg'],
|
||||
'explosion-small': ['explosionCrunch_002.ogg', 'explosionCrunch_003.ogg'],
|
||||
'explosion-big': ['lowFrequency_explosion_000.ogg'],
|
||||
'player-hit': ['impactPunch_heavy_000.ogg', 'impactPunch_heavy_001.ogg'],
|
||||
'player-death': ['lowFrequency_explosion_001.ogg'],
|
||||
reload: ['impactPlate_light_000.ogg'],
|
||||
'reload-done': ['click_001.ogg'],
|
||||
switch: ['select_001.ogg'],
|
||||
'combo-step': ['tick_001.ogg'],
|
||||
'combo-fail': ['error_001.ogg'],
|
||||
'orbital-call': ['confirmation_001.ogg'],
|
||||
pickup: ['maximize_001.ogg'],
|
||||
brood: ['slime_001.ogg'],
|
||||
'ui-click': ['click_001.ogg'],
|
||||
'ui-confirm': ['confirmation_002.ogg'],
|
||||
'wave-start': ['jingles_HIT15.ogg'],
|
||||
'wave-cleared': ['jingles_STEEL07.ogg'],
|
||||
alarm: ['jingles_HIT11.ogg'],
|
||||
'game-over': ['jingles_PIZZI07.ogg'],
|
||||
// Nur synthetisch (kein passendes kurzes Sample): Ausweichrolle, Mörserpfeifen.
|
||||
dodge: [],
|
||||
mortar: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Lautstärke (0..1), Tonhöhe (1 = original) und Tonhöhenstreuung je Effekt.
|
||||
* Jingles laufen über den Musikkanal, damit „Musik leiser“ auch sie betrifft.
|
||||
* @type {Readonly<Record<string, {volume?: number, rate?: number, spread?: number, channel?: 'sfx'|'music'}>>}
|
||||
*/
|
||||
export const SOUND_OPTIONS = Object.freeze({
|
||||
'shot-carbine': { volume: 0.35, spread: 0.08 },
|
||||
'shot-shotgun': { volume: 0.5, rate: 0.8, spread: 0.06 },
|
||||
'shot-pistol': { volume: 0.4, spread: 0.06 },
|
||||
'turret-shot': { volume: 0.22, rate: 1.4, spread: 0.1 },
|
||||
'enemy-shot': { volume: 0.22, rate: 0.7, spread: 0.1 },
|
||||
'hit-flesh': { volume: 0.35, spread: 0.15 },
|
||||
'hit-metal': { volume: 0.3, spread: 0.15 },
|
||||
'death-swarm': { volume: 0.5, spread: 0.15 },
|
||||
'death-legion': { volume: 0.4, rate: 1.2, spread: 0.1 },
|
||||
'explosion-small': { volume: 0.6, spread: 0.1 },
|
||||
'explosion-big': { volume: 1 },
|
||||
'player-hit': { volume: 0.6, spread: 0.1 },
|
||||
'player-death': { volume: 1, rate: 0.8 },
|
||||
reload: { volume: 0.45 },
|
||||
'reload-done': { volume: 0.4 },
|
||||
switch: { volume: 0.4 },
|
||||
'combo-step': { volume: 0.5 },
|
||||
'combo-fail': { volume: 0.5 },
|
||||
'orbital-call': { volume: 0.6 },
|
||||
pickup: { volume: 0.6 },
|
||||
brood: { volume: 0.5, rate: 0.7 },
|
||||
'ui-click': { volume: 0.4 },
|
||||
'ui-confirm': { volume: 0.5 },
|
||||
'wave-start': { volume: 0.8, channel: 'music' },
|
||||
'wave-cleared': { volume: 0.8, channel: 'music' },
|
||||
alarm: { volume: 0.8, channel: 'music' },
|
||||
'game-over': { volume: 0.9, channel: 'music' },
|
||||
dodge: { volume: 0.35 },
|
||||
mortar: { volume: 0.3 },
|
||||
});
|
||||
|
||||
/** @returns {string[]} Alle benötigten Dateien ohne Duplikate. */
|
||||
export function allSoundFiles() {
|
||||
return [...new Set(Object.values(SOUND_FILES).flat())];
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Audio: Lautstärkekanäle (Gesamt, Musik, Effekte), Klangdateien, synthetischer
|
||||
* Ersatz und Hintergrundmusik – alles über die Web Audio API.
|
||||
*
|
||||
* Browser erlauben Ton erst nach einer Nutzerinteraktion. Die Dateien werden deshalb
|
||||
* vorab nur als Bytes geladen und erst in `unlock()` (erster Klick/Tastendruck)
|
||||
* dekodiert. Ohne Web Audio bleibt das Spiel stumm, aber spielbar.
|
||||
* @module systems/AudioManager
|
||||
*/
|
||||
|
||||
import { AUDIO } from '../config.js';
|
||||
import { allSoundFiles, AUDIO_PATH, SOUND_FILES, SOUND_OPTIONS } from './AudioAssets.js';
|
||||
import { MarchMusic } from './Music.js';
|
||||
import { createNoiseBuffer, SYNTH_RECIPES } from './Synth.js';
|
||||
|
||||
/** Obergrenze gleichzeitig klingender Effekte – schützt vor Übersteuern und CPU-Spitzen. */
|
||||
const MAX_VOICES = 24;
|
||||
/** Lautstärke der Musik während der Pause (Anteil). */
|
||||
const PAUSE_DUCK = 0.35;
|
||||
|
||||
export class AudioManager {
|
||||
/**
|
||||
* @param {import('./Settings.js').Settings} settings
|
||||
* @param {{staticBase?: string, fetchImpl?: typeof fetch}} [options]
|
||||
*/
|
||||
constructor(settings, options = {}) {
|
||||
this.settings = settings;
|
||||
this.baseUrl = `${options.staticBase ?? '/static/'}assets/${AUDIO_PATH}`;
|
||||
this.fetchImpl = options.fetchImpl ?? globalThis.fetch?.bind(globalThis);
|
||||
/** @type {AudioContext|null} */
|
||||
this.context = null;
|
||||
/** @type {{master: GainNode, music: GainNode, sfx: GainNode, duck: GainNode}|null} */
|
||||
this.gains = null;
|
||||
/** @type {Map<string, Promise<ArrayBuffer|null>>} Rohdaten je Datei (vor dem Entsperren). */
|
||||
this.raw = new Map();
|
||||
/** @type {Map<string, AudioBuffer>} Dekodierte Klänge je Datei. */
|
||||
this.buffers = new Map();
|
||||
/** @type {Map<string, number>} Letzte Abspielzeit je Effekt (gegen Übersteuern). */
|
||||
this.lastPlayed = new Map();
|
||||
this.activeVoices = 0;
|
||||
/** @type {MarchMusic|null} */
|
||||
this.music = null;
|
||||
this.musicWanted = false;
|
||||
this.noiseBuffer = null;
|
||||
settings.onVolumeChange(() => this.applyVolumes());
|
||||
}
|
||||
|
||||
/** Lädt die Klangdateien als Bytes vor (ohne AudioContext, also vor jeder Interaktion möglich). */
|
||||
preload() {
|
||||
if (!this.fetchImpl) return;
|
||||
for (const file of allSoundFiles()) {
|
||||
if (this.raw.has(file)) continue;
|
||||
const request = this.fetchImpl(this.baseUrl + file)
|
||||
.then((response) => (response.ok ? response.arrayBuffer() : null))
|
||||
.catch(() => null);
|
||||
this.raw.set(file, request);
|
||||
}
|
||||
}
|
||||
|
||||
/** Erzeugt den AudioContext nach der ersten Nutzerinteraktion und dekodiert die Klänge. */
|
||||
unlock() {
|
||||
if (this.context) {
|
||||
if (this.context.state === 'suspended') this.context.resume().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const Ctor = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
try {
|
||||
this.context = new Ctor();
|
||||
} catch {
|
||||
this.context = null;
|
||||
return;
|
||||
}
|
||||
const ctx = this.context;
|
||||
const master = ctx.createGain();
|
||||
const duck = ctx.createGain();
|
||||
const music = ctx.createGain();
|
||||
const sfx = ctx.createGain();
|
||||
music.connect(duck).connect(master);
|
||||
sfx.connect(master);
|
||||
master.connect(ctx.destination);
|
||||
this.gains = { master, music, sfx, duck };
|
||||
this.noiseBuffer = createNoiseBuffer(ctx);
|
||||
this.applyVolumes();
|
||||
this.decodeAll();
|
||||
if (this.musicWanted) this.startMusic();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
decodeAll() {
|
||||
for (const [file, request] of this.raw) {
|
||||
request.then((bytes) => {
|
||||
if (!bytes || !this.context) return;
|
||||
// decodeAudioData übernimmt den Puffer – eine Kopie hält die Rohdaten wiederverwendbar.
|
||||
this.context
|
||||
.decodeAudioData(bytes.slice(0))
|
||||
.then((buffer) => this.buffers.set(file, buffer))
|
||||
.catch(() => {
|
||||
// Format nicht unterstützt (z. B. OGG in alten Safari-Versionen): Synthesizer übernimmt.
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Überträgt die Einstellungen auf die Gain-Knoten. */
|
||||
applyVolumes() {
|
||||
if (!this.gains || !this.context) return;
|
||||
const t = this.context.currentTime;
|
||||
this.gains.master.gain.setTargetAtTime(this.settings.getVolume('master'), t, 0.02);
|
||||
this.gains.music.gain.setTargetAtTime(this.settings.getVolume('music'), t, 0.02);
|
||||
this.gains.sfx.gain.setTargetAtTime(this.settings.getVolume('sfx'), t, 0.02);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Mindestabstand und Stimmenlimit.
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldPlay(name) {
|
||||
if (!this.context || !this.gains || this.context.state !== 'running') return false;
|
||||
if (this.activeVoices >= MAX_VOICES) return false;
|
||||
const now = performance.now();
|
||||
const last = this.lastPlayed.get(name) ?? -Infinity;
|
||||
if (now - last < AUDIO.sfxMinIntervalMs) return false;
|
||||
this.lastPlayed.set(name, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt einen Effekt: Klangdatei, falls dekodiert, sonst synthetischer Ersatz.
|
||||
* @param {string} name Schlüssel aus SOUND_FILES.
|
||||
*/
|
||||
play(name) {
|
||||
if (!this.shouldPlay(name)) return;
|
||||
const ctx = /** @type {AudioContext} */ (this.context);
|
||||
const gains = /** @type {NonNullable<AudioManager['gains']>} */ (this.gains);
|
||||
const options = SOUND_OPTIONS[name] ?? {};
|
||||
const destination = options.channel === 'music' ? gains.music : gains.sfx;
|
||||
const volume = options.volume ?? 1;
|
||||
const variants = (SOUND_FILES[name] ?? []).map((file) => this.buffers.get(file)).filter(Boolean);
|
||||
|
||||
this.activeVoices += 1;
|
||||
const release = () => {
|
||||
this.activeVoices = Math.max(0, this.activeVoices - 1);
|
||||
};
|
||||
|
||||
if (variants.length > 0) {
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = variants[Math.floor(Math.random() * variants.length)];
|
||||
// Leichte Tonhöhenstreuung: derselbe Effekt klingt nie exakt gleich.
|
||||
const spread = options.spread ?? 0;
|
||||
source.playbackRate.value = (options.rate ?? 1) * (1 + (Math.random() * 2 - 1) * spread);
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = volume;
|
||||
source.connect(gain).connect(destination);
|
||||
source.onended = release;
|
||||
source.start();
|
||||
return;
|
||||
}
|
||||
const recipe = SYNTH_RECIPES[name];
|
||||
if (recipe && this.noiseBuffer) recipe(ctx, destination, this.noiseBuffer, volume);
|
||||
// Synthetische Klänge sind kurz; die Stimme wird nach einer Sekunde freigegeben.
|
||||
setTimeout(release, 1000);
|
||||
}
|
||||
|
||||
/** Startet die Marschmusik (oder merkt sie vor, bis Audio entsperrt ist). */
|
||||
startMusic() {
|
||||
this.musicWanted = true;
|
||||
if (!this.context || !this.gains || !this.noiseBuffer || this.music) return;
|
||||
this.music = new MarchMusic(this.context, this.gains.music, this.noiseBuffer);
|
||||
this.music.start();
|
||||
}
|
||||
|
||||
stopMusic() {
|
||||
this.musicWanted = false;
|
||||
this.music?.stop();
|
||||
this.music = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Senkt die Musik während der Pause ab.
|
||||
* @param {boolean} ducked
|
||||
*/
|
||||
duck(ducked) {
|
||||
if (!this.gains || !this.context) return;
|
||||
this.gains.duck.gain.setTargetAtTime(ducked ? PAUSE_DUCK : 1, this.context.currentTime, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Visuelle Rückmeldung: Explosionen, Funken, Rauch, Brandspuren, Mündungsfeuer,
|
||||
* schwebende Texte und Bildschirmwackeln.
|
||||
*
|
||||
* Rückmeldung ist kein Luxus: Ohne Treffer-Aufblitzen und Funken fühlt sich Schießen
|
||||
* „hohl“ an und man sieht nicht, ob man überhaupt trifft. Sind die Kenney-Partikel
|
||||
* nicht geladen, fällt alles auf einfache Kreise zurück.
|
||||
* @module systems/Effects
|
||||
*/
|
||||
|
||||
import { COLORS, FX, GAME } from '../config.js';
|
||||
import { UI } from '../ui/theme.js';
|
||||
import { TEXTURE_KEYS } from './Placeholders.js';
|
||||
|
||||
/** Kenney-Partikel sind 128 px groß; auf diese Größen wird skaliert. */
|
||||
const PARTICLE_SOURCE_SIZE = 128;
|
||||
/** Höchstzahl gleichzeitig sichtbarer Brandspuren (älteste verschwinden zuerst). */
|
||||
const MAX_SCORCH_MARKS = 40;
|
||||
const SCORCH_LIFETIME_MS = 20000;
|
||||
const MUZZLE_FLASH_MS = 70;
|
||||
|
||||
export class Effects {
|
||||
/** @param {Phaser.Scene} scene */
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
const has = (key) => scene.textures.exists(key);
|
||||
this.hasKenney = has('fx-spark') && has('fx-smoke') && has('fx-fire');
|
||||
/** @type {Phaser.GameObjects.Image[]} */
|
||||
this.scorchMarks = [];
|
||||
|
||||
// Gemeinsame Emitter: deutlich günstiger als ein Emitter pro Treffer.
|
||||
const sparkKey = has('fx-spark') ? 'fx-spark' : TEXTURE_KEYS.particle;
|
||||
const sparkScale = has('fx-spark') ? 14 / PARTICLE_SOURCE_SIZE : 0.9;
|
||||
this.sparks = scene.add.particles(0, 0, sparkKey, {
|
||||
lifespan: 380,
|
||||
speed: { min: 80, max: 280 },
|
||||
scale: { start: sparkScale, end: 0 },
|
||||
alpha: { start: 1, end: 0 },
|
||||
rotate: { min: 0, max: 360 },
|
||||
blendMode: 'ADD',
|
||||
emitting: false,
|
||||
});
|
||||
this.sparks.setDepth(GAME.depth.effects);
|
||||
|
||||
if (this.hasKenney) {
|
||||
this.fire = scene.add.particles(0, 0, 'fx-fire', {
|
||||
lifespan: 450,
|
||||
speed: { min: 20, max: 140 },
|
||||
scale: { start: 0.35, end: 0 },
|
||||
alpha: { start: 0.9, end: 0 },
|
||||
rotate: { min: 0, max: 360 },
|
||||
blendMode: 'ADD',
|
||||
emitting: false,
|
||||
});
|
||||
this.smoke = scene.add.particles(0, 0, 'fx-smoke', {
|
||||
lifespan: 1100,
|
||||
speed: { min: 10, max: 60 },
|
||||
scale: { start: 0.3, end: 0.9 },
|
||||
alpha: { start: 0.45, end: 0 },
|
||||
rotate: { min: 0, max: 360 },
|
||||
tint: 0x555555,
|
||||
emitting: false,
|
||||
});
|
||||
this.fire.setDepth(GAME.depth.effects);
|
||||
this.smoke.setDepth(GAME.depth.effects - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkenregen an einer Stelle.
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} color
|
||||
* @param {number} [count]
|
||||
*/
|
||||
burst(x, y, color, count = 8) {
|
||||
this.sparks.setParticleTint(color);
|
||||
this.sparks.explode(count, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explosion mit Feuer, Rauch, Lichtblitz und Brandspur. Der Ring zeigt den
|
||||
* tatsächlichen Wirkungsradius, damit Spieler lernen, wie weit Schaden reicht.
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} radius
|
||||
* @param {number} color
|
||||
*/
|
||||
explosion(x, y, radius, color) {
|
||||
const ring = this.scene.add.circle(x, y, radius, color, 0.22).setDepth(GAME.depth.effects);
|
||||
ring.setStrokeStyle(4, color, 0.9);
|
||||
ring.setScale(0.15);
|
||||
this.scene.tweens.add({
|
||||
targets: ring,
|
||||
scale: 1,
|
||||
alpha: 0,
|
||||
duration: 380,
|
||||
ease: 'Cubic.easeOut',
|
||||
onComplete: () => ring.destroy(),
|
||||
});
|
||||
|
||||
if (this.hasKenney) {
|
||||
const amount = Math.min(28, Math.round(radius / 6));
|
||||
this.fire.explode(amount, x, y);
|
||||
this.smoke.explode(Math.ceil(amount / 2), x, y);
|
||||
this.glow(x, y, radius * 1.6, color);
|
||||
this.scorch(x, y, radius);
|
||||
}
|
||||
this.burst(x, y, color, Math.min(40, Math.round(radius / 5)));
|
||||
this.burst(x, y, COLORS.cream, 8);
|
||||
this.shake('explosion');
|
||||
}
|
||||
|
||||
/** Kurzer, additiver Lichtblitz. */
|
||||
glow(x, y, size, color) {
|
||||
if (!this.scene.textures.exists('fx-glow')) return;
|
||||
const flash = this.scene.add
|
||||
.image(x, y, 'fx-glow')
|
||||
.setDisplaySize(size, size)
|
||||
.setTint(color)
|
||||
.setBlendMode(Phaser.BlendModes.ADD)
|
||||
.setDepth(GAME.depth.effects);
|
||||
this.scene.tweens.add({ targets: flash, alpha: 0, duration: 260, onComplete: () => flash.destroy() });
|
||||
}
|
||||
|
||||
/** Brandspur auf dem Boden, die langsam verblasst. */
|
||||
scorch(x, y, radius) {
|
||||
if (!this.scene.textures.exists('fx-scorch')) return;
|
||||
const mark = this.scene.add
|
||||
.image(x, y, 'fx-scorch')
|
||||
.setDisplaySize(radius * 2.2, radius * 2.2)
|
||||
.setAngle(Math.random() * 360)
|
||||
.setTint(0x1a1410)
|
||||
.setAlpha(0.55)
|
||||
.setDepth(GAME.depth.ground + 2);
|
||||
this.scorchMarks.push(mark);
|
||||
if (this.scorchMarks.length > MAX_SCORCH_MARKS) this.scorchMarks.shift()?.destroy();
|
||||
this.scene.tweens.add({
|
||||
targets: mark,
|
||||
alpha: 0,
|
||||
delay: SCORCH_LIFETIME_MS,
|
||||
duration: 2000,
|
||||
onComplete: () => {
|
||||
this.scorchMarks = this.scorchMarks.filter((m) => m !== mark);
|
||||
mark.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufsteigender, verblassender Text (Punkte, Meldungen am Ort).
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {string} text
|
||||
* @param {string} [color]
|
||||
* @param {number} [fontSize]
|
||||
*/
|
||||
floatingText(x, y, text, color = '#f2c14e', fontSize = 18) {
|
||||
const label = this.scene.add
|
||||
.text(x, y, text, {
|
||||
fontFamily: UI.uiFont,
|
||||
fontStyle: 'bold',
|
||||
fontSize: `${fontSize}px`,
|
||||
color,
|
||||
stroke: '#0d1321',
|
||||
strokeThickness: 4,
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(GAME.depth.effects);
|
||||
this.scene.tweens.add({
|
||||
targets: label,
|
||||
y: y - 36,
|
||||
alpha: 0,
|
||||
duration: FX.damageNumberMs,
|
||||
ease: 'Quad.easeOut',
|
||||
onComplete: () => label.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kamera wackeln lassen.
|
||||
* @param {'hit'|'explosion'} kind
|
||||
*/
|
||||
shake(kind) {
|
||||
const cfg = FX.screenShake[kind];
|
||||
this.scene.cameras.main.shake(cfg.durationMs, cfg.intensity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lässt ein Sprite kurz weiß aufblitzen (Treffer-Feedback).
|
||||
* @param {Phaser.GameObjects.Sprite} sprite
|
||||
*/
|
||||
flash(sprite) {
|
||||
sprite.setTintFill(0xffffff);
|
||||
this.scene.time.delayedCall(FX.hitFlashMs, () => {
|
||||
if (sprite.active) sprite.clearTint();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachbild einer Figur (Ausweichrolle): verdeutlicht Richtung und Tempo der Bewegung.
|
||||
* @param {Phaser.GameObjects.Sprite} sprite
|
||||
*/
|
||||
afterimage(sprite) {
|
||||
const ghost = this.scene.add
|
||||
.image(sprite.x, sprite.y, sprite.texture.key)
|
||||
.setRotation(sprite.rotation)
|
||||
.setAlpha(0.45)
|
||||
.setTint(COLORS.gold)
|
||||
.setDepth(sprite.depth - 1);
|
||||
this.scene.tweens.add({ targets: ghost, alpha: 0, scale: 1.15, duration: 260, onComplete: () => ghost.destroy() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mündungsfeuer an der Waffenspitze.
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} angle Schussrichtung (Bogenmaß).
|
||||
* @param {number} [color]
|
||||
*/
|
||||
muzzle(x, y, angle, color = COLORS.playerBullet) {
|
||||
if (this.scene.textures.exists('fx-muzzle')) {
|
||||
// Die Kenney-Grafik zeigt nach oben – um 90° drehen, damit sie in Schussrichtung zeigt.
|
||||
const flash = this.scene.add
|
||||
.image(x, y, 'fx-muzzle')
|
||||
.setOrigin(0.5, 0.9)
|
||||
.setRotation(angle + Math.PI / 2)
|
||||
.setScale(0.26)
|
||||
.setTint(color)
|
||||
.setBlendMode(Phaser.BlendModes.ADD)
|
||||
.setDepth(GAME.depth.effects);
|
||||
this.scene.tweens.add({ targets: flash, alpha: 0, duration: MUZZLE_FLASH_MS, onComplete: () => flash.destroy() });
|
||||
} else {
|
||||
this.burst(x, y, color, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Bodenmarkierung für verzögerte Einschläge (Orbital-Befehle, Mörser).
|
||||
*
|
||||
* Die Markierung zeigt den tatsächlichen Wirkungsradius und füllt sich bis zum
|
||||
* Einschlag – so kann der Spieler Gefahr erkennen und ausweichen.
|
||||
* @module systems/GroundMarker
|
||||
*/
|
||||
|
||||
import { GAME } from '../config.js';
|
||||
import { UI } from '../ui/theme.js';
|
||||
|
||||
export class GroundMarker {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} radius
|
||||
* @param {number} color
|
||||
* @param {number} startAt Spielzeit bei Erstellung.
|
||||
* @param {number} impactAt Spielzeit des Einschlags.
|
||||
* @param {{label?: string, beam?: boolean}} [options]
|
||||
*/
|
||||
constructor(scene, x, y, radius, color, startAt, impactAt, options = {}) {
|
||||
this.scene = scene;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.radius = radius;
|
||||
this.color = color;
|
||||
this.startAt = startAt;
|
||||
this.impactAt = impactAt;
|
||||
this.beam = options.beam ?? false;
|
||||
this.graphics = scene.add.graphics().setDepth(GAME.depth.markers);
|
||||
this.label = options.label
|
||||
? scene.add
|
||||
.text(x, y, '', {
|
||||
fontFamily: UI.uiFont,
|
||||
fontStyle: 'bold',
|
||||
fontSize: '16px',
|
||||
color: '#f4ecd8',
|
||||
stroke: '#0d1321',
|
||||
strokeThickness: 4,
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setDepth(GAME.depth.markers + 1)
|
||||
: null;
|
||||
this.labelText = options.label ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet den aktuellen Zustand.
|
||||
* @param {number} now
|
||||
*/
|
||||
update(now) {
|
||||
const total = Math.max(1, this.impactAt - this.startAt);
|
||||
const progress = Math.min(1, Math.max(0, (now - this.startAt) / total));
|
||||
// Puls wird zum Einschlag hin schneller – intuitive Warnung ohne Text.
|
||||
const pulse = 0.5 + 0.5 * Math.sin(now / (120 - 80 * progress));
|
||||
const g = this.graphics;
|
||||
g.clear();
|
||||
g.fillStyle(this.color, 0.12 + 0.1 * pulse);
|
||||
g.fillCircle(this.x, this.y, this.radius);
|
||||
g.fillStyle(this.color, 0.35);
|
||||
g.fillCircle(this.x, this.y, this.radius * progress);
|
||||
g.lineStyle(3, this.color, 0.6 + 0.4 * pulse);
|
||||
g.strokeCircle(this.x, this.y, this.radius);
|
||||
if (this.beam) {
|
||||
g.lineStyle(2 + 6 * progress, this.color, 0.25 + 0.5 * progress);
|
||||
g.lineBetween(this.x, this.y - 900, this.x, this.y);
|
||||
}
|
||||
if (this.label) {
|
||||
const seconds = Math.max(0, (this.impactAt - now) / 1000).toFixed(1).replace('.', ',');
|
||||
this.label.setText(`${this.labelText} ${seconds}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {number} now @returns {boolean} */
|
||||
isDue(now) {
|
||||
return now >= this.impactAt;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.graphics.destroy();
|
||||
this.label?.destroy();
|
||||
}
|
||||
}
|
||||